IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Voir le flux RSS

XeGregory

[Actualité] Scrapix, un « Aspirateur » (Web Crawler)

Note : 24 votes pour une moyenne de 4,88.
par , 26/10/2025 à 09h59 (2067 Affichages)
Scrapix, un « aspirateur » web (web crawler) simple orienté VCL qui gère : la récupération HTTP, l’extraction de liens et de ressources (images, documents, audio, vidéo, ressources web), le respect optionnel de robots.txt, un mécanisme facultatif de téléchargement des ressources, des limites (nombre de fichiers trouvés, nombre de pages explorées), et des mises à jour UI thread-safe vers un TscListView et un TscStatusBar.

Le crawler est conçu pour être lancé depuis un thread d’arrière-plan et pour mettre à jour l’interface en toute sécurité via des wrappers TThread.Queue.
Il expose des commandes pour démarrer, mettre en pause, reprendre, annuler et attendre l’arrêt.

Nom : Capture d'écran 2025-10-25 155043.png
Affichages : 377
Taille : 102,2 Ko

Scrapix.Core.pas
Code : Sélectionner tout - Visualiser dans une fenêtre à part
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
unit Scrapix.Core;

interface

uses
  {Winapi}
  WinApi.Windows, WinApi.Messages,
  {System}
  System.SysUtils, System.Classes, System.Generics.Collections,
  System.RegularExpressions, System.Types, System.Net.HttpClient,
  System.Net.URLClient, System.IOUtils, System.Threading, System.SyncObjs,
  {Vcl}
  Vcl.ComCtrls, Vcl.StdCtrls, Vcl.CheckLst, Vcl.Graphics,
  {StyleControls VCL}
  scControls,
  {Translate.Core}
  Translate.Core;

const
  // États d'exploration
  STATE_RUNNING = 0;
  STATE_PAUSED = 1;
  STATE_CANCEL = 2;

  // User-Agent utilisé pour toutes les requêtes HTTP
  UserAgent = 'Scrapix/1.0';

type
  TScrapix = class
  private
    { --- Données de suivi et compteurs --- }
    VisitedLinks: TDictionary<string, Boolean>;
    FFoundFiles: TDictionary<string, Boolean>;
    FBrokenLinks: TDictionary<string, Boolean>;
    TotalLinks: Integer;
    FileCount: Integer;
    BrokenCount: Integer;
    FRobotsBlocked: Integer;
    FLinksTraversed: Integer;

    { --- Contrôle d'exécution --- }
    FState: Integer;
    FPauseEvent: TEvent;
    FStoppedEvent: TEvent;

    { --- Paramètres de crawl --- }
    RequestTimeoutMs: Integer;
    RequestDelayMs: Integer;
    SameDomainOnly: Boolean;
    RootDomain: string;

    { --- Téléchargement --- }
    FAutoDownload: Boolean;
    DownloadFolder: string;

    { --- Robots.txt --- }
    RobotsRules: TDictionary<string, TStringList>;
    FRespectRobots: Boolean;

    { --- Filtres de ressources --- }
    FSearchImages: Boolean;
    FSearchDocuments: Boolean;
    FSearchAudio: Boolean;
    FSearchVideo: Boolean;
    FSearchWeb: Boolean;

    { --- Exécution courante --- }
    FRunning: Boolean;
    FMaxDepth: Integer;

    { --- Chemins de rapports temporaires --- }
    FVisitedFilePath: string;
    FBrokenFilePath: string;
    FFoundFilePath: string;

    FLogFilePath: string;
    FLogLock: TCriticalSection;

    FDisableUIUpdates: Boolean;

    { --- Limites configurables --- }
    FFoundFilesLimit: Integer;
    FExploreLimit: Integer;

    { --- Fonctions internes --- }

    // Retourne true si les mises à jour UI sont autorisées
    function UIUpdatesAllowed: Boolean;

    // Récupère le contenu d'une URL via GET et met à jour le ListView (thread-safe)
    function GetWebContent(const URL: string; ListView: TscListView;
      Depth: Integer; Logging: TscListBox): string;

    // Extrait les liens <a href="..."> et normalise les URL
    procedure ExtractLinks(const HTML: string; BaseURL: string;
      var LinkList: TStringList);

    // Extrait sources médias (images, docs, audio, vidéo, ressources web) selon les flags actifs
    procedure ExtractMediaSources(const HTML: string; BaseURL: string;
      var ImageList, DocList, AudioList, VideoList, WebList: TStringList);

    // Incrémente le compteur d'URL bloquées par robots.txt et met à jour le StatusBar
    procedure IncrementRobotsBlocked(StatusBar: TscStatusBar);

    // Incrémente le compteur de liens parcourus et applique la limite d'exploration
    procedure IncrementLinksTraversed(StatusBar: TscStatusBar);

    // Traite et enregistre un lien cassé : UI, dictionnaire, fichier rapport
    procedure MarkBrokenLink(const URL: string; ListView: TscListView;
      StatusBar: TscStatusBar; Logging: TscListBox);

    // Vérifie la disponibilité d'un fichier via HEAD; fallback GET range si HEAD échoue
    function IsFileAvailable(const URL: string; ListView: TscListView;
      StatusBar: TscStatusBar; Depth: Integer; Logging: TscListBox): Boolean;

    // Télécharge une ressource dans le dossier de téléchargement (organisé par type)
    function DownloadFile(const URL: string; Client: THTTPClient;
      out LocalPath: string; Logging: TscListBox): Boolean;

    // Routine factorisée pour traiter une liste de ressources (vérif, robots, dispo, download)
    procedure ProcessResourceGroup(ResourceList: TStringList;
      const AcceptExts: array of string; StatusBar: TscStatusBar;
      ListView: TscListView; Depth: Integer; const DefaultUIType: string;
      Logging: TscListBox);

    // Traite toutes les listes de ressources extraites d'une page (appel ProcessResourceGroup)
    procedure ProcessFoundFiles(ImageList, DocList, AudioList, VideoList,
      WebList: TStringList; StatusBar: TscStatusBar; ListView: TscListView;
      CheckList: TscCheckListBox; Depth: Integer; Logging: TscListBox);

    // Exploration récursive d'une page : récupération, extraction, traitement, récursion
    procedure ExploreLinksRecursive(const URL: string; ListView: TscListView;
      StatusBar: TscStatusBar; CheckList: TscCheckListBox; Depth: Integer;
      Logging: TscListBox);

    // Vérifie si LinkURL appartient au même domaine que BaseURL (ou sous-domaine)
    function IsSameDomain(const BaseURL, LinkURL: string): Boolean;

    // Normalise une URL relative ou absolue en URL absolue utilisable
    function NormalizeURL(const BaseURL, RelOrAbsURL: string): string;

    // Wrappers thread-safe pour mise à jour ListView / StatusBar
    procedure SafeUpdateListViewStatus(ListView: TscListView;
      const URL, StatusText: string; const Method: string = '');
    procedure SafeUpdateListViewDownloadState(ListView: TscListView;
      const URL, DownloadState: string);
    procedure SafeUpdateListViewInfo(ListView: TscListView; const URL: string;
      RespMs: Integer; const SizeBytes: string; Depth: Integer);
    procedure SafeScrollListViewToBottom(ListView: TscListView);
    procedure SafeSetStatusBarPanel(StatusBar: TscStatusBar;
      PanelIndex: Integer; const Text: string);

    // Logging thread-safe vers une TscListBox
    procedure SafeLog(Logging: TscListBox; const Msg: string);

    // robots.txt helpers : parse, cache et vérifie l'autorisation
    function ParseRobots(const RobotsText: string;
      OutList: TStringList): Boolean;
    function EnsureRobotsForHost(const Host, Scheme: string): Boolean;
    function IsAllowedByRobots(const URL: string): Boolean;

  public
    // Constructeur : initialise structure et valeurs par défaut
    constructor Create;
    // Destructeur : annule, attend et libère ressources
    destructor Destroy; override;

    // Configure les paramètres du crawl (timeouts, limites, options)
    procedure ConfigureCrawl(const ARequestTimeoutMs, ARequestDelayMs: Integer;
      ASameDomainOnly: Boolean; AAutoDownload: Boolean; ARespectRobots: Boolean;
      AFoundFilesLimit: Integer; AExploreLimit: Integer);

    // Démarre l'exploration synchroniquement ; crée rapports si demandé
    procedure ExploreLinks(const URL: string; ListView: TscListView;
      StatusBar: TscStatusBar; CheckList: TscCheckListBox; MaxDepth: Integer;
      SaveBrokenToFile, SaveVisitedToFile, SaveFoundFilesToFile: Boolean;
      Logging: TscListBox);

    // Mettre en pause, reprendre ou annuler l'exploration
    procedure PauseExploration;
    procedure ResumeExploration;
    procedure CancelExploration;
    function IsCanceled: Boolean;
    function IsPaused: Boolean;
    function IsRunning: Boolean;

    // Attend l'arrêt complet (bloquant) ; supprime le paramètre Timeout
    function WaitForStop: Boolean;

    // Applique les filtres de types de fichiers depuis une CheckList UI
    procedure ApplyFileTypeFiltersFromCheckList(CheckList: TscCheckListBox);

    // Autorise la désactivation des mises à jour UI (tests, performances)
    property DisableUIUpdates: Boolean read FDisableUIUpdates
      write FDisableUIUpdates;
  end;

implementation

uses
  System.Net.Mime, System.StrUtils;

function TScrapix.UIUpdatesAllowed: Boolean;
begin
  Result := not FDisableUIUpdates;
end;

procedure TScrapix.SafeScrollListViewToBottom(ListView: TscListView);
begin
  if not UIUpdatesAllowed then
    Exit;
  if ListView = nil then
    Exit;

  if TThread.Current.ThreadID = MainThreadID then
  begin
    if (csDestroying in ListView.ComponentState) or
      (not ListView.HandleAllocated) then
      Exit;
    if ListView.Items.Count > 0 then
      ListView.Items[ListView.Items.Count - 1].MakeVisible(False);
  end
  else
  begin
    TThread.Queue(nil,
      procedure
      begin
        if not UIUpdatesAllowed then
          Exit;
        if (ListView = nil) or (csDestroying in ListView.ComponentState) or
          (not ListView.HandleAllocated) then
          Exit;
        if ListView.Items.Count > 0 then
          ListView.Items[ListView.Items.Count - 1].MakeVisible(False);
      end);
  end;
end;

procedure TScrapix.SafeSetStatusBarPanel(StatusBar: TscStatusBar;
PanelIndex: Integer; const Text: string);
begin
  if not UIUpdatesAllowed then
    Exit;
  if StatusBar = nil then
    Exit;
  if PanelIndex < 0 then
    Exit;

  if TThread.Current.ThreadID = MainThreadID then
  begin
    if (csDestroying in StatusBar.ComponentState) or
      (not StatusBar.HandleAllocated) then
      Exit;
    if PanelIndex < StatusBar.Panels.Count then
      StatusBar.Panels[PanelIndex].Text := Text;
  end
  else
  begin
    TThread.Queue(nil,
      procedure
      begin
        if not UIUpdatesAllowed then
          Exit;
        if (StatusBar = nil) or (csDestroying in StatusBar.ComponentState) or
          (not StatusBar.HandleAllocated) then
          Exit;
        if PanelIndex < StatusBar.Panels.Count then
          StatusBar.Panels[PanelIndex].Text := Text;
      end);
  end;
end;

procedure TScrapix.SafeUpdateListViewStatus(ListView: TscListView;
const URL, StatusText: string; const Method: string = '');
var
  sURL, sStatus, sMethod: string;
  I: Integer;
  Item: TListItem;
begin
  if not UIUpdatesAllowed then
    Exit;
  if ListView = nil then
    Exit;

  sURL := NormalizeURL(URL, URL);
  if sURL = '' then
    sURL := URL;
  sStatus := StatusText;
  sMethod := Method;

  if TThread.Current.ThreadID = MainThreadID then
  begin
    if (csDestroying in ListView.ComponentState) or
      (not ListView.HandleAllocated) then
      Exit;

    for I := 0 to ListView.Items.Count - 1 do
    begin
      Item := ListView.Items[I];
      if SameText(Item.Caption, sURL) or SameText(Item.Caption, URL) then
      begin
        while Item.SubItems.Count < 6 do
          Item.SubItems.Add('');
        Item.SubItems[0] := sStatus;
        Item.SubItems[5] := sMethod;
        SafeScrollListViewToBottom(ListView);
        Exit;
      end;
    end;

    Item := ListView.Items.Add;
    Item.Caption := sURL;
    while Item.SubItems.Count < 6 do
      Item.SubItems.Add('');
    Item.SubItems[0] := sStatus;
    Item.SubItems[5] := sMethod;
    SafeScrollListViewToBottom(ListView);
  end
  else
  begin
    TThread.Queue(nil,
      procedure
      var
        I2: Integer;
        It: TListItem;
      begin
        if not UIUpdatesAllowed then
          Exit;
        if (ListView = nil) or (csDestroying in ListView.ComponentState) or
          (not ListView.HandleAllocated) then
          Exit;
        for I2 := 0 to ListView.Items.Count - 1 do
        begin
          It := ListView.Items[I2];
          if SameText(It.Caption, sURL) or SameText(It.Caption, URL) then
          begin
            while It.SubItems.Count < 6 do
              It.SubItems.Add('');
            It.SubItems[0] := sStatus;
            It.SubItems[5] := sMethod;
            SafeScrollListViewToBottom(ListView);
            Exit;
          end;
        end;
        It := ListView.Items.Add;
        It.Caption := sURL;
        while It.SubItems.Count < 6 do
          It.SubItems.Add('');
        It.SubItems[0] := sStatus;
        It.SubItems[5] := sMethod;
        SafeScrollListViewToBottom(ListView);
      end);
  end;
end;

procedure TScrapix.SafeUpdateListViewDownloadState(ListView: TscListView;
const URL, DownloadState: string);
var
  sURL, sState: string;
  I: Integer;
  Item: TListItem;
begin
  if not UIUpdatesAllowed then
    Exit;
  if ListView = nil then
    Exit;

  sURL := NormalizeURL(URL, URL);
  if sURL = '' then
    sURL := URL;
  sState := DownloadState;

  if TThread.Current.ThreadID = MainThreadID then
  begin
    if (csDestroying in ListView.ComponentState) or
      (not ListView.HandleAllocated) then
      Exit;
    for I := 0 to ListView.Items.Count - 1 do
    begin
      Item := ListView.Items[I];
      if SameText(Item.Caption, sURL) or SameText(Item.Caption, URL) then
      begin
        while Item.SubItems.Count < 6 do
          Item.SubItems.Add('');
        Item.SubItems[1] := sState;
        SafeScrollListViewToBottom(ListView);
        Exit;
      end;
    end;
    Item := ListView.Items.Add;
    Item.Caption := sURL;
    while Item.SubItems.Count < 6 do
      Item.SubItems.Add('');
    Item.SubItems[1] := sState;
    SafeScrollListViewToBottom(ListView);
  end
  else
  begin
    TThread.Queue(nil,
      procedure
      var
        I2: Integer;
        It: TListItem;
      begin
        if not UIUpdatesAllowed then
          Exit;
        if (ListView = nil) or (csDestroying in ListView.ComponentState) or
          (not ListView.HandleAllocated) then
          Exit;
        for I2 := 0 to ListView.Items.Count - 1 do
        begin
          It := ListView.Items[I2];
          if SameText(It.Caption, sURL) or SameText(It.Caption, URL) then
          begin
            while It.SubItems.Count < 6 do
              It.SubItems.Add('');
            It.SubItems[1] := sState;
            SafeScrollListViewToBottom(ListView);
            Exit;
          end;
        end;
        It := ListView.Items.Add;
        It.Caption := sURL;
        while It.SubItems.Count < 6 do
          It.SubItems.Add('');
        It.SubItems[1] := sState;
        SafeScrollListViewToBottom(ListView);
      end);
  end;
end;

procedure TScrapix.SafeUpdateListViewInfo(ListView: TscListView;
const URL: string; RespMs: Integer; const SizeBytes: string; Depth: Integer);
var
  sURL, sRespMs, sSizeLocal, sDepth: string;
  DepthLocal: Integer;
  I: Integer;
  Item: TListItem;
begin
  if not UIUpdatesAllowed then
    Exit;
  if ListView = nil then
    Exit;

  sURL := NormalizeURL(URL, URL);
  if sURL = '' then
    sURL := URL;

  if RespMs < 0 then
    sRespMs := ''
  else
    sRespMs := IntToStr(RespMs);
  sSizeLocal := SizeBytes;
  DepthLocal := Depth;
  if DepthLocal > 0 then
    sDepth := IntToStr(DepthLocal)
  else
    sDepth := '';

  if TThread.Current.ThreadID = MainThreadID then
  begin
    if (csDestroying in ListView.ComponentState) or
      (not ListView.HandleAllocated) then
      Exit;
    for I := 0 to ListView.Items.Count - 1 do
    begin
      Item := ListView.Items[I];
      if SameText(Item.Caption, sURL) or SameText(Item.Caption, URL) then
      begin
        while Item.SubItems.Count < 6 do
          Item.SubItems.Add('');
        Item.SubItems[2] := sRespMs;
        Item.SubItems[3] := sSizeLocal;
        if sDepth <> '' then
          Item.SubItems[4] := sDepth;
        SafeScrollListViewToBottom(ListView);
        Exit;
      end;
    end;
    Item := ListView.Items.Add;
    Item.Caption := sURL;
    while Item.SubItems.Count < 6 do
      Item.SubItems.Add('');
    Item.SubItems[2] := sRespMs;
    Item.SubItems[3] := sSizeLocal;
    if sDepth <> '' then
      Item.SubItems[4] := sDepth;
    SafeScrollListViewToBottom(ListView);
  end
  else
  begin
    TThread.Queue(nil,
      procedure
      begin
        if not UIUpdatesAllowed then
          Exit;
        SafeUpdateListViewInfo(ListView, URL, RespMs, SizeBytes, Depth);
      end);
  end;
end;

procedure TScrapix.SafeLog(Logging: TscListBox; const Msg: string);
const
  HORIZONTAL_MARGIN = 16;
var
  NewWidth, CurrExtent, ClientW: Integer;
  CanvasHandle: TCanvas;
  S: string;
begin
  S := Msg;

  if UIUpdatesAllowed and (Logging <> nil) then
  begin
    if TThread.Current.ThreadID = MainThreadID then
    begin
      if (not(csDestroying in Logging.ComponentState)) and Logging.HandleAllocated
      then
      begin
        try
          Logging.Items.Add(S);
          Logging.ItemIndex := Logging.Items.Count - 1;
        except
        end;
        try
          CanvasHandle := Logging.Canvas;
          NewWidth := CanvasHandle.TextWidth(S) + HORIZONTAL_MARGIN;
        except
          NewWidth := 0;
        end;
        if NewWidth > 0 then
        begin
          CurrExtent := SendMessage(Logging.Handle,
            LB_GETHORIZONTALEXTENT, 0, 0);
          if NewWidth > CurrExtent then
            SendMessage(Logging.Handle, LB_SETHORIZONTALEXTENT, NewWidth, 0)
          else
          begin
            ClientW := Logging.ClientWidth;
            if CurrExtent < ClientW then
              SendMessage(Logging.Handle, LB_SETHORIZONTALEXTENT, ClientW, 0);
          end;
        end;
      end;
    end
    else
    begin
      TThread.Queue(nil,
        procedure
        var
          W: Integer;
          Ce: Integer;
          Cw: Integer;
        begin
          if not UIUpdatesAllowed then
            Exit;
          if (Logging = nil) or (csDestroying in Logging.ComponentState) or
            (not Logging.HandleAllocated) then
            Exit;
          try
            Logging.Items.Add(S);
            Logging.ItemIndex := Logging.Items.Count - 1;
          except
          end;
          try
            W := Logging.Canvas.TextWidth(S) + HORIZONTAL_MARGIN;
          except
            W := 0;
          end;
          if W > 0 then
          begin
            Ce := SendMessage(Logging.Handle, LB_GETHORIZONTALEXTENT, 0, 0);
            if W > Ce then
              SendMessage(Logging.Handle, LB_SETHORIZONTALEXTENT, W, 0)
            else
            begin
              Cw := Logging.ClientWidth;
              if Ce < Cw then
                SendMessage(Logging.Handle, LB_SETHORIZONTALEXTENT, Cw, 0);
            end;
          end;
        end);
    end;
  end;

  if (FLogFilePath <> '') and Assigned(FLogLock) then
  begin
    try
      FLogLock.Acquire;
      try
        try
          TFile.AppendAllText(FLogFilePath, S + sLineBreak, TEncoding.UTF8);
        except
        end;
      finally
        FLogLock.Release;
      end;
    except
    end;
  end;
end;

function TScrapix.NormalizeURL(const BaseURL, RelOrAbsURL: string): string;
var
  S: string;
  BaseUri: TURI;
  SchemeHost, BasePath, BaseTrimmed: string;
  Idx: Integer;
begin
  Result := '';
  S := Trim(RelOrAbsURL);
  if S = '' then
    Exit;

  Idx := Pos('#', S);
  if Idx > 0 then
    Delete(S, Idx, MaxInt);

  if S.StartsWith('mailto:', True) or S.StartsWith('javascript:', True) or
    S.StartsWith('tel:', True) or S.StartsWith('data:', True) then
    Exit;

  if S.StartsWith('//') then
  begin
    Result := 'https:' + S;
    Exit;
  end;

  if S.StartsWith('http://', True) or S.StartsWith('https://', True) then
  begin
    try
      Result := TURI.Create(S).ToString
    except
      Result := S
    end;
    Exit;
  end;

  if BaseURL = '' then
    Exit;
  try
    BaseUri := TURI.Create(BaseURL)
  except
    Exit
  end;

  SchemeHost := BaseUri.Scheme + '://' + BaseUri.Host;
  if BaseUri.Port > 0 then
    SchemeHost := SchemeHost + ':' + IntToStr(BaseUri.Port);

  if S.StartsWith('/') then
  begin
    Result := SchemeHost + S;
    Exit;
  end;

  BasePath := BaseUri.Path;
  if (BasePath = '') or BasePath.EndsWith('/') then
    BaseTrimmed := BasePath
  else
    BaseTrimmed := ExtractFilePath(BasePath);
  if (BaseTrimmed = '') or (BaseTrimmed[1] <> '/') then
    BaseTrimmed := '/' + BaseTrimmed;

  Result := SchemeHost + BaseTrimmed;
  if not Result.EndsWith('/') then
    Result := Result + '/';
  Result := Result + S;

  try
    Result := TURI.Create(Result).ToString
  except
  end;
end;

function GetResponseHeaderValue(const Resp: IHTTPResponse;
const HeaderName: string): string;
var
  I: Integer;
begin
  Result := '';
  if Resp = nil then
    Exit;
  for I := 0 to Length(Resp.Headers) - 1 do
    if SameText(Resp.Headers[I].Name, HeaderName) then
      Exit(Resp.Headers[I].Value);
end;

function FormatBytes(const SizeBytes: string): string;
var
  Bytes: Int64;
  d: Double;
  DigitsOnly: string;
begin
  Result := '';
  if Trim(SizeBytes) = '' then
    Exit;
  if Trim(SizeBytes).ToLower = 'n/a' then
  begin
    Result := 'n/a';
    Exit;
  end;

  try
    Bytes := StrToInt64(Trim(SizeBytes))
  except
    DigitsOnly := TRegEx.Replace(SizeBytes, '[^0-9]', '');
    if DigitsOnly = '' then
      Exit;
    try
      Bytes := StrToInt64(DigitsOnly)
    except
      Exit
    end;
  end;

  if Bytes = 0 then
  begin
    Result := 'n/a';
    Exit;
  end;

  if Bytes < 1024 then
    Result := Format('%d Octets', [Bytes])
  else if Bytes < Int64(1024) * 1024 then
  begin
    d := Bytes / 1024;
    Result := FormatFloat('0.##', d) + ' Ko';
  end
  else if Bytes < Int64(1024) * 1024 * 1024 then
  begin
    d := Bytes / (1024 * 1024);
    Result := FormatFloat('0.##', d) + ' Mo';
  end
  else
  begin
    d := Bytes / (1024 * 1024 * 1024);
    Result := FormatFloat('0.##', d) + ' Go';
  end;
end;

function TScrapix.ParseRobots(const RobotsText: string;
OutList: TStringList): Boolean;
var
  Lines: TArray<string>;
  I: Integer;
  CurrentAgents: TStringList;
  L: string;
  AgentMatched: Boolean;
  PathPart: string;
begin
  Result := False;
  OutList.Clear;
  if RobotsText = '' then
    Exit;

  Lines := RobotsText.Split([#13#10, #10, #13],
    TStringSplitOptions.ExcludeEmpty);
  CurrentAgents := TStringList.Create;
  try
    CurrentAgents.Clear;
    AgentMatched := False;
    for I := 0 to Length(Lines) - 1 do
    begin
      L := Trim(Lines[I]);
      if L = '' then
      begin
        CurrentAgents.Clear;
        AgentMatched := False;
        Continue;
      end;

      if StartsText('User-agent:', L) then
      begin
        CurrentAgents.Clear;
        CurrentAgents.Add(Trim(Copy(L, Length('User-agent:') + 1, MaxInt)
          ).ToLower);
        AgentMatched := SameText(CurrentAgents[0], 'Scrapix') or
          SameText(CurrentAgents[0], '*');
        Continue;
      end;

      if AgentMatched and StartsText('Disallow:', L) then
      begin
        PathPart := Trim(Copy(L, Length('Disallow:') + 1, MaxInt));
        if PathPart = '' then
          Continue;
        if not PathPart.StartsWith('/') then
          PathPart := '/' + PathPart;
        if OutList.IndexOf(PathPart) = -1 then
          OutList.Add(PathPart);
      end;
    end;
  finally
    CurrentAgents.Free;
  end;
  Result := True;
end;

function TScrapix.EnsureRobotsForHost(const Host, Scheme: string): Boolean;
var
  Key: string;
  RobotsURL, RobotsText: string;
  Client: THTTPClient;
  RespStream: TStringStream;
  Resp: IHTTPResponse;
  SL, NewSL: TStringList;
begin
  if Host = '' then
    Exit(False);
  Key := LowerCase(Host);
  if RobotsRules = nil then
    RobotsRules := TDictionary<string, TStringList>.Create;
  if RobotsRules.ContainsKey(Key) then
    Exit(True);

  RobotsURL := Scheme + '://' + Host + '/robots.txt';

  Client := THTTPClient.Create;
  try
    Client.ConnectionTimeout := RequestTimeoutMs;
    Client.ResponseTimeout := RequestTimeoutMs;
    Client.UserAgent := UserAgent;
    RespStream := TStringStream.Create('', TEncoding.UTF8);
    try
      try
        Resp := Client.Get(RobotsURL, RespStream);
        RobotsText := RespStream.DataString
      except
        RobotsText := '';
      end;
    finally
      RespStream.Free;
    end;
  finally
    Client.Free;
  end;

  SL := TStringList.Create;
  try
    ParseRobots(RobotsText, SL);
    NewSL := TStringList.Create;
    NewSL.Assign(SL);
    RobotsRules.Add(Key, NewSL);
  finally
    SL.Free;
  end;
  Result := True;
end;

function TScrapix.IsAllowedByRobots(const URL: string): Boolean;
var
  Host, Scheme, Path: string;
  Rules: TStringList;
  Key: string;
  U: TURI;
  I: Integer;
  DisallowPath: string;
begin
  if not FRespectRobots then
    Exit(True);
  Result := True;
  if URL = '' then
    Exit(True);

  try
    U := TURI.Create(URL);
    Host := U.Host;
    Scheme := U.Scheme;
    Path := U.Path;
    if Path = '' then
      Path := '/';
  except
    Exit(True);
  end;

  Key := LowerCase(Host);
  if (RobotsRules = nil) or (not RobotsRules.ContainsKey(Key)) then
    EnsureRobotsForHost(Host, Scheme);

  if (RobotsRules <> nil) and RobotsRules.ContainsKey(Key) then
  begin
    Rules := RobotsRules[Key];
    for I := 0 to Rules.Count - 1 do
    begin
      DisallowPath := Rules[I];
      if Path.StartsWith(DisallowPath, True) then
      begin
        Result := False;
        Exit;
      end;
    end;
  end;
end;

constructor TScrapix.Create;
begin
  inherited Create;
  VisitedLinks := nil;
  FFoundFiles := nil;
  RequestTimeoutMs := 30000;
  RequestDelayMs := 0;
  SameDomainOnly := True;

  FState := STATE_RUNNING;
  FPauseEvent := TEvent.Create(nil, True, True, '');
  FStoppedEvent := TEvent.Create(nil, True, True, '');

  TotalLinks := 0;
  FileCount := 0;
  BrokenCount := 0;
  FRobotsBlocked := 0;
  FLinksTraversed := 0;

  FAutoDownload := False;
  DownloadFolder := '';

  RobotsRules := nil;
  FRespectRobots := True;

  FSearchImages := True;
  FSearchDocuments := True;
  FSearchAudio := True;
  FSearchVideo := True;
  FSearchWeb := True;

  FFoundFiles := TDictionary<string, Boolean>.Create;
  FBrokenLinks := TDictionary<string, Boolean>.Create;

  FRunning := False;
  FMaxDepth := 0;

  FFoundFilesLimit := 2000;
  FExploreLimit := 100;

  FVisitedFilePath := '';
  FBrokenFilePath := '';
  FFoundFilePath := '';

  FLogFilePath := '';
  FLogLock := TCriticalSection.Create;

  FDisableUIUpdates := False;
end;

destructor TScrapix.Destroy;
var
  SL: TStringList;
begin
  CancelExploration;
  WaitForStop;

  FreeAndNil(VisitedLinks);
  FreeAndNil(FFoundFiles);
  FreeAndNil(FBrokenLinks);

  FreeAndNil(FPauseEvent);
  FreeAndNil(FStoppedEvent);

  FreeAndNil(FLogLock);

  if Assigned(RobotsRules) then
  begin
    for SL in RobotsRules.Values do
      SL.Free;
    RobotsRules.Free;
  end;

  inherited;
end;

procedure TScrapix.PauseExploration;
begin
  TInterlocked.Exchange(FState, STATE_PAUSED);
  if Assigned(FPauseEvent) then
    FPauseEvent.ResetEvent;
end;

procedure TScrapix.ResumeExploration;
begin
  TInterlocked.Exchange(FState, STATE_RUNNING);
  if Assigned(FPauseEvent) then
    FPauseEvent.SetEvent;
end;

procedure TScrapix.CancelExploration;
begin
  TInterlocked.Exchange(FState, STATE_CANCEL);
  if Assigned(FPauseEvent) then
    FPauseEvent.SetEvent;
end;

function TScrapix.IsCanceled: Boolean;
begin
  Result := TInterlocked.CompareExchange(FState, 0, 0) = STATE_CANCEL;
end;

function TScrapix.IsPaused: Boolean;
begin
  Result := TInterlocked.CompareExchange(FState, 0, 0) = STATE_PAUSED;
end;

function TScrapix.IsRunning: Boolean;
begin
  Result := TInterlocked.CompareExchange(PInteger(@FRunning)^, 0, 0) <> 0;
end;

function TScrapix.WaitForStop: Boolean;
begin
  CancelExploration;

  if Assigned(FStoppedEvent) then
  begin
    Result := FStoppedEvent.WaitFor(INFINITE) = wrSignaled;
    Exit;
  end;

  while FRunning do
    Sleep(20);
  Result := not FRunning;
end;

procedure TScrapix.ConfigureCrawl(const ARequestTimeoutMs, ARequestDelayMs
  : Integer; ASameDomainOnly: Boolean; AAutoDownload: Boolean;
ARespectRobots: Boolean; AFoundFilesLimit: Integer; AExploreLimit: Integer);
begin
  if ARequestTimeoutMs <= 0 then
    RequestTimeoutMs := 30000
  else
    RequestTimeoutMs := ARequestTimeoutMs;
  if ARequestDelayMs < 0 then
    RequestDelayMs := 0
  else
    RequestDelayMs := ARequestDelayMs;
  SameDomainOnly := ASameDomainOnly;

  FAutoDownload := AAutoDownload;
  FRespectRobots := ARespectRobots;

  if AFoundFilesLimit < 1 then
    FFoundFilesLimit := 1
  else if AFoundFilesLimit > 2000 then
    FFoundFilesLimit := 2000
  else
    FFoundFilesLimit := AFoundFilesLimit;

  if AExploreLimit < 1 then
    FExploreLimit := 1
  else if AExploreLimit > 100 then
    FExploreLimit := 100
  else
    FExploreLimit := AExploreLimit;
end;

function TScrapix.GetWebContent(const URL: string; ListView: TscListView;
Depth: Integer; Logging: TscListBox): string;
var
  Client: THTTPClient;
  Mem: TMemoryStream;
  Resp: IHTTPResponse;
  ContentType, ContentLength: string;
  StatusCode: Integer;
  NormURL: string;
  StartTick, EndTick, ElapsedMs: Cardinal;
  S: RawByteString;
  I: Integer;
begin
  Result := '';
  if URL = '' then
    Exit;
  NormURL := NormalizeURL(URL, URL);
  if NormURL = '' then
    Exit;

  Client := THTTPClient.Create;
  try
    Client.ConnectionTimeout := RequestTimeoutMs;
    Client.ResponseTimeout := RequestTimeoutMs;
    Client.UserAgent := UserAgent;
    Mem := TMemoryStream.Create;
    try
      try
        StartTick := GetTickCount;
        Resp := Client.Get(NormURL, Mem);
        EndTick := GetTickCount;
        ElapsedMs := EndTick - StartTick;

        StatusCode := -1;
        ContentType := '';
        ContentLength := '';
        if Resp <> nil then
        begin
          StatusCode := Resp.StatusCode;
          ContentType := GetResponseHeaderValue(Resp, 'Content-Type');
          ContentLength := GetResponseHeaderValue(Resp, 'Content-Length');
          if ContentLength = '' then
            ContentLength := GetResponseHeaderValue(Resp, 'Content-Range');
          if (ContentLength <> '') and ContentLength.StartsWith('bytes', True)
          then
          begin
            I := LastDelimiter('/', ContentLength);
            if I > 0 then
              ContentLength := Copy(ContentLength, I + 1, MaxInt);
          end;
        end;

        if ContentLength = '' then
          ContentLength := IntToStr(Mem.Size);

        if Mem.Size > 0 then
        begin
          SetLength(S, Mem.Size);
          Mem.Position := 0;
          Mem.ReadBuffer(S[1], Mem.Size);
          Result := string(S);
        end
        else
          Result := '';

        if Assigned(ListView) then
        begin
          SafeUpdateListViewStatus(ListView, NormURL,
            Format('%d %s', [StatusCode, ContentType]), 'GET');
          SafeUpdateListViewInfo(ListView, NormURL, ElapsedMs,
            FormatBytes(ContentLength), Depth);
        end;

        if Assigned(Logging) then
          SafeLog(Logging, Format('GET %s -> %d %s (%s)', [NormURL, StatusCode,
            ContentType, FormatBytes(ContentLength)]));
      except
        on E: Exception do
        begin
          if Assigned(ListView) then
          begin
            SafeUpdateListViewStatus(ListView, NormURL,
              'Exception : ' + E.Message, 'GET');
            SafeUpdateListViewInfo(ListView, NormURL, -1, '', Depth);
          end;
          if Assigned(Logging) then
            SafeLog(Logging, Format('GET Exception %s : %s',
              [NormURL, E.Message]));
          Result := '';
        end;
      end;
    finally
      Mem.Free;
    end;

    if RequestDelayMs > 0 then
      TThread.Sleep(RequestDelayMs);
  finally
    Client.Free;
  end;
end;

procedure TScrapix.ExtractLinks(const HTML: string; BaseURL: string;
var LinkList: TStringList);
var
  Regex: TRegEx;
  Match: TMatch;
  RawLink, Link: string;
begin
  Regex := TRegEx.Create('<a\s+(?:[^>]*?\s+)?href="([^"]*)"', [roIgnoreCase]);
  Match := Regex.Match(HTML);
  while Match.Success do
  begin
    RawLink := Match.Groups[1].Value.Trim;
    Match := Match.NextMatch;
    if RawLink = '' then
      Continue;
    Link := NormalizeURL(BaseURL, RawLink);
    if Link = '' then
      Continue;
    if (VisitedLinks = nil) or not VisitedLinks.ContainsKey(Link) then
      if LinkList.IndexOf(Link) = -1 then
        LinkList.Add(Link);
  end;
end;

function RemoveURLParams(const URL: string): string;
var
  P: Integer;
begin
  Result := URL;
  P := Pos('?', Result);
  if P > 0 then
    Result := Copy(Result, 1, P - 1);
end;

procedure TScrapix.ExtractMediaSources(const HTML: string; BaseURL: string;
var ImageList, DocList, AudioList, VideoList, WebList: TStringList);
var
  RegexImg, RegexDoc, RegexAudio, RegexVideo: TRegEx;
  RegexCss, RegexJs, RegexFont, RegexHtmlLink: TRegEx;
  Match: TMatch;
  RawSource, Source: string;

  procedure AddIfNew(List: TStringList; const S: string);
  begin
    if (S <> '') and (TPath.GetExtension(S) <> '') and (List.IndexOf(S) = -1)
    then
      List.Add(S);
  end;

begin
  if FSearchImages then
  begin
    RegexImg := TRegEx.Create
      ('<img\s+[^>]*src="([^"]+\.(jpg|jpeg|png|gif|bmp|webp|svg))"',
      [roIgnoreCase]);
    Match := RegexImg.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(ImageList, Source);
      Match := Match.NextMatch;
    end;
  end;

  if FSearchDocuments then
  begin
    RegexDoc := TRegEx.Create
      ('<a\s+[^>]*href="([^"]+\.(pdf|zip|rtf|doc|docx|xls|xlsx|ppt|pptx))"',
      [roIgnoreCase]);
    Match := RegexDoc.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(DocList, Source);
      Match := Match.NextMatch;
    end;
  end;

  if FSearchAudio then
  begin
    RegexAudio := TRegEx.Create
      ('(?:<audio\b[^>]*>.*?<source[^>]*src="([^"]+\.(mp3|wav|ogg|m4a|flac))")|href="([^"]+\.(mp3|wav|ogg|m4a|flac))"',
      [roIgnoreCase, roSingleLine]);
    Match := RegexAudio.Match(HTML);
    while Match.Success do
    begin
      if Match.Groups[1].Value <> '' then
        RawSource := Match.Groups[1].Value
      else
        RawSource := Match.Groups[3].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(AudioList, Source);
      Match := Match.NextMatch;
    end;
  end;

  if FSearchVideo then
  begin
    RegexVideo := TRegEx.Create
      ('(?:<video\b[^>]*>.*?<source[^>]*src="([^"]+\.(mp4|webm|mov|ogv|mkv))")|href="([^"]+\.(mp4|webm|mov|ogv|mkv))"',
      [roIgnoreCase, roSingleLine]);
    Match := RegexVideo.Match(HTML);
    while Match.Success do
    begin
      if Match.Groups[1].Value <> '' then
        RawSource := Match.Groups[1].Value
      else
        RawSource := Match.Groups[3].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(VideoList, Source);
      Match := Match.NextMatch;
    end;
  end;

  if FSearchWeb then
  begin
    RegexCss := TRegEx.Create('<link\s+[^>]*href="([^"]+\.(css))"[^>]*>',
      [roIgnoreCase]);
    Match := RegexCss.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(WebList, Source);
      Match := Match.NextMatch;
    end;

    RegexJs := TRegEx.Create('<script\s+[^>]*src="([^"]+\.js)"',
      [roIgnoreCase]);
    Match := RegexJs.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(WebList, Source);
      Match := Match.NextMatch;
    end;

    RegexHtmlLink := TRegEx.Create('<a\s+[^>]*href="([^"]+\.(html|htm))"',
      [roIgnoreCase]);
    Match := RegexHtmlLink.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(WebList, Source);
      Match := Match.NextMatch;
    end;

    RegexFont := TRegEx.Create
      ('(?:href|src)=["'']([^"''\)]+?\.(woff2?|ttf|otf))["'']', [roIgnoreCase]);
    Match := RegexFont.Match(HTML);
    while Match.Success do
    begin
      RawSource := Match.Groups[1].Value;
      Source := RemoveURLParams(RawSource);
      Source := NormalizeURL(BaseURL, Source);
      AddIfNew(WebList, Source);
      Match := Match.NextMatch;
    end;
  end;
end;

procedure TScrapix.MarkBrokenLink(const URL: string; ListView: TscListView;
StatusBar: TscStatusBar; Logging: TscListBox);
begin
  Inc(BrokenCount);
  if Assigned(ListView) then
    SafeUpdateListViewStatus(ListView, URL, GetTranslate('Broken'));
  if Assigned(FBrokenLinks) and not FBrokenLinks.ContainsKey(URL) then
    FBrokenLinks.Add(URL, True);
  if FBrokenFilePath <> '' then
    try
      TFile.AppendAllText(FBrokenFilePath, URL + sLineBreak, TEncoding.UTF8)
    except
    end;
  if Assigned(StatusBar) then
    SafeSetStatusBarPanel(StatusBar, 3, IntToStr(BrokenCount));
  if Assigned(Logging) then
    SafeLog(Logging, Format(GetTranslate('BrokenLinkLog'), [URL]));
end;

procedure TScrapix.IncrementRobotsBlocked(StatusBar: TscStatusBar);
begin
  Inc(FRobotsBlocked);
  if Assigned(StatusBar) then
    SafeSetStatusBarPanel(StatusBar, 5, IntToStr(FRobotsBlocked));
end;

procedure TScrapix.IncrementLinksTraversed(StatusBar: TscStatusBar);
begin
  Inc(FLinksTraversed);
  if Assigned(StatusBar) then
    SafeSetStatusBarPanel(StatusBar, 7, IntToStr(FLinksTraversed));
  if (FExploreLimit > 0) and (FLinksTraversed >= FExploreLimit) then
    CancelExploration;
end;

function TScrapix.IsFileAvailable(const URL: string; ListView: TscListView;
StatusBar: TscStatusBar; Depth: Integer; Logging: TscListBox): Boolean;
var
  Client: THTTPClient;
  Resp: IHTTPResponse;
  Headers: TNetHeaders;
  RespMem: TMemoryStream;
  StatusCode: Integer;
  ContentType: string;
  NormURL: string;
  StartTick, EndTick, ElapsedMs: Cardinal;
  ContentLength: string;
  I: Integer;
begin
  Result := False;
  if URL = '' then
    Exit;
  NormURL := NormalizeURL(URL, URL);
  if NormURL = '' then
    Exit;

  if FRespectRobots and not IsAllowedByRobots(NormURL) then
  begin
    if Assigned(ListView) then
      SafeUpdateListViewStatus(ListView, NormURL,
        GetTranslate('BlockedByRobots'), 'HEAD');
    IncrementRobotsBlocked(StatusBar);
    if Assigned(Logging) then
      SafeLog(Logging, Format(GetTranslate('BlockedRobotsHEADLog'), [NormURL]));
    Exit(False);
  end;

  Client := THTTPClient.Create;
  try
    Client.ConnectionTimeout := RequestTimeoutMs;
    Client.ResponseTimeout := RequestTimeoutMs;
    Client.UserAgent := UserAgent;
    Resp := nil;
    ContentLength := '';

    StartTick := 0;

    try
      try
        StartTick := GetTickCount;
        Resp := Client.Head(NormURL);
        EndTick := GetTickCount;
      except
        Resp := nil;
        EndTick := GetTickCount;
      end;

      ElapsedMs := EndTick - StartTick;

      if Resp = nil then
      begin
        SetLength(Headers, 1);
        Headers[0].Name := 'Range';
        Headers[0].Value := 'bytes=0-0';
        RespMem := TMemoryStream.Create;
        try
          try
            StartTick := GetTickCount;
            Resp := Client.Get(NormURL, RespMem, Headers);
            EndTick := GetTickCount;
            ElapsedMs := EndTick - StartTick;
            ContentLength := GetResponseHeaderValue(Resp, 'Content-Length');
            if ContentLength = '' then
              ContentLength := GetResponseHeaderValue(Resp, 'Content-Range');
            if (ContentLength <> '') and ContentLength.StartsWith('bytes', True)
            then
            begin
              I := LastDelimiter('/', ContentLength);
              if I > 0 then
                ContentLength := Copy(ContentLength, I + 1, MaxInt);
            end;
            if ContentLength = '' then
              ContentLength := IntToStr(RespMem.Size);
          except
            Resp := nil;
            ContentLength := '';
          end;
        finally
          RespMem.Free;
        end;
        SetLength(Headers, 0);
      end
      else
      begin
        ContentLength := GetResponseHeaderValue(Resp, 'Content-Length');
      end;

      if Resp <> nil then
      begin
        StatusCode := Resp.StatusCode;
        ContentType := GetResponseHeaderValue(Resp, 'Content-Type');

        Result := (StatusCode >= 200) and (StatusCode < 300);
        if Assigned(ListView) then
        begin
          if ContentLength = '' then
            ContentLength := '0';
          SafeUpdateListViewStatus(ListView, NormURL,
            Format('%d %s', [StatusCode, ContentType]), 'HEAD');
          SafeUpdateListViewInfo(ListView, NormURL, ElapsedMs,
            FormatBytes(ContentLength), Depth);
        end;
        if Assigned(Logging) then
          SafeLog(Logging, Format('HEAD %s -> %d %s (%s)', [NormURL, StatusCode,
            ContentType, FormatBytes(ContentLength)]));
      end
      else
      begin
        if Assigned(ListView) then
          SafeUpdateListViewStatus(ListView, NormURL,
            GetTranslate('NoResponse'), 'HEAD');
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('NoResponseHEADLog'),
            [NormURL]));
        Result := False;
      end;
    except
      on E: Exception do
      begin
        if Assigned(ListView) then
        begin
          SafeUpdateListViewStatus(ListView, NormURL,
            'Exception : ' + E.Message, '');
          SafeUpdateListViewInfo(ListView, NormURL, -1, '', Depth);
        end;
        if Assigned(Logging) then
          SafeLog(Logging, Format('HEAD Exception %s : %s',
            [NormURL, E.Message]));
        Result := False;
      end;
    end;
  finally
    Client.Free;
  end;
end;

function TScrapix.DownloadFile(const URL: string; Client: THTTPClient;
out LocalPath: string; Logging: TscListBox): Boolean;
var
  FileName, Ext, UriPath, CandidateFolder, CandidateFile, BaseName: string;
  FS: TFileStream;
  Resp: IHTTPResponse;
  NormURL: string;
  Suffix: Integer;

  function DetermineSubFolderByExtension(const AExt: string): string;
  begin
    if AExt = '' then
      Exit('Autre');
    if SameText(AExt, '.jpg') or SameText(AExt, '.jpeg') or
      SameText(AExt, '.png') or SameText(AExt, '.gif') or SameText(AExt, '.bmp')
      or SameText(AExt, '.webp') or SameText(AExt, '.svg') then
      Exit('Image');
    if SameText(AExt, '.pdf') or SameText(AExt, '.zip') or
      SameText(AExt, '.rtf') or SameText(AExt, '.doc') or
      SameText(AExt, '.docx') or SameText(AExt, '.xls') or
      SameText(AExt, '.xlsx') or SameText(AExt, '.ppt') or
      SameText(AExt, '.pptx') or SameText(AExt, '.txt') then
      Exit('Document');
    if SameText(AExt, '.mp3') or SameText(AExt, '.wav') or
      SameText(AExt, '.ogg') or SameText(AExt, '.m4a') or SameText(AExt, '.flac')
    then
      Exit('Audio');
    if SameText(AExt, '.mp4') or SameText(AExt, '.webm') or
      SameText(AExt, '.mov') or SameText(AExt, '.ogv') or SameText(AExt, '.mkv')
    then
      Exit('Vidéo');
    if SameText(AExt, '.css') then
      Exit(TPath.Combine('Ressources Web', 'CSS'));
    if SameText(AExt, '.js') then
      Exit(TPath.Combine('Ressources Web', 'JS'));
    if SameText(AExt, '.html') or SameText(AExt, '.htm') then
      Exit(TPath.Combine('Ressources Web', 'HTML'));
    if SameText(AExt, '.woff') or SameText(AExt, '.woff2') or
      SameText(AExt, '.ttf') or SameText(AExt, '.otf') then
      Exit(TPath.Combine('Ressources Web', 'Fonts'));
    Result := 'Autre';
  end;

begin
  Result := False;
  LocalPath := '';
  if (URL = '') or (Client = nil) then
    Exit;
  NormURL := NormalizeURL(URL, URL);
  if NormURL = '' then
    Exit;

  if FRespectRobots and not IsAllowedByRobots(NormURL) then
  begin
    if Assigned(Logging) then
      SafeLog(Logging, Format(GetTranslate('BlockedRobotsDownLog'), [NormURL]));
    Exit(False);
  end;

  try
    try
      try
        UriPath := TURI.Create(NormURL).Path
      except
        UriPath := '';
      end;
      FileName := TPath.GetFileName(UriPath);
      if FileName = '' then
        FileName := 'file';
      BaseName := TPath.GetFileNameWithoutExtension(FileName);
      Ext := TPath.GetExtension(FileName);
      if Ext = '' then
        Ext := '';

      CandidateFolder := TPath.Combine(DownloadFolder,
        DetermineSubFolderByExtension(Ext));
      if not TDirectory.Exists(CandidateFolder) then
        try
          TDirectory.CreateDirectory(CandidateFolder)
        except
          Exit(False)
        end;

      CandidateFile := TPath.Combine(CandidateFolder, BaseName + Ext);
      Suffix := 0;
      while TFile.Exists(CandidateFile) do
      begin
        Inc(Suffix);
        CandidateFile := TPath.Combine(CandidateFolder,
          BaseName + '_' + IntToStr(Suffix) + Ext);
        if Suffix > 10000 then
          Break;
      end;

      LocalPath := CandidateFile;
      FS := TFileStream.Create(LocalPath, fmCreate);
      try
        Resp := Client.Get(NormURL, FS);
        if Resp <> nil then
          Result := (Resp.StatusCode >= 200) and (Resp.StatusCode < 300)
        else
          Result := False;
      finally
        FS.Free;
        if not Result then
        begin
          try
            if TFile.Exists(LocalPath) then
              TFile.Delete(LocalPath)
          except
          end;
          LocalPath := '';
        end;
      end;
      if Result then
      begin
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('DonwLog'),
            [NormURL, LocalPath]));
      end
      else
      begin
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('DonwFailedLog'), [NormURL]));
      end;
    except
      on E: Exception do
      begin
        Result := False;
        LocalPath := '';
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('ExceptDonwLog'),
            [URL, E.Message]));
      end;
    end;
  finally
  end;
end;

procedure TScrapix.ProcessResourceGroup(ResourceList: TStringList;
const AcceptExts: array of string; StatusBar: TscStatusBar;
ListView: TscListView; Depth: Integer; const DefaultUIType: string;
Logging: TscListBox);
var
  I: Integer;
  URL: string;
  Available: Boolean;
  DLClient: THTTPClient;
  LocalPath: string;
  AddedCount: Integer;
  AcceptAny: Boolean;

  function IsExtAccepted(const AURL: string): Boolean;
  var
    E: string;
    AExt: string;
  begin
    if Length(AcceptExts) = 0 then
    begin
      Result := True;
      Exit;
    end;
    AExt := LowerCase(TPath.GetExtension(AURL));
    for E in AcceptExts do
      if AExt = LowerCase(E) then
        Exit(True);
    Result := False;
  end;

begin
  if (ResourceList = nil) or (ResourceList.Count = 0) then
    Exit;
  AddedCount := 0;
  AcceptAny := Length(AcceptExts) = 0;

  for I := 0 to ResourceList.Count - 1 do
  begin
    if IsCanceled then
      Exit;
    while IsPaused do
    begin
      if IsCanceled then
        Exit;
      if Assigned(FPauseEvent) then
        FPauseEvent.WaitFor(250);
    end;

    URL := ResourceList[I];

    if (not AcceptAny) and (not IsExtAccepted(URL)) then
      Continue;
    if (FFoundFiles <> nil) and FFoundFiles.ContainsKey(URL) then
      Continue;

    if FRespectRobots and not IsAllowedByRobots(URL) then
    begin
      SafeUpdateListViewStatus(ListView, URL, GetTranslate('BlockedByRobots'));
      SafeUpdateListViewDownloadState(ListView, URL, GetTranslate('Ignored'));
      IncrementRobotsBlocked(StatusBar);
      if Assigned(Logging) then
        SafeLog(Logging, Format(GetTranslate('BlockedRobotsResLog'), [URL]));
      Continue;
    end;

    Available := IsFileAvailable(URL, ListView, StatusBar, Depth, Logging);
    if Available then
    begin
      if FAutoDownload then
      begin
        DLClient := THTTPClient.Create;
        try
          DLClient.ConnectionTimeout := RequestTimeoutMs;
          DLClient.ResponseTimeout := RequestTimeoutMs;
          DLClient.UserAgent := UserAgent;

          SafeUpdateListViewDownloadState(ListView, URL,
            GetTranslate('Downloading'));
          if DownloadFile(URL, DLClient, LocalPath, Logging) then
          begin
            SafeUpdateListViewDownloadState(ListView, URL,
              GetTranslate('Downloaded'));
            Inc(AddedCount);
            if FFoundFiles <> nil then
            begin
              FFoundFiles.Add(URL, True);
              if FFoundFilePath <> '' then
                try
                  TFile.AppendAllText(FFoundFilePath, URL + sLineBreak,
                    TEncoding.UTF8)
                except
                end;
            end;
          end
          else
          begin
            SafeUpdateListViewDownloadState(ListView, URL,
              GetTranslate('DownloadFailed'));
            SafeUpdateListViewStatus(ListView, URL,
              GetTranslate('DownloadFailed'));
          end;
        finally
          DLClient.Free;
        end;
      end
      else
      begin
        Inc(AddedCount);
        SafeUpdateListViewDownloadState(ListView, URL,
          GetTranslate('NotDownloaded'));
        if FFoundFiles <> nil then
        begin
          FFoundFiles.Add(URL, True);
          if FFoundFilePath <> '' then
            try
              TFile.AppendAllText(FFoundFilePath, URL + sLineBreak,
                TEncoding.UTF8)
            except
            end;
        end;
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('FoundNotDownLog'), [URL]));
      end;
    end
    else
    begin
      if not FAutoDownload then
      begin
        MarkBrokenLink(URL, ListView, StatusBar, Logging);
        SafeUpdateListViewDownloadState(ListView, URL, GetTranslate('Broken'));
      end
      else
      begin
        SafeUpdateListViewStatus(ListView, URL, GetTranslate('Broken_Ignored'));
        SafeUpdateListViewDownloadState(ListView, URL,
          GetTranslate('Broken_Ignored'));
        if Assigned(FBrokenLinks) and not FBrokenLinks.ContainsKey(URL) then
          FBrokenLinks.Add(URL, True);
        if Assigned(Logging) then
          SafeLog(Logging, Format(GetTranslate('CorruptedDownLog'), [URL]));
      end;
    end;

    if (FFoundFilesLimit > 0) and (FileCount + AddedCount >= FFoundFilesLimit)
    then
    begin
      CancelExploration;
      Break;
    end;
  end;

  if AddedCount > 0 then
    Inc(FileCount, AddedCount);
  if Assigned(StatusBar) then
    SafeSetStatusBarPanel(StatusBar, 1, IntToStr(FileCount));
  if (FFoundFilesLimit > 0) and (FileCount >= FFoundFilesLimit) then
    CancelExploration;
end;

procedure TScrapix.ProcessFoundFiles(ImageList, DocList, AudioList, VideoList,
  WebList: TStringList; StatusBar: TscStatusBar; ListView: TscListView;
CheckList: TscCheckListBox; Depth: Integer; Logging: TscListBox);
begin
  if FSearchImages then
    ProcessResourceGroup(ImageList, ['.jpg', '.jpeg', '.png', '.gif', '.bmp',
      '.webp', '.svg'], StatusBar, ListView, Depth, 'Image', Logging);

  if FSearchDocuments then
    ProcessResourceGroup(DocList, ['.pdf', '.zip', '.rtf', '.doc', '.docx',
      '.xls', '.xlsx', '.ppt', '.pptx', '.txt'], StatusBar, ListView, Depth,
      'Document', Logging);

  if FSearchVideo then
    ProcessResourceGroup(VideoList, ['.mp4', '.webm', '.mov', '.ogv', '.mkv'],
      StatusBar, ListView, Depth, 'Vidéo', Logging);

  if FSearchAudio then
    ProcessResourceGroup(AudioList, ['.mp3', '.wav', '.ogg', '.m4a', '.flac'],
      StatusBar, ListView, Depth, 'Audio', Logging);

  if FSearchWeb then
    ProcessResourceGroup(WebList, ['.css', '.js', '.html', '.htm', '.woff',
      '.woff2', '.ttf', '.otf'], StatusBar, ListView, Depth,
      'RessourceWeb', Logging);
end;

function TScrapix.IsSameDomain(const BaseURL, LinkURL: string): Boolean;
var
  HostBase, HostLink: string;
  TempBase: string;

  function HostIsSuffixOf(const SuffixHost, FullHost: string): Boolean;
  begin
    Result := (SuffixHost = FullHost) or FullHost.EndsWith('.' + SuffixHost);
  end;

begin
  Result := False;
  if LinkURL = '' then
    Exit;
  TempBase := Trim(BaseURL);
  if TempBase = '' then
    Exit;

  if Pos('://', TempBase) = 0 then
    HostBase := LowerCase(TempBase)
  else
    try
      HostBase := LowerCase(TURI.Create(TempBase).Host)
    except
      HostBase := ''
    end;
  if HostBase = '' then
    Exit;

  try
    HostLink := LowerCase(TURI.Create(LinkURL).Host)
  except
    HostLink := ''
  end;
  if HostLink = '' then
    Exit;

  Result := HostIsSuffixOf(HostBase, HostLink);
end;

procedure TScrapix.ExploreLinksRecursive(const URL: string;
ListView: TscListView; StatusBar: TscStatusBar; CheckList: TscCheckListBox;
Depth: Integer; Logging: TscListBox);
var
  HTMLContent: string;
  Links, Images, Docs, Audio, Video, Webs: TStringList;
  I: Integer;
  Item: TListItem;
  NormURL: string;
  NeedExtract: Boolean;
  CurrentDepth: Integer;
begin
  if IsCanceled then
    Exit;
  if Depth <= 0 then
    Exit;

  if FMaxDepth <= 0 then
    CurrentDepth := Depth
  else
    CurrentDepth := FMaxDepth - Depth + 1;

  NormURL := NormalizeURL(URL, URL);
  if NormURL = '' then
    Exit;
  if (VisitedLinks <> nil) and VisitedLinks.ContainsKey(NormURL) then
    Exit;

  if FRespectRobots and not IsAllowedByRobots(NormURL) then
  begin
    if Assigned(ListView) then
    begin
      SafeUpdateListViewStatus(ListView, NormURL,
        GetTranslate('BlockedByRobots'));
      SafeUpdateListViewDownloadState(ListView, NormURL,
        GetTranslate('Ignored'));
    end;
    IncrementRobotsBlocked(StatusBar);
    if Assigned(Logging) then
      SafeLog(Logging, Format(GetTranslate('BlockedRobotsLog'), [NormURL]));
    Exit;
  end;

  while IsPaused do
  begin
    if IsCanceled then
      Exit;
    if Assigned(FPauseEvent) then
      FPauseEvent.WaitFor(250);
  end;

  VisitedLinks.Add(NormURL, True);
  Inc(TotalLinks);
  IncrementLinksTraversed(StatusBar);

  if Assigned(Logging) then
    SafeLog(Logging, Format(GetTranslate('VisitedLog'),
      [NormURL, CurrentDepth]));

  if IsCanceled then
    Exit;
  if (FFoundFilesLimit > 0) and (FileCount >= FFoundFilesLimit) then
  begin
    CancelExploration;
    Exit;
  end;
  if (FExploreLimit > 0) and (FLinksTraversed >= FExploreLimit) then
  begin
    CancelExploration;
    Exit;
  end;

  if FVisitedFilePath <> '' then
    try
      TFile.AppendAllText(FVisitedFilePath, NormURL + sLineBreak,
        TEncoding.UTF8)
    except
    end;

  if Assigned(ListView) and UIUpdatesAllowed then
  begin
    if TThread.Current.ThreadID = MainThreadID then
    begin
      Item := ListView.Items.Add;
      Item.Caption := NormURL;
      while Item.SubItems.Count < 6 do
        Item.SubItems.Add('');
      Item.SubItems[0] := GetTranslate('OnHold');
      Item.SubItems[4] := IntToStr(CurrentDepth);
      SafeScrollListViewToBottom(ListView);
    end
    else
    begin
      var
      LV := ListView;
      var
      sURL := NormURL;
      var
      sDepth := IntToStr(CurrentDepth);
      TThread.Queue(nil,
        procedure
        begin
          if not UIUpdatesAllowed then
            Exit;
          if (LV = nil) or (csDestroying in LV.ComponentState) or
            (not LV.HandleAllocated) then
            Exit;
          Item := LV.Items.Add;
          while Item.SubItems.Count < 6 do
            Item.SubItems.Add('');
          Item.Caption := sURL;
          Item.SubItems[0] := GetTranslate('OnHold');
          Item.SubItems[4] := sDepth;
          SafeScrollListViewToBottom(LV);
        end);
    end;
  end;

  HTMLContent := GetWebContent(NormURL, ListView, CurrentDepth, Logging);

  if HTMLContent = '' then
  begin
    if not FAutoDownload then
    begin
      if Assigned(Logging) then
        SafeLog(Logging, Format(GetTranslate('MarkBrokenLinkLog'), [NormURL]));
      MarkBrokenLink(NormURL, ListView, StatusBar, Logging)
    end
    else
    begin
      SafeUpdateListViewStatus(ListView, NormURL,
        GetTranslate('Broken_Ignored'));
      if Assigned(Logging) then
        SafeLog(Logging, Format(GetTranslate('NoContentLog'), [NormURL]));
    end;
    Exit;
  end;

  Links := TStringList.Create;
  Images := TStringList.Create;
  Docs := TStringList.Create;
  Audio := TStringList.Create;
  Video := TStringList.Create;
  Webs := TStringList.Create;
  try
    ExtractLinks(HTMLContent, NormURL, Links);

    NeedExtract := FSearchImages or FSearchDocuments or FSearchAudio or
      FSearchVideo or FSearchWeb;
    if NeedExtract then
      ExtractMediaSources(HTMLContent, NormURL, Images, Docs, Audio,
        Video, Webs);

    ProcessFoundFiles(Images, Docs, Audio, Video, Webs, StatusBar, ListView,
      CheckList, CurrentDepth, Logging);

    if IsCanceled then
      Exit;
    if (FFoundFilesLimit > 0) and (FileCount >= FFoundFilesLimit) then
      Exit;
    if (FExploreLimit > 0) and (FLinksTraversed >= FExploreLimit) then
      Exit;

    for I := 0 to Links.Count - 1 do
    begin
      if IsCanceled then
        Exit;
      while IsPaused do
      begin
        if IsCanceled then
          Exit;
        if Assigned(FPauseEvent) then
          FPauseEvent.WaitFor(250);
      end;

      if SameDomainOnly then
      begin
        if not IsSameDomain(RootDomain, Links[I]) then
          Continue;
      end;

      ExploreLinksRecursive(Links[I], ListView, StatusBar, CheckList,
        Depth - 1, Logging);
    end;

    if Assigned(StatusBar) then
    begin
      SafeSetStatusBarPanel(StatusBar, 1, IntToStr(FileCount));
      SafeSetStatusBarPanel(StatusBar, 3, IntToStr(BrokenCount));
      SafeSetStatusBarPanel(StatusBar, 5, IntToStr(FRobotsBlocked));
      SafeSetStatusBarPanel(StatusBar, 7, IntToStr(FLinksTraversed));
    end;
  finally
    Links.Free;
    Images.Free;
    Docs.Free;
    Audio.Free;
    Video.Free;
    Webs.Free;
  end;
end;

procedure TScrapix.ExploreLinks(const URL: string; ListView: TscListView;
StatusBar: TscStatusBar; CheckList: TscCheckListBox; MaxDepth: Integer;
SaveBrokenToFile, SaveVisitedToFile, SaveFoundFilesToFile: Boolean;
Logging: TscListBox);
var
  UriStart: TURI;
  RootFolder, StartUrlFolder, ReportFolder, StartFolderName: string;
  DocFolder, FileName, FilePath, LogFolder: string;
  SL: TStringList;
  Key: string;
begin
  if Assigned(FStoppedEvent) then
    FStoppedEvent.ResetEvent;
  FRunning := True;
  try
    FreeAndNil(VisitedLinks);
    VisitedLinks := TDictionary<string, Boolean>.Create;
    if Assigned(FFoundFiles) then
      FFoundFiles.Clear;
    if Assigned(FBrokenLinks) then
      FBrokenLinks.Clear;
    TotalLinks := 0;
    FileCount := 0;
    BrokenCount := 0;
    FRobotsBlocked := 0;
    FLinksTraversed := 0;

    FVisitedFilePath := '';
    FBrokenFilePath := '';
    FFoundFilePath := '';

    TInterlocked.Exchange(FState, STATE_RUNNING);
    if Assigned(FPauseEvent) then
      FPauseEvent.SetEvent;

    if RequestTimeoutMs <= 0 then
      RequestTimeoutMs := 30000;
    if RequestDelayMs < 0 then
      RequestDelayMs := 0;

    if Assigned(Logging) then
      SafeLog(Logging, Format(GetTranslate('StartingLog'), [URL, MaxDepth]));

    try
      try
        UriStart := TURI.Create(URL);
        RootDomain := UriStart.Host
      except
        RootDomain := ''
      end;

      RootFolder := TPath.Combine(TPath.GetDocumentsPath, 'Scrapix');
      StartFolderName := RootDomain;
      StartUrlFolder := TPath.Combine(RootFolder, StartFolderName);
      ReportFolder := TPath.Combine(StartUrlFolder, 'Report');

      try
        if not TDirectory.Exists(RootFolder) then
          TDirectory.CreateDirectory(RootFolder);
        if not TDirectory.Exists(StartUrlFolder) then
          TDirectory.CreateDirectory(StartUrlFolder);
        if not TDirectory.Exists(ReportFolder) then
          TDirectory.CreateDirectory(ReportFolder);
      except
      end;

      try
        LogFolder := TPath.Combine(ReportFolder, 'Logging');
        if not TDirectory.Exists(LogFolder) then
          TDirectory.CreateDirectory(LogFolder);
        FLogFilePath := TPath.Combine(LogFolder, 'Logging.txt');

        try
          TFile.WriteAllText(FLogFilePath, '', TEncoding.UTF8);
        except
          FLogFilePath := '';
        end;
      except
        FLogFilePath := '';
      end;

      DownloadFolder := TPath.Combine(StartUrlFolder, 'download');
      try
        if not TDirectory.Exists(DownloadFolder) then
          TDirectory.CreateDirectory(DownloadFolder);
        if not TDirectory.Exists(TPath.Combine(DownloadFolder, 'Image')) then
          TDirectory.CreateDirectory(TPath.Combine(DownloadFolder, 'Image'));
        if not TDirectory.Exists(TPath.Combine(DownloadFolder, 'Document')) then
          TDirectory.CreateDirectory(TPath.Combine(DownloadFolder, 'Document'));
        if not TDirectory.Exists(TPath.Combine(DownloadFolder, 'Audio')) then
          TDirectory.CreateDirectory(TPath.Combine(DownloadFolder, 'Audio'));
        if not TDirectory.Exists(TPath.Combine(DownloadFolder, 'Vidéo')) then
          TDirectory.CreateDirectory(TPath.Combine(DownloadFolder, 'Vidéo'));
        if not TDirectory.Exists(TPath.Combine(DownloadFolder, 'Web Document'))
        then
          TDirectory.CreateDirectory(TPath.Combine(DownloadFolder,
            'Web Document'));
      except
      end;

      if SaveBrokenToFile then
      begin
        FileName := 'BrokenLinks.txt';
        FBrokenFilePath := TPath.Combine(ReportFolder, FileName);
        try
          TFile.WriteAllText(FBrokenFilePath, '', TEncoding.UTF8)
        except
          FBrokenFilePath := ''
        end;
      end;

      if SaveVisitedToFile then
      begin
        FileName := 'VisitedLinks.txt';
        FVisitedFilePath := TPath.Combine(ReportFolder, FileName);
        try
          TFile.WriteAllText(FVisitedFilePath, '', TEncoding.UTF8)
        except
          FVisitedFilePath := ''
        end;
      end;

      if SaveFoundFilesToFile then
      begin
        FileName := 'FoundFiles.txt';
        FFoundFilePath := TPath.Combine(ReportFolder, FileName);
        try
          TFile.WriteAllText(FFoundFilePath, '', TEncoding.UTF8)
        except
          FFoundFilePath := ''
        end;
      end;

      FMaxDepth := MaxDepth;
      if Assigned(Logging) then
        SafeLog(Logging, GetTranslate('LaunchingLog'));
      ExploreLinksRecursive(URL, ListView, StatusBar, CheckList,
        MaxDepth, Logging);
    finally
      FreeAndNil(VisitedLinks);
    end;

    if SaveBrokenToFile and Assigned(FBrokenLinks) and (FBrokenLinks.Count > 0)
    then
    begin
      DocFolder := TPath.GetDocumentsPath;
      FileName := 'BrokenLinks.txt';
      FilePath := TPath.Combine(DocFolder, FileName);
      SL := TStringList.Create;
      try
        for Key in FBrokenLinks.Keys do
          SL.Add(Key);
        try
          SL.SaveToFile(FilePath, TEncoding.UTF8)
        except
        end;
      finally
        SL.Free;
      end;
    end;

    if SaveVisitedToFile and Assigned(VisitedLinks) and (VisitedLinks.Count > 0)
    then
    begin
      DocFolder := TPath.GetDocumentsPath;
      if FVisitedFilePath <> '' then
        FilePath := FVisitedFilePath
      else
        FilePath := TPath.Combine(DocFolder, 'VisitedLinks.txt');
      SL := TStringList.Create;
      try
        for Key in VisitedLinks.Keys do
          SL.Add(Key);
        try
          SL.SaveToFile(FilePath, TEncoding.UTF8)
        except
        end;
      finally
        SL.Free;
      end;
    end;

    if SaveFoundFilesToFile and Assigned(FFoundFiles) and (FFoundFiles.Count > 0)
    then
    begin
      DocFolder := TPath.GetDocumentsPath;
      if FFoundFilePath <> '' then
        FilePath := FFoundFilePath
      else
        FilePath := TPath.Combine(DocFolder, 'FoundFiles.txt');
      SL := TStringList.Create;
      try
        for Key in FFoundFiles.Keys do
          SL.Add(Key);
        try
          SL.SaveToFile(FilePath, TEncoding.UTF8)
        except
        end;
      finally
        SL.Free;
      end;
    end;
  finally
    if Assigned(Logging) then
      SafeLog(Logging, Format(GetTranslate('FinishedLog'),
        [FileCount, BrokenCount, TotalLinks]));
    FVisitedFilePath := '';
    FBrokenFilePath := '';
    FFoundFilePath := '';
    FRunning := False;
    if Assigned(FStoppedEvent) then
      FStoppedEvent.SetEvent;
  end;
end;

procedure TScrapix.ApplyFileTypeFiltersFromCheckList
  (CheckList: TscCheckListBox);
var
  Idx: Integer;
  Txt: string;
begin
  FSearchImages := False;
  FSearchDocuments := False;
  FSearchAudio := False;
  FSearchVideo := False;
  FSearchWeb := False;
  if CheckList = nil then
    Exit;

  for Idx := 0 to CheckList.Count - 1 do
  begin
    if not CheckList.Checked[Idx] then
      Continue;
    Txt := Trim(CheckList.Items[Idx]);
    if StartsText('Image', Txt) then
      FSearchImages := True
    else if StartsText('Document', Txt) then
      FSearchDocuments := True
    else if StartsText('Audio', Txt) then
      FSearchAudio := True
    else if StartsText('Vidéo', Txt) or StartsText('Video', Txt) then
      FSearchVideo := True
    else if StartsText('Web Document', Txt) then
      FSearchWeb := True
    else
      case Idx of
        0:
          FSearchImages := True;
        1:
          FSearchDocuments := True;
        2:
          FSearchAudio := True;
        3:
          FSearchVideo := True;
        4:
          FSearchWeb := True;
      end;
  end;
end;

end.
Translate.Core.pas
Code : Sélectionner tout - Visualiser dans une fenêtre à part
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
unit Translate.Core;

interface

uses
  {System}
  System.SysUtils, System.Classes, System.Generics.Collections, System.SyncObjs;

type
  // Type énuméré représentant les langues supportées
  TLang = (lgFrench, lgEnglish);

  { Définitions publiques
    - SetLanguage : change la langue courante (thread-safe)
    - GetLanguage : retourne la langue courante (thread-safe)
    - GetTranslate : retourne la chaîne traduite correspondant à une clé
    - RegisterText : enregistre une traduction pour une clé donnée
  }

procedure SetLanguage(ALang: TLang);
function GetLanguage: TLang;
function GetTranslate(const Key: string): string;
procedure RegisterText(const Key, FrenchText, EnglishText: string);

implementation

var
  // Verrou pour protéger l'accès concurrent à CurrentLang et Texts
  LangLock: TCriticalSection;
  // Langue courante utilisée par GetTranslate
  CurrentLang: TLang;
  // Dictionnaire stockant les traductions : Key -> [fr, en]
  Texts: TDictionary<string, TArray<string>>; // Key -> [fr, en]

  { InitDefaults
    Initialise les traductions par défaut utilisées par l'application.
    Appelle RegisterText pour chaque clé afin de remplir le dictionnaire.
  }
procedure InitDefaults;
begin
  // Boutons
  RegisterText('BtnStart', 'Démarrer', 'Start');
  RegisterText('BtnBreak_Pause', 'Pause', 'Break');
  RegisterText('BtnBreak_Resume', 'Reprendre', 'Resume');
  RegisterText('BtnStop', 'Arrêter', 'Stop');
  RegisterText('InProgress', 'En cours...', 'In progress...');

  // Messages utilisateur
  RegisterText('PleaseProvideUrl', 'Veuillez fournir une URL de départ.',
    'Please provide a starting URL.');
  RegisterText('StopOrPauseToOpen',
    'Arrêtez ou mettez en pause l''exploration pour ouvrir le lien.',
    'Stop or pause the crawl before opening the link.');
  RegisterText('InvalidUrl', 'URL invalide : ', 'Invalid URL: ');

  // Titres de colonnes ListView
  RegisterText('Col0', 'Exploration', 'Exploration');
  RegisterText('Col1', 'Statut', 'Statut');
  RegisterText('Col2', 'Téléchargement', 'Download');
  RegisterText('Col3', 'Temps de réponse (ms)', 'Response time (ms)');
  RegisterText('Col4', 'Taille', 'Size');
  RegisterText('Col5', 'Profondeur', 'Depth');
  RegisterText('Col6', 'Type de requête', 'Query type');

  // États et sous-items
  RegisterText('Broken', 'Corrompu', 'Broken');
  RegisterText('Broken_Ignored', 'Corrompu (ignoré)', 'Broken (ignored)');
  RegisterText('BlockedByRobots', 'Bloqué par robots.txt',
    'Blocked by robots.txt');
  RegisterText('Ignored', 'Ignoré', 'Ignored');
  RegisterText('Downloading', 'Téléchargement', 'Downloading');
  RegisterText('Downloaded', 'Téléchargé', 'Downloaded');
  RegisterText('DownloadFailed', 'Échec téléchargement', 'Download failed');
  RegisterText('NotDownloaded', 'Non téléchargé', 'Not downloaded');
  RegisterText('NoResponse', 'Pas de réponse', 'No response');
  RegisterText('OnHold', 'En attente', 'On hold');

  // Labels et cases à cocher
  RegisterText('LabDepth', 'Profondeur d''exploration', 'Exploration depth');
  RegisterText('CkSameDomain', 'Limiter au même domaine',
    'Limit to the same domain');
  RegisterText('CkRobot', 'Respecter les directives Robots.txt',
    'Respect Robots.txt directives');
  RegisterText('LabExploreLimit', 'Limite d''exploration', 'Exploration limit');
  RegisterText('LabFoundFilesLimit', 'Limite fichiers trouvés',
    'Limit files found');
  RegisterText('LabTimeout', 'Temps d''attente par requête (ms)',
    'Wait time per request (ms)');
  RegisterText('LabDelay', 'Délai entre requêtes (ms)',
    'Delay between requests (ms)');
  RegisterText('LabListFileTypes', 'Types de fichiers à rechercher',
    'File types to search for');
  RegisterText('CkAutoDownload', 'Téléchargement automatique',
    'Automatic download');
  RegisterText('LabReport', 'Rapport d''exploration', 'Crawl Report');
  RegisterText('CkSaveBrokenLinks', 'Rapport des liens corrompus',
    'Report corrupted links');
  RegisterText('CkSaveBrokenToFile', 'Rapport des pages visitées',
    'Report of visited pages');
  RegisterText('CkSaveFoundFilesToFile', 'Rapport des fichiers trouvés',
    'Report of found files');

  // Texte de la barre d'état
  RegisterText('Panel0', 'Fichiers trouvés ', 'Files found ');
  RegisterText('Panel2', 'Liens corrompus ', 'Corrupted links ');
  RegisterText('Panel4', 'Bloqué par robots.txt ', 'Blocked by robots.txt ');
  RegisterText('Panel6', 'Liens parcourus ', 'Links browsed ');

  // Observateur d'événements
  RegisterText('ExPanelLog', 'Observateur d''événements', 'Event Viewer');

  RegisterText('BrokenLinkLog', 'Lien corrompues : %s','Corrupted link: %s');
  RegisterText('BlockedRobotsHEADLog', 'Bloqué par le robots (HEAD) : %s',
    'Blocked by robots (HEAD): %s');
  RegisterText('NoResponseHEADLog', 'Aucune réponse HEAD pour %s',
    'No response HEAD for %s');
  RegisterText('BlockedRobotsDownLog',
    'Bloqué par le robots (Téléchargement) : %s',
    'Blocked by robots (Download): %s');
  RegisterText('DonwLog', 'Téléchargé %s -> %s', 'Downloaded %s -> %s');
  RegisterText('DonwFailedLog', 'Échec du téléchargement %s',
    'Download failed %s');
  RegisterText('ExcepDonwLog', 'Exception de téléchargement %s : %s',
    'Download exception %s : %s');
  RegisterText('BlockedRobotsResLog', 'Bloqué par le robots (Resource) : %s',
    'Blocked by robots (Resource) : %s');
  RegisterText('FoundNotDownLog', 'Trouvé (Non téléchargé) : %s',
    'Found (Not downloaded): %s');
  RegisterText('CorruptedDownLog',
    'Lien corrompue (Téléchargement automatique)*: %s',
    'Corrupted link (Automatic download) : %s');
  RegisterText('BlockedRobotsLog', 'Bloqué par le robots (Récursif) : %s',
    'Blocked by robots (Recursive) : %s');
  RegisterText('VisitedLog', 'Visité : %s (Profondeur = %d)',
    'Visited : %s (Depth = %d)');
  RegisterText('MarkBrokenLinkLog', 'Aucun contenu / lien corrompues : %s',
    'No content / Corrupted link : %s');
  RegisterText('NoContentLog', 'Aucun contenu (ignoré) : %s',
    'No content (ignored): %s');
  RegisterText('StartingLog',
    'Scrapix*: démarrage de l''exploration de %s (MaxDepth = %d)',
    'Scrapix: Starting to crawl %s (MaxDepth = %d)');
  RegisterText('LaunchingLog',
    'Scrapix : lancement de l''exploration récursive',
    'Scrapix: launching recursive exploration');
  RegisterText('FinishedLog',
    'Scrapix*: Exploration terminée. Fichiers trouvés = %d ,*Liens corrompus*=*%d ,*Liens parcourus*=*%d',
    'Scrapix: Crawling complete. Files found = %d , Corrupted links = %d , Links browsed = %d');
end;

{ SetLanguage
  Définit la langue courante de façon thread-safe en protégeant l'affectation
  par un TCriticalSection afin d'éviter les conditions de concurrence.
}
procedure SetLanguage(ALang: TLang);
begin
  LangLock.Enter;
  try
    CurrentLang := ALang;
  finally
    LangLock.Leave;
  end;
end;

{ GetLanguage
  Retourne la langue courante de façon thread-safe en accédant à CurrentLang
  sous protection du verrou LangLock.
}
function GetLanguage: TLang;
begin
  LangLock.Enter;
  try
    Result := CurrentLang;
  finally
    LangLock.Leave;
  end;
end;

{ GetTranslate
  Recherche la traduction correspondant à Key dans le dictionnaire Texts.
  - Si la clé est vide, retourne immédiatement une chaîne vide.
  - Si la clé n'existe pas, retourne la clé elle-même comme fallback.
  - Sélectionne l'élément francais ou anglais suivant CurrentLang.
  L'accès au dictionnaire est protégé par LangLock pour être thread-safe.
}
function GetTranslate(const Key: string): string;
var
  Arr: TArray<string>;
begin
  Result := Key; // fallback si aucune traduction trouvée
  if Key = '' then
    Exit;
  LangLock.Enter;
  try
    // Vérifie que Texts est initialisé et que la clé existe
    if (Texts <> nil) and Texts.TryGetValue(Key, Arr) then
    begin
      case CurrentLang of
        lgFrench:
          if Length(Arr) > 0 then
            Result := Arr[0]; // français
        lgEnglish:
          if Length(Arr) > 1 then
            Result := Arr[1]; // anglais
      end;
    end;
  finally
    LangLock.Leave;
  end;
end;

{ RegisterText
  Enregistre ou met à jour la traduction pour une clé donnée.
  - Ignore les clés vides.
  - Alloue le tableau de 2 éléments [fr, en].
  - Si le dictionnaire n'existe pas encore, le crée.
  - Utilise AddOrSetValue pour ajouter ou remplacer la valeur existante.
  L'opération est thread-safe via LangLock.
}
procedure RegisterText(const Key, FrenchText, EnglishText: string);
var
  Arr: TArray<string>;
begin
  if Key.IsEmpty then
    Exit;
  LangLock.Enter;
  try
    SetLength(Arr, 2);
    Arr[0] := FrenchText;
    Arr[1] := EnglishText;
    if Texts = nil then
      Texts := TDictionary < string, TArray < string >>.Create;
    Texts.AddOrSetValue(Key, Arr);
  finally
    LangLock.Leave;
  end;
end;

{ Bloc d'initialisation
  - Crée le verrou LangLock.
  - Définit la langue par défaut (ici français).
  - Crée le dictionnaire Texts.
  - Remplit les traductions par défaut via InitDefaults.
}
initialization

LangLock := TCriticalSection.Create;
CurrentLang := lgFrench; // valeur par défaut
Texts := TDictionary < string, TArray < string >>.Create;
InitDefaults;

{ Bloc de finalisation
  - Libère les ressources allouées dans l'initialization.
  - Important de libérer Texts avant LangLock si le dictionnaire utilise des sections critiques.
}
finalization

Texts.Free;
LangLock.Free;

end.
UScrapix.pas Vcl UI
Code : Sélectionner tout - Visualiser dans une fenêtre à part
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
unit UScrapix;

interface

uses
  {Winapi}
  Winapi.Windows, Winapi.Messages, Winapi.ShellAPI,
  {System}
  System.SysUtils, System.Variants, System.Classes, System.IOUtils,
  System.Net.URLClient, System.UITypes, System.StrUtils, System.ImageList,
  System.SyncObjs,
  {Vcl}
  Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.ComCtrls,
  Vcl.StdCtrls, Vcl.Mask, Vcl.CheckLst, Vcl.Themes, Vcl.ImgList,
  Vcl.VirtualImageList, Vcl.BaseImageCollection, Vcl.ImageCollection,
  {StyleControls VCL}
  scStyleManager, scControls, scModernControls, scDialogs, scExtControls,
  {Scrapix.Core}
  Scrapix.Core,
  {Translate.Core}
  Translate.Core;

type
  TFScrapix = class(TForm)
    scStyleManager: TscStyleManager;
    Collection: TImageCollection;
    ImageList: TVirtualImageList;
    BoxMain: TscPanel;
    BtnOpenDir: TscButton;
    EdUrl: TscEdit;
    BtnStart: TscButton;
    BtnBreak: TscButton;
    BtnStop: TscButton;
    BtnSettings: TscButton;
    BtnResetUI: TscButton;
    BtnAbout: TscButton;
    BtnTranslate: TscButton;
    BoxScrap: TscPanel;
    ListView: TscListView;
    SplitView: TscSplitView;
    ScrollBox: TscScrollBox;
    LabDepth: TscLabel;
    SeDepth: TscSpinEdit;
    CkSameDomain: TscCheckBox;
    CkRobot: TscCheckBox;
    LabTimeout: TscLabel;
    SeTimeout: TscSpinEdit;
    LabDelay: TscLabel;
    SeDelay: TscSpinEdit;
    LabExploreLimit: TscLabel;
    SeExploreLimit: TscSpinEdit;
    LabFoundFilesLimit: TscLabel;
    SeFoundFilesLimit: TscSpinEdit;
    LabListFileTypes: TLabel;
    CkListFileTypes: TscCheckListBox;
    CkAutoDownload: TscCheckBox;
    LabReport: TscLabel;
    CkSaveBrokenLinks: TscCheckBox;
    CkSaveBrokenToFile: TscCheckBox;
    CkSaveFoundFilesToFile: TscCheckBox;
    ExPanelLog: TscExPanel;
    Logging: TscListBox;
    StatusBar: TscStatusBar;

    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormClose(Sender: TObject; var Action: TCloseAction);
    procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
    procedure BtnOpenDirClick(Sender: TObject);
    procedure BtnStartClick(Sender: TObject);
    procedure BtnBreakClick(Sender: TObject);
    procedure BtnStopClick(Sender: TObject);
    procedure BtnSettingsClick(Sender: TObject);
    procedure BtnResetUIClick(Sender: TObject);
    procedure BtnAboutClick(Sender: TObject);
    procedure BtnTranslateClick(Sender: TObject);
    procedure ListViewDblClick(Sender: TObject);

  private
    Scrapix: TScrapix;

    { Met à jour l'état du bouton Pause/Resume selon l'état du crawler. }
    procedure UpdateBtnBreak;

    { Active / désactive les contrôles de l'UI selon le bool Running (thread-safe). }
    procedure UpdateUI(Running: Boolean);

    { Met à jour les libellés traduits dans l'UI. }
    procedure UpdateTranslateUI;

    { Vérifie rapidement que la chaîne donnée est une URL HTTP/HTTPS valide. }
    function IsUrl(const AUrl: string): Boolean;

    { Réinitialise les panneaux de la statusbar (thread-safe). }
    procedure ResetStatusPanels;

    { Routine interne pour restaurer l'UI (appelée toujours sur le thread principal). }
    procedure RestoreUIAfterRun;
  public
  end;

var
  FScrapix: TFScrapix;

implementation

{$R *.dfm}

{ UpdateBtnBreak: adapte le libellé du bouton Pause/Resume selon l'état Scrapix. }
procedure TFScrapix.UpdateBtnBreak;
begin
  if not Assigned(Scrapix) then
  begin
    BtnBreak.Caption := GetTranslate('BtnBreak_Pause');
    Exit;
  end;

  if Scrapix.IsPaused then
    BtnBreak.Caption := GetTranslate('BtnBreak_Resume')
  else
    BtnBreak.Caption := GetTranslate('BtnBreak_Pause');
end;

{ UpdateUI: active ou désactive les contrôles pertinents; exécute via Queue si appelé hors du MainThread. }
procedure TFScrapix.UpdateUI(Running: Boolean);
begin
  if TThread.Current.ThreadID <> MainThreadID then
  begin
    TThread.Queue(nil,
      procedure
      begin
        UpdateUI(Running);
      end);
    Exit;
  end;

  BtnStart.Enabled := not Running;
  BtnStop.Enabled := Running;
  BtnBreak.Enabled := Running;
  BtnSettings.Enabled := not Running;
  BtnResetUI.Enabled := not Running;
  BtnAbout.Enabled := not Running;
  BtnTranslate.Enabled := not Running;
end;

{ UpdateTranslateUI: applique les traductions aux contrôles visibles. }
procedure TFScrapix.UpdateTranslateUI;
var
  I: Integer;
begin
  BtnStart.Caption := GetTranslate('BtnStart');
  BtnStop.Caption := GetTranslate('BtnStop');
  BtnBreak.Caption := GetTranslate('BtnBreak_Pause');

  for I := 0 to ListView.Columns.Count - 1 do
    ListView.Columns[I].Caption := GetTranslate('Col' + IntToStr(I));

  LabDepth.Caption := GetTranslate('LabDepth');
  CkSameDomain.Caption := GetTranslate('CkSameDomain');
  CkRobot.Caption := GetTranslate('CkRobot');
  LabTimeout.Caption := GetTranslate('LabTimeout');
  LabDelay.Caption := GetTranslate('LabDelay');
  LabExploreLimit.Caption := GetTranslate('LabExploreLimit');
  LabFoundFilesLimit.Caption := GetTranslate('LabFoundFilesLimit');
  LabListFileTypes.Caption := GetTranslate('LabListFileTypes');
  CkAutoDownload.Caption := GetTranslate('CkAutoDownload');
  LabReport.Caption := GetTranslate('LabReport');
  CkSaveBrokenLinks.Caption := GetTranslate('CkSaveBrokenLinks');
  CkSaveBrokenToFile.Caption := GetTranslate('CkSaveBrokenToFile');
  CkSaveFoundFilesToFile.Caption := GetTranslate('CkSaveFoundFilesToFile');

  ExPanelLog.Caption := GetTranslate('ExPanelLog');

  StatusBar.Panels[0].Text := GetTranslate('Panel0');
  StatusBar.Panels[2].Text := GetTranslate('Panel2');
  StatusBar.Panels[4].Text := GetTranslate('Panel4');
  StatusBar.Panels[6].Text := GetTranslate('Panel6');
end;

{ IsUrl: vérifie qu'une chaîne est une URL http(s) valide (sans lever d'exception). }
function TFScrapix.IsUrl(const AUrl: string): Boolean;
var
  U: TURI;
begin
  Result := False;
  if AUrl.IsEmpty then
    Exit;
  try
    U := TURI.Create(AUrl);
    Result := ((U.Scheme = 'http') or (U.Scheme = 'https')) and (U.Host <> '');
  except
    Result := False;
  end;
end;

{ FormCreate: initialise valeurs par défaut et UI. }
procedure TFScrapix.FormCreate(Sender: TObject);
begin
  SetLanguage(lgFrench);
  Scrapix := TScrapix.Create;

  SeDepth.MinValue := 1;
  SeDepth.MaxValue := 20;
  SeDepth.Value := 2;

  SeTimeout.MinValue := 1000;
  SeTimeout.MaxValue := 30000;
  SeTimeout.Value := 10000;

  SeDelay.MinValue := 1;
  SeDelay.MaxValue := 60000;
  SeDelay.Value := 100;

  SeExploreLimit.MinValue := 1;
  SeExploreLimit.MaxValue := 100;
  SeExploreLimit.Value := 20;

  SeFoundFilesLimit.MinValue := 1;
  SeFoundFilesLimit.MaxValue := 2000;
  SeFoundFilesLimit.Value := 500;

  CkSameDomain.Checked := False;

  with ListView do
  begin
    Columns.BeginUpdate;
    try
      Columns.Clear;
      with Columns.Add do
        Width := 600;
      with Columns.Add do
        Width := 200;
      with Columns.Add do
        Width := 200;
      with Columns.Add do
        Width := 200;
      with Columns.Add do
        Width := 120;
      with Columns.Add do
        Width := 120;
      with Columns.Add do
        Width := 200;
    finally
      Columns.EndUpdate;
    end;
    ViewStyle := vsReport;
  end;

  CkRobot.Checked := True;

  BtnBreak.Enabled := False;
  BtnStop.Enabled := False;

  with CkListFileTypes do
  begin
    Items.Clear;
    Items.Add('Image');
    Items.Add('Document');
    Items.Add('Audio');
    Items.Add('Vidéo');
    Items.Add('Web Document');

    Checked[0] := True;
    Checked[1] := True;
    Checked[2] := True;
    Checked[3] := True;
    Checked[4] := False;
  end;

  SplitView.Close;
  UpdateUI(False);
  UpdateTranslateUI;

  ExPanelLog.RollUpState := True;

{$IFDEF DEBUG}
  EdUrl.Text := 'https://github.com/';
  SeDepth.Value := 2;
  SeTimeout.Value := 10000;
  SeDelay.Value := 100;
{$ENDIF}
end;

{ FormClose: ordonne l'arrêt du crawler et empêche fuite d'objet. }
procedure TFScrapix.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  if Assigned(Scrapix) then
  begin
    Scrapix.DisableUIUpdates := True;
    Scrapix.CancelExploration;
    Scrapix.WaitForStop;
    Action := caFree;
  end;
end;

{ FormCloseQuery: empêche la fermeture tant que le crawler est en cours. }
procedure TFScrapix.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  if Assigned(Scrapix) and Scrapix.IsRunning then
  begin
    Scrapix.DisableUIUpdates := True;
    Scrapix.CancelExploration;

    if not Scrapix.WaitForStop then
    begin
      CanClose := False;
      Exit;
    end;
  end;
  CanClose := True;
end;

{ FormDestroy: libère l'objet Scrapix de manière sûre. }
procedure TFScrapix.FormDestroy(Sender: TObject);
begin
  if Assigned(Scrapix) then
  begin
    try
      Scrapix.DisableUIUpdates := True;
      Scrapix.CancelExploration;
      Scrapix.WaitForStop;
      FreeAndNil(Scrapix);
    except
      on E: Exception do
        scShowMessage(E.Message);
    end;
  end;
end;

{ BtnOpenDirClick: ouvre le dossier de rapport / téléchargement lié à l'URL saisie. }
procedure TFScrapix.BtnOpenDirClick(Sender: TObject);
var
  Dir, DirDom: String;
  U: TURI;
begin
  DirDom := EmptyStr;
  Dir := TPath.Combine(TPath.GetDocumentsPath, Application.Title);

  if IsUrl(EdUrl.Text) then
  begin
    try
      U := TURI.Create(EdUrl.Text);
      DirDom := TPath.Combine(Dir, U.Host);
    except
      DirDom := EmptyStr;
    end;
  end;

  if DirectoryExists(DirDom) then
    ShellExecute(0, 'open', PChar(DirDom), nil, nil, SW_SHOWNORMAL)
  else if DirectoryExists(Dir) then
    ShellExecute(0, 'open', PChar(Dir), nil, nil, SW_SHOWNORMAL);
end;

{ ResetStatusPanels: remet à zéro les compteurs affichés sur la statusbar (thread-safe). }
procedure TFScrapix.ResetStatusPanels;
begin
  if TThread.Current.ThreadID <> MainThreadID then
  begin
    TThread.Queue(nil,
      procedure
      begin
        ResetStatusPanels;
      end);
    Exit;
  end;
  StatusBar.Panels[1].Text := '0';
  StatusBar.Panels[3].Text := '0';
  StatusBar.Panels[5].Text := '0';
  StatusBar.Panels[7].Text := '0';
end;

{ RestoreUIAfterRun: restaure l'UI après exécution du thread (toujours MainThread). }
procedure TFScrapix.RestoreUIAfterRun;
begin
  UpdateUI(False);
  BtnStart.Caption := GetTranslate('BtnStart');
  UpdateBtnBreak;
end;

{ BtnStartClick: lance l'exploration dans un thread anonyme, protège contre double démarrage. }
procedure TFScrapix.BtnStartClick(Sender: TObject);
begin
  if not IsUrl(Trim(EdUrl.Text)) then
  begin
    scShowMessage(GetTranslate('PleaseProvideUrl'));
    EdUrl.SetFocus;
    Exit;
  end;

  ListView.Items.Clear;
  SplitView.Close;
  UpdateUI(True);
  ResetStatusPanels;

  Logging.Items.Clear;
  ExPanelLog.RollUpState := False;

  if not Assigned(Scrapix) then
    Scrapix := TScrapix.Create;

  Scrapix.ConfigureCrawl(SeTimeout.ValueAsInt, SeDelay.ValueAsInt,
    CkSameDomain.Checked, CkAutoDownload.Checked, CkRobot.Checked,
    SeFoundFilesLimit.ValueAsInt, SeExploreLimit.ValueAsInt);

  BtnStart.Caption := GetTranslate('InProgress');

  TThread.CreateAnonymousThread(
    procedure
    begin
      try
        try
          Scrapix.ApplyFileTypeFiltersFromCheckList(CkListFileTypes);
          Scrapix.ExploreLinks(Trim(EdUrl.Text), ListView, StatusBar,
            CkListFileTypes, SeDepth.ValueAsInt, CkSaveBrokenLinks.Checked,
            CkSaveBrokenToFile.Checked, CkSaveFoundFilesToFile.Checked,
            Logging);
        except
          on E: Exception do
            TThread.Queue(nil,
              procedure
              begin
                scShowMessage('Explorer thread exception : ' + E.Message);
              end);
        end;
      finally
        TThread.Queue(nil,
          procedure
          begin
            if Assigned(Scrapix) and Scrapix.IsRunning then
            begin
              Scrapix.CancelExploration;
              Scrapix.WaitForStop;
            end;
            RestoreUIAfterRun;
          end);
      end;
    end).Start;
end;

{ BtnBreakClick: bascule entre pause et reprise. }
procedure TFScrapix.BtnBreakClick(Sender: TObject);
begin
  if not Assigned(Scrapix) then
    Exit;

  if Scrapix.IsPaused then
    Scrapix.ResumeExploration
  else
    Scrapix.PauseExploration;

  UpdateBtnBreak;
  UpdateUI(True);
end;

{ BtnStopClick: demande l'arrêt et attend la fin (bloquant court sur le thread UI). }
procedure TFScrapix.BtnStopClick(Sender: TObject);
begin
  if Assigned(Scrapix) then
  begin
    Scrapix.CancelExploration;
    Scrapix.WaitForStop;

    UpdateUI(False);
    BtnStart.Enabled := True;
    BtnStart.Caption := GetTranslate('BtnStart');
    BtnStop.Enabled := False;
    BtnBreak.Enabled := False;
  end;
end;

{ BtnSettingsClick: ouvre/ferme le panneau de configuration. }
procedure TFScrapix.BtnSettingsClick(Sender: TObject);
begin
  SplitView.Opened := not SplitView.Opened;

  if SplitView.Opened then
    ScrollBox.VertScrollBar.Position := 0;
end;

{ BtnResetUIClick: Réinitialise l'UI }
procedure TFScrapix.BtnResetUIClick(Sender: TObject);
begin
  EdUrl.Clear;
  ListView.Items.Clear;
  Logging.Items.Clear;
end;

{ BtnAboutClick: A propos... }
procedure TFScrapix.BtnAboutClick(Sender: TObject);
begin
  with TStringList.Create do
  begin
    Add('Développé par : XeGregory');
    Add('IDE : Embarcadero Delphi 11');
    Add('');
    Add('Version :');
    Add('- Srapix UI : v1.0');
    Add('- Srapix.Core.pas : v1.0');
    Add('- Translate.Core : v1.0');
    scShowMessage(Text);
    Free;
  end;
end;

{ BtnTranslateClick: change la langue de l'UI et met à jour les libellés. }
procedure TFScrapix.BtnTranslateClick(Sender: TObject);
begin
  case GetLanguage of
    lgFrench:
      begin
        SetLanguage(lgEnglish);
        BtnTranslate.ImageIndex := 3;
      end;
    lgEnglish:
      begin
        SetLanguage(lgFrench);
        BtnTranslate.ImageIndex := 2;
      end;
  end;
  UpdateTranslateUI;
end;

{ ListViewDblClick: ouvre l'URL sélectionnée dans le navigateur, exige que le crawler soit stoppé ou en pause. }
procedure TFScrapix.ListViewDblClick(Sender: TObject);
var
  SelItem: TListItem;
  Url: string;
begin
  SelItem := ListView.Selected;
  if SelItem = nil then
    Exit;

  Url := SelItem.Caption.Trim;
  if Url = '' then
    Exit;

  if Assigned(Scrapix) and not(Scrapix.IsPaused or Scrapix.IsCanceled) then
  begin
    scShowMessage(GetTranslate('StopOrPauseToOpen'));
    Exit;
  end;

  if not IsUrl(Url) then
  begin
    scShowMessage(GetTranslate('InvalidUrl') + Url);
    Exit;
  end;

  ShellExecute(0, 'open', PChar(Url), nil, nil, SW_SHOWNORMAL);
end;

end.



# Interface Vcl

Nom : Video_2025_10_26-2_edit_0.gif
Affichages : 245
Taille : 1,77 Mo




# Champs et structures importantes

  • VisitedLinks : TDictionary<string, Boolean> — Dictionnaire des URL déjà visitées pour éviter les doublons pendant l'exploration.
  • FFoundFiles : TDictionary<string, Boolean> — Dictionnaire des fichiers repérés ou téléchargés.
  • FBrokenLinks : TDictionary<string, Boolean> — Dictionnaire des liens identifiés comme cassés.
  • TotalLinks, FileCount, BrokenCount, FRobotsBlocked, FLinksTraversed — Compteurs statistiques mis à jour pendant l'exploration.
  • FState, FPauseEvent, FStoppedEvent — Contrôle d'état de l'exploration pour pause, reprise et annulation.
  • RequestTimeoutMs, RequestDelayMs, SameDomainOnly, RootDomain — Paramètres du crawl et comportement d'URL.
  • FAutoDownload, DownloadFolder — Options et destination pour téléchargement automatique.
  • RobotsRules : TDictionary<string, TStringList> — Cache des règles robots.txt par hôte.
  • FRespectRobots, FSearchImages, FSearchDocuments, FSearchAudio, FSearchVideo, FSearchWeb — Flags de comportement.
  • FRunning, FMaxDepth — Indicateurs d'exécution et de profondeur.
  • FVisitedFilePath, FBrokenFilePath, FFoundFilePath, FLogFilePath, FLogLock — Chemins de rapports et synchronisation du log.
  • FDisableUIUpdates, FFoundFilesLimit, FExploreLimit — Contrôle d'UI et limites d'exploration.





# Initialisation, destruction et contrôle d'exécution

constructor Create
Rôle : Initialise les champs, crée les dictionnaires FFoundFiles et FBrokenLinks, crée les events de pause/stop et le TCriticalSection pour le log.
Valeurs par défaut importantes : RequestTimeoutMs = 30000 ms, RequestDelayMs = 0, SameDomainOnly = True, FRespectRobots = True, FFoundFilesLimit = 2000, FExploreLimit = 100, recherches de ressources activées.
Effet : L'objet est prêt pour configurer et lancer une exploration.

destructor Destroy
Rôle : Annule toute exploration en cours, attend l'arrêt, libère les dictionnaires, events, lock, et libère et vide RobotsRules correctement en libérant chaque TStringList.
Effet : Nettoyage sûr et libération des ressources.

PauseExploration / ResumeExploration / CancelExploration
Rôle : Modifier l'état FState pour PAUSED, RUNNING ou CANCEL respectivement et manipuler FPauseEvent afin d'ordonner le blocage ou la reprise des threads qui attendent.
Effet : Permet au code récursif d'attendre ou d'interrompre proprement son exécution.

IsCanceled / IsPaused / IsRunning
Rôle : Fournir l'état courant par lecture atomique via TInterlocked pour IsCanceled et IsPaused et lecture de FRunning pour IsRunning.
Retour : Boolean indiquant la condition demandée.

WaitForStop
Rôle : Met en CANCEL l'exploration puis attend que FStoppedEvent soit signalé ou boucle tant que FRunning reste vrai. Renvoie vrai si l'arrêt est confirmé.
Usage : Bloquant pour attendre fin complète avant destruction ou autre action.

ConfigureCrawl
Rôle : Applique les paramètres fournis au crawler tels que timeouts, délai entre requêtes, restriction même domaine, téléchargement automatique, respect robots, limites de fichiers trouvés et limite d'exploration.
Validation : Définit des bornes pour les limites et normalise les timeout/delay.




# Normalisation d'URL et gestion domaines

NormalizeURL(const BaseURL, RelOrAbsURL: string): string
Rôle : Convertir une URL relative ou étrange en URL absolue normalisée.
Comportement clé :
  • Supprime la partie fragment après '#'.
  • Ignore les URI de schéma non HTTP utiles mailto, javascript, tel, data.
  • Gère les protocoles relatifs en préfixant par https.
  • Si URL déjà absolue, tente TURI.Create pour normaliser.
  • Si URL relative et BaseURL fourni, combine scheme+host+port+chemin de base et concatène la partie relative.
  • Tente de normaliser le résultat via TURI.Create.

Retour : URL normalisée ou chaîne vide si impossible.
Impact : Utilisée partout pour uniformiser les comparaisons et requêtes.

IsSameDomain(const BaseURL, LinkURL: string): Boolean
Rôle : Déterminer si LinkURL appartient au même domaine ou sous-domaine du BaseURL.
Logique :
  • Extrait les hôtes via TURI.Create ou prend BaseURL textuel si pas de schéma.
  • Compare en vérifiant si HostBase est suffixe du HostLink ou égal.

Retour : True si même domaine ou sous-domaine.




# robots.txt : parsing, caching et autorisations

ParseRobots(const RobotsText: string; OutList: TStringList): Boolean
Rôle : Lire le contenu de robots.txt et extraire les chemins "Disallow" applicables à l'agent "Scrapix" ou à "*" en tenant compte du bloc User-agent courant.
Comportement :
  • Sépare en lignes, ignore les lignes vides, détecte les blocs User-agent.
  • Si le User-agent correspond à "Scrapix" ou "*", récupère les Disallow non vides et préfixe d'un slash si nécessaire.
  • Ajoute chaque chemin unique à OutList.

Retour : True si parsing exécuté.

EnsureRobotsForHost(const Host, Scheme: string): Boolean
Rôle : Charger robots.txt pour un hôte donné et stocker les règles dans RobotsRules pour cache.
Comportement :
  • Construit l'URL robots.txt et effectue un GET avec THTTPClient.
  • Parse le contenu via ParseRobots et stocke une copie des règles dans RobotsRules[host en minuscule].
  • Ne relance pas si déjà en cache.

Retour : True sauf si Host vide.

IsAllowedByRobots(const URL: string): Boolean
Rôle : Vérifier si une URL est autorisée par les règles en cache ou en récupérant robots.txt si nécessaire.
Comportement :
  • Si FRespectRobots est false, renvoie true sans vérification.
  • Extrait Host, Scheme et Path via TURI.
  • S'assure que robots.txt est présent dans le cache pour l'hôte en appelant EnsureRobotsForHost.
  • Parcourt les chemins Disallow pour l'hôte et si Path commence par un Disallow, renvoie false.

Retour : True si autorisée, False si bloquée.




# Requêtes HTTP et utilitaires

GetResponseHeaderValue(const Resp: IHTTPResponse; const HeaderName: string): string
Rôle : Extraire la valeur d'un header HTTP donné depuis l'objet IHTTPResponse.
Comportement : Parcourt Resp.Headers et compare les noms en insensitif. Renvoie la première valeur correspondante.

FormatBytes(const SizeBytes: string): string
Rôle : Formater une taille binaire fournie en chaîne en représentation lisible avec suffixes Octets, Ko, Mo, Go et arrondissements.
Comportement :
  • Tente de convertir SizeBytes en entier. Si échoue, extrait les chiffres via regex.
  • Si valeur 0 ou vide, renvoie "n/a".
  • Convertit en unités en divisant et formatant avec FormatFloat.

Retour : Chaîne lisible.

GetWebContent(const URL: string; ListView: TscListView; Depth: Integer; Logging: TscListBox): string
Rôle : Effectue une requête HTTP GET sur l'URL normalisée et récupère le corps en texte brut, met à jour l'UI et le log.
Comportement :
  • Normalise l'URL.
  • Crée THTTPClient, configure timeout et user-agent.
  • Télécharge le contenu dans TMemoryStream et mesure le temps.
  • Récupère status, Content-Type et Content-Length depuis les headers.
  • Convertit le contenu binaire en string et retourne.
  • Met à jour ListView via SafeUpdateListViewStatus et SafeUpdateListViewInfo.
  • Journalise l'opération dans Logging via SafeLog.
  • Applique RequestDelayMs via TThread.Sleep si nécessaire.

Erreurs : Capture les exceptions, met à jour l'UI avec l'exception et renvoie chaîne vide.

IsFileAvailable(const URL: string; ListView: TscListView; StatusBar: TscStatusBar; Depth: Integer; Logging: TscListBox): Boolean
Rôle : Vérifie la disponibilité d'une ressource en exécutant HEAD puis en Fallback GET avec Range bytes=0-0 si HEAD échoue.
Comportement :
  • Normalise l'URL et vérifie robots.txt via IsAllowedByRobots.
  • Tente Client.Head(NormURL) et mesure temps.
  • Si HEAD échoue, effectue un GET avec en-tête Range pour ne récupérer qu'un octet.
  • Extrait Content-Length ou Content-Range pour estimer la taille.
  • Détermine la disponibilité si StatusCode dans [200,299].
  • Met à jour ListView et Logging via SafeUpdateListViewStatus, SafeUpdateListViewInfo et SafeLog.

Retour : True si réponse HTTP 2xx, False sinon.




# Téléchargement de fichiers

DownloadFile(const URL: string; Client: THTTPClient; out LocalPath: string; Logging: TscListBox): Boolean
Rôle : Télécharger la ressource URL vers un fichier local dans DownloadFolder en organisant par type d'extension.
Comportement détaillé :
  • Normalise URL et vérifie robots.txt..
  • Extrait le chemin et le nom de fichier via TURI et TPath.
  • Si pas de nom, utilise "file" et obtient l'extension.
  • Détermine sous-dossier cible via DetermineSubFolderByExtension qui mappe extensions à Image, Document, Audio, Vidéo, Ressources Web/ CSS/JS/HTML/Fonts ou Autre.
  • Crée le dossier cible si nécessaire.
  • Si fichier existant, ajoute suffixe incrémental _n jusqu'à disponibilité.
  • Crée un TFileStream en mode fmCreate et effectue Client.Get pour écrire directement le flux dans le fichier.
  • Si la requête donne un code 2xx, considère le téléchargement réussi, sinon supprime le fichier partiel.
  • Log des succès ou échecs via SafeLog.

Sorties : LocalPath contenant le chemin absolu en cas de succès.
Retour : True si téléchargement réussi.




# Extraction de liens et ressources

ExtractLinks(const HTML: string; BaseURL: string; var LinkList: TStringList)
Rôle : Extraire toutes les URLs d'éléments <a href="..."> depuis le HTML et les normaliser.
Comportement :
  • Utilise une expression régulière insensible à la casse pour trouver href dans les balises <a>.
  • Pour chaque href non vide, normalise via NormalizeURL et ajoute à LinkList si non visité et non déjà présent.

Retour : Remplit LinkList avec URLs absolues.

RemoveURLParams(const URL: string): string
Rôle : Retire la partie query string après le '?' pour obtenir un chemin plus stable pour déduplication et nom de fichier.
Retour : URL sans paramètres.

ExtractMediaSources(const HTML: string; BaseURL: string; var ImageList, DocList, AudioList, VideoList, WebList: TStringList)
Rôle : Rechercher et récupérer les sources médias et ressources web dans le contenu HTML selon les flags d'extension activés.
Comportement :
  • Pour chaque catégorie active, exécute une expression régulière adaptée pour détecter src ou href vers extensions ciblées.
  • Nettoie via RemoveURLParams puis NormalizeURL.
  • Ajoute l'URL dans la liste correspondante si elle a une extension valide et n'existe pas déjà.

Types extraits :
  • Images : .jpg, .jpeg, .png, .gif, .bmp, .webp, .svg.
  • Documents : .pdf, .zip, .rtf, .doc, .docx, .xls, .xlsx, .ppt, .pptx.
  • Audio : .mp3, .wav, .ogg, .m4a, .flac depuis <audio> ou href.
  • Vidéo : .mp4, .webm, .mov, .ogv, .mkv depuis <video> ou href.
  • Web : CSS, JS, HTML, fonts et liens vers fichiers HTML.

Retour : Les listes passées en paramètre sont remplies.




# Traitement des ressources trouvées

ProcessResourceGroup(ResourceList: TStringList; const AcceptExts: array of string; StatusBar: TscStatusBar; ListView: TscListView; Depth: Integer; const DefaultUIType: string; Logging: TscListBox)
Rôle : Routine générique qui traite une liste de ressources d'une catégorie: vérification d'extension, robots.txt, disponibilité, téléchargement ou marquage.
Comportement détaillé :
  • Si AcceptExts vide, accepte n'importe quelle extension.
  • Pour chaque URL de la liste :
  • Respecte les signaux d'annulation et de pause.
  • Filtre par extension si nécessaire.
  • Ignore si déjà dans FFoundFiles.
  • Vérifie robots.txt et marque Ignored si bloquée.
  • Appelle IsFileAvailable pour vérifier disponibilité.
  • Si disponible et FAutoDownload true, crée un THTTPClient local, met à jour UI en "Downloading", appelle DownloadFile et met à jour UI selon succès ou échec, ajoute l'URL à FFoundFiles et écrit FFoundFilePath si configuré.
  • Si disponible et FAutoDownload false, marque NotDownloaded et ajoute à FFoundFiles.
  • Si indisponible, si FAutoDownload false alors marque comme broken via MarkBrokenLink, si FAutoDownload true alors marque Broken_Ignored, ajoute à FBrokenLinks et logge.
  • Met à jour FileCount et vérifie FFoundFilesLimit pour annuler exploration si atteint.
  • Met à jour StatusBar panel pour FileCount à la fin et annule si limite atteinte.

Retour : Aucun. Effets sur dictionnaires, fichiers de rapport, UI et logs.

ProcessFoundFiles(ImageList, DocList, AudioList, VideoList, WebList: TStringList; StatusBar: TscStatusBar; ListView: TscListView; CheckList: TscCheckListBox; Depth: Integer; Logging: TscListBox)
Rôle : Appel ordonné de ProcessResourceGroup pour chaque type activé par les flags FSearch*.
Comportement : Pour chaque catégorie activée, appelle ProcessResourceGroup avec la liste et les extensions acceptées prédéfinies.
Effet : Centralise le traitement des ressources extraites depuis une page.

MarkBrokenLink(const URL: string; ListView: TscListView; StatusBar: TscStatusBar; Logging: TscListBox)
Rôle : Incrémenter BrokenCount, mettre à jour UI et enregistrer le lien cassé.
Comportement :
  • Augmente BrokenCount.
  • Met à jour ListView via SafeUpdateListViewStatus en utilisant la traduction "Broken".
  • Ajoute à FBrokenLinks si non présent et écrit FBrokenFilePath si configuré.
  • Met à jour StatusBar panel pour BrokenCount.
  • Log l'événement.





# Exploration récursive

ExploreLinksRecursive(const URL: string; ListView: TscListView; StatusBar: TscStatusBar; CheckList: TscCheckListBox; Depth: Integer; Logging: TscListBox)
Rôle : Cœur de l'algorithme récursif d'exploration. Gère la visite d'une URL, extraction des liens et ressources, traitement des ressources, et récursion sur les liens extraits.
Étapes détaillées :
  • Vérifie signaux d'annulation et profondeur restante.
  • Calcule CurrentDepth en fonction de FMaxDepth pour affichage.
  • Normalise URL et skip si déjà visitée.
  • Vérifie robots.txt; si bloquée, incrémente compteur et sort.
  • Attend si en pause via FPauseEvent.
  • Ajoute NormURL à VisitedLinks, incrémente TotalLinks et appelle IncrementLinksTraversed, journalise la visite.
  • Vérifie limites FFoundFilesLimit et FExploreLimit et annule si dépassées.
  • Écrit NormURL dans FVisitedFilePath si configuré.
  • Ajoute une ligne "OnHold" au ListView de façon thread-safe pour indiquer URL en cours.
  • Récupère contenu via GetWebContent.
  • Si contenu vide : si FAutoDownload false marque lien cassé via MarkBrokenLink, sinon marque Broken_Ignored et log.

Si contenu présent :
  • Crée listes temporaires Links, Images, Docs, Audio, Video, Webs.
  • ExtractLinks pour récupérer <a href>.
  • Si besoin, ExtractMediaSources pour extraire les ressources selon flags.
  • Appelle ProcessFoundFiles pour traiter et éventuellement télécharger ces ressources.
  • Vérifie à nouveau signaux d'annulation et limites.
  • Parcourt chaque lien extrait et, si SameDomainOnly est true, filtre via IsSameDomain en se basant sur RootDomain, puis appelle ExploreLinksRecursive récursivement avec Depth - 1.
  • Met à jour panels StatusBar avec compteurs.
  • Libère toutes les listes temporaires.

Comportement d'arrêt : Respecte IsCanceled et IsPaused à de multiples points pour arrêt propre et responsive.
Notes : C'est la routine qui construit l'arbre d'exploration et déclenche le traitement des ressources.

ExploreLinks(const URL: string; ListView: TscListView; StatusBar: TscStatusBar; CheckList: TscCheckListBox; MaxDepth: Integer; SaveBrokenToFile, SaveVisitedToFile, SaveFoundFilesToFile: Boolean; Logging: TscListBox)
Rôle : Point d'entrée synchronique pour démarrer une exploration complète depuis une URL racine et gérer les rapports sur disque.
Comportement :
  • Initialise et reset des structures internes, compteurs et états.
  • Prépare les dossiers de rapport sous Documents\Scrapix

\[rootdomain]\Report et le dossier download organisé par type :
  • Initialise FLogFilePath et vide le fichier de log.
  • Configure les chemins FBrokenFilePath, FVisitedFilePath, FFoundFilePath si les options Save* sont cochées et crée des fichiers vides.
  • Détermine RootDomain via TURI.Create(URL).
  • Configure FMaxDepth et log le lancement.
  • Appelle ExploreLinksRecursive pour débuter l'exploration.
  • Après la fin, si SaveBrokenToFile, SaveVisitedToFile ou SaveFoundFilesToFile, consolide les dictionnaires en fichiers placés dans le dossier Documents principal.
  • Journalise la fin et réinitialise les chemins et FRunning, signale FStoppedEvent.

Retour : Procédure synchrone qui ne retourne qu'à la fin du crawl ou après annulation.
Effets : Gère création de rapports et dossiers, et coordination globale du crawl.




# Wrappers thread-safe pour mise à jour UI et log

Les routines suivantes garantissent que les mises à jour de contrôles VCL se font depuis le thread principal ou via TThread.Queue si appelées depuis d'autres threads. Elles respectent FDisableUIUpdates et vérifient l'état des composants avant modification.

  • UIUpdatesAllowed: Boolean — Renvoie la possibilité d'effectuer des mises à jour UI selon FDisableUIUpdates.
  • SafeScrollListViewToBottom(ListView) — Rend visible la dernière ligne du ListView.
  • SafeSetStatusBarPanel(StatusBar, PanelIndex, Text) — Met à jour un panel du StatusBar identifié.
  • SafeUpdateListViewStatus(ListView, URL, StatusText, Method) — Ajoute ou met à jour une ligne dans ListView colonne Status et Method.
  • SafeUpdateListViewDownloadState(ListView, URL, DownloadState) — Met à jour la colonne état de téléchargement.
  • SafeUpdateListViewInfo(ListView, URL, RespMs, SizeBytes, Depth) — Met à jour colonnes temps de réponse, taille et profondeur.
  • SafeLog(Logging, Msg) — Ajoute une ligne au TscListBox et ajuste le horizon horizontal, écrit aussi de façon thread-safe dans FLogFilePath en utilisant FLogLock.

Chaque wrapper :
  • Vérifie UIUpdatesAllowed, paramètre nil et état ComponentState et HandleAllocated.
  • Si appel depuis le thread principal, met à jour directement.
  • Sinon, poste une closure via TThread.Queue qui répète les mêmes vérifications avant mise à jour.





# Filtres et options UI

ApplyFileTypeFiltersFromCheckList(CheckList: TscCheckListBox)
Rôle : Traduire les éléments cochés d'une CheckList en activation des flags FSearchImages, FSearchDocuments, FSearchAudio, FSearchVideo, FSearchWeb.
Comportement :
  • Réinitialise tous les flags à false.
  • Pour chaque item coché, compare le texte et active le flag correspondant.
  • Si libellés non reconnaissables, utilise la position de l'élément pour correspondance par index.

Effet : Permet de contrôler quels types de ressources sont recherchés.




# Comportement global et séquence d'opération

  1. Créer une instance TScrapix et appeler ConfigureCrawl pour paramétrer timeouts, limites, téléchargement et respect robots.
  2. Appeler ExploreLinks avec l'URL racine et options de rapport.
  3. ExploreLinks initialise environnements, crée dossiers et fichiers de rapport, puis appelle ExploreLinksRecursive.
  4. ExploreLinksRecursive normalise les URL, respecte robots.txt, récupère le HTML via GetWebContent, extrait links et ressources, traite les ressources via ProcessFoundFiles puis descend récursivement sur les liens filtrés par SameDomainOnly et profondeur restante.
  5. ProcessResourceGroup vérifie la disponibilité via IsFileAvailable, télécharge si demandé via DownloadFile ou enregistre le fichier trouvé dans FFoundFiles, marque cassés et écrit les rapports.
  6. Tout au long du processus, les wrappers Safe* mettent à jour l'UI et SafeLog journalise et écrit dans FLogFilePath de façon thread-safe.
  7. Les opérations respectent les signaux Pause et Cancel afin d'arrêter proprement l'exploration et permettre la reprise.





# Cycle de vie public

TScrapix s’utilise comme un objet unique pour lancer, contrôler et terminer une session d’exploration. Séquence typique : création, configuration, démarrage (ExploreLinks), contrôles runtime (Pause/Resume/Cancel), attente d’arrêt (WaitForStop) et destruction.

Étapes concrètes du cycle de vie
  • Create: instancier TScrapix pour initialiser structures internes et valeurs par défaut.
  • ConfigureCrawl: appeler pour fixer timeouts, délai entre requêtes, comportement same-domain, téléchargement automatique, respect robots, limite de fichiers trouvés et limite d’exploration.
  • ApplyFileTypeFiltersFromCheckList: appeler si l’état des filtres de type de fichier provient d’une CheckList UI; active/désactive FSearchImages, FSearchDocuments, FSearchAudio, FSearchVideo, FSearchWeb.
  • ExploreLinks(...): lancer l’exploration de manière synchrone en fournissant l’URL racine, contrôles UI (ListView, StatusBar, CheckList), profondeur maximale, options d’écriture des rapports (SaveBrokenToFile, SaveVisitedToFile, SaveFoundFilesToFile) et logging UI. L’appel retourne seulement lorsque le crawl est fini ou annulé.
  • Pendant l’exploration: contrôler par PauseExploration, ResumeExploration et CancelExploration. Consulter IsPaused, IsCanceled, IsRunning pour l’état courant.
  • WaitForStop: si une attente bloquante de la fin est nécessaire, appeler pour s’assurer que toutes les tâches sont terminées.
  • Destroy: libérer l’objet TScrapix et toutes ses ressources; CancelExploration + WaitForStop sont exécutés dans le destructeur pour garantir arrêt propre.

États et transitions publics
  • États internes accessibles via IsRunning, IsPaused, IsCanceled.


Transitions d’état
  • Par défaut après Create, FState = STATE_RUNNING (prêt).
  • ConfigureCrawl n’affecte pas directement FState.
  • ExploreLinks met FRunning = True et TInterlocked.Exchange(FState, STATE_RUNNING).
  • PauseExploration met FState = STATE_PAUSED et reset FPauseEvent. Les routines checkent IsPaused et attendent FPauseEvent.
  • ResumeExploration met FState = STATE_RUNNING et SetEvent sur FPauseEvent.
  • CancelExploration met FState = STATE_CANCEL et SetEvent sur FPauseEvent pour réveiller les waiters. Les boucles respectent IsCanceled et quittent proprement.
  • À la fin d’ExploreLinks (fin normale ou après Cancel), FRunning devient False et FStoppedEvent est SetEvent. WaitForStop retourne alors true.


Configuration publique détaillée
ConfigureCrawl(ARequestTimeoutMs, ARequestDelayMs, ASameDomainOnly, AAutoDownload, ARespectRobots, AFoundFilesLimit, AExploreLimit)
  • ARequestTimeoutMs: timeout en millisecondes pour les requêtes HTTP; si ≤ 0, valeur par défaut 30000 ms.
  • ARequestDelayMs: pause entre requêtes en ms; si < 0 devient 0.
  • ASameDomainOnly: true pour n’explorer que les liens du même domaine racine. RootDomain est extrait dans ExploreLinks.
  • AAutoDownload: true pour télécharger automatiquement les ressources trouvées.
  • ARespectRobots: true pour activer la vérification robots.txt avant HEAD/GET et download.
  • AFoundFilesLimit: nombre maximal de fichiers trouvés/téléchargés; borne [1..2000], valeur par défaut 2000.
  • AExploreLimit: nombre maximal de liens parcourus; borne [1..100], valeur par défaut 100.
  • ApplyFileTypeFiltersFromCheckList(CheckList)
  • Active ou désactive les recherches par type en fonction des éléments cochés de la CheckList UI. Si CheckList est nil, laisse tous les flags à false. Utilise libellés (Image, Document, Audio, Vidéo, Web Document) ou l’index comme fallback.


Propriétés publiques
  • DisableUIUpdates: booléen pour désactiver les mises à jour UI thread-safe; utile pour tests/performance.


Entrée/sortie et rapports
  • ExploreLinks crée (si demandé) ces fichiers dans Documents\Scrapix
  • \[rootdomain]\Report : BrokenLinks.txt, VisitedLinks.txt, FoundFiles.txt.. Le logger écrit Logging.txt dans Report\Logging. Les chemins sont initialisés au début du crawl et vidés à la fin.
  • FFoundFiles, FBrokenLinks et VisitedLinks sont tenus en mémoire pendant la session et écrits sur disque à la fin si les options Save* sont activées.
  • DownloadFolder est créé sous Documents\Scrapix
  • \[rootdomain]\download et les fichiers téléchargés sont organisés en sous-dossiers par type (Image, Document, Audio, Vidéo, Ressources Web/JS/CSS/HTML/Fonts, Autre).


Contrôle d’exécution en pratique
  • Toujours appeler ConfigureCrawl avant ExploreLinks pour fixer timeouts et limites.
  • Pour reprendre un crawl interrompu, détacher ou recréer TScrapix, reconfigurer et relancer ExploreLinks; l’état mémoire interne (VisitedLinks, FFoundFiles) n’est pas persistant entre instances.
  • Utiliser PauseExploration/ResumeExploration pour interruptions courtes; CancelExploration pour arrêter définitivement. Appeler WaitForStop après CancelExploration si on doit attendre la complétion avant Destroy.
  • Pour gros crawls, réduire UIUpdates ou définir DisableUIUpdates à true pour diminuer l’impact UI.
  • Vérifier FRespectRobots avant d’activer AAutoDownload pour respecter les sites.





# Exemple de trace d’exécution pour une page racine contenant 2 liens et 3 images

Contexte
Instance TScrapix configurée avec :
  • RequestTimeoutMs=30000
  • RequestDelayMs=0
  • ASameDomainOnly=True
  • AAutoDownload=False
  • ARespectRobots=True
  • FFoundFilesLimit=2000
  • FExploreLimit=100


Page racine http://example.com/index.html contient 2 liens internes (/page1.html, /page2.html) et 3 images (/img1.jpg, /img2.png, /img3.svg) référencées dans l’HTML.

État initial
  • VisitedLinks empty; FFoundFiles empty; FBrokenLinks empty.
  • FileCount=0; BrokenCount=0; TotalLinks=0; FLinksTraversed=0; FRobotsBlocked=0.
  • DownloadFolder et ReportFolder non créés jusqu’au début d’ExploreLinks.
  • UI: ListView vide; StatusBar panels vides; Logging vide.


Appel ExploreLinks(url=http://example.com/index.html, MaxDepth=2, Save*: true)
  • ExploreLinks initialise dossiers sous Documents\Scrapix\example.com\Report et Logging\Logging.txt, crée DownloadFolder et sous-dossiers, initialise FVisitedFilePath, FBrokenFilePath, FFoundFilePath, met FRunning := True et logge "Starting crawl http://example.com/index.html depth 2" dans Logging.
  • RootDomain extrait "example.com".
  • ExploreLinks appelle ExploreLinksRecursive(url, Depth=2).


ExploreLinksRecursive étape pour la racine
  1. NormalizeURL normalise http://example.com/index.html en http://example.com/index.html..
  2. IsAllowedByRobots vérifie robots.txt pour example.com, ajoute règles au cache si nécessaire; résultat true (autorisé).
  3. VisitedLinks.Add("http://example.com/index.html"), TotalLinks := 1, IncrementLinksTraversed incrémente FLinksTraversed := 1 et met à jour StatusBar panel correspondant.
  4. UI : ListView ajoute une ligne pour http://example.com/index.html avec colonne Status = "OnHold" et colonne Depth = "1".
  5. GetWebContent effectue GET sur la racine, mesure elapsed ms, récupère Content-Type "text/html" et body HTML.
  6. SafeUpdateListViewStatus remplace "OnHold" par "200 text/html" et SafeUpdateListViewInfo met RespMs et taille. Logging ajoute "GET http://example.com/index.html -> 200 text/html (X Ko)".
  7. ExtractLinks trouve deux href normalisés : http://example.com/page1.html et http://example.com/page2.html..
  8. ExtractMediaSources trouve trois images normalisées : http://example.com/img1.jpg, /img2.png, /img3.svg.
  9. ProcessFoundFiles est appelé pour images ; AAutoDownload=false donc chaque image est marqué NotDownloaded et ajouté à FFoundFiles. Pour chaque image : FileCount incremente de 1. UI : pour chaque image SafeUpdateListViewDownloadState = "NotDownloaded" et SafeUpdateListViewStatus = statut HEAD info si IsFileAvailable a été appelé ou bien status initial. Logging ajoute "Found not downloaded" pour chaque image.
  10. Après traitement des ressources : FileCount = 3, StatusBar panel fichiers mis à jour à "3".


Récursion sur les deux liens
Pour chaque lien (page1, page2) ExploreLinksRecursive est appelé avec Depth=1.
Pour page1 :
  1. NormalizeURL -> http://example.com/page1.html..
  2. IsAllowedByRobots true.
  3. VisitedLinks.Add(page1), TotalLinks := 2, FLinksTraversed := 2, StatusBar mis à jour.
  4. UI ajoute ligne OnHold Depth=2.
  5. GetWebContent GET page1 retourne 200 text/html avec HTML vide de ressources (hypothèse).
  6. Aucun média trouvé ; ProcessFoundFiles ne fait rien. Logging "GET page1 -> 200 text/html".
  7. Aucun lien supplémentaire ; fin de la branche page1.


Pour page2 :
  1. NormalizeURL -> http://example.com/page2.html..
  2. IsAllowedByRobots true.
  3. VisitedLinks.Add(page2), TotalLinks := 3, FLinksTraversed := 3, StatusBar mis à jour.
  4. UI ajoute ligne OnHold Depth=2.
  5. GetWebContent GET page2 retourne 404 (hypothèse d’un lien cassé).
  6. SafeUpdateListViewStatus affiche "404 text/html" et SafeUpdateListViewInfo met RespMs et taille. Logging "GET page2 -> 404 text/html (n/a)".
  7. HTMLContent vide ou non utile ; IsAutoDownload=false déclenche MarkBrokenLink(page2) qui : BrokenCount := 1, ajoute page2 à FBrokenLinks, écrit page2 dans BrokenLinks.txt, UI marque statut "Broken", StatusBar panel broken mis à jour à "1", Logging ajoute "Broken link: http://example.com/page2.html".


Fin de l’exploration et écriture des rapports
  1. Après retour de toutes les branches, ExploreLinks termine la récursion.
  2. ExploreLinks écrit VisitedLinks.txt contenant les 3 URL visitées (index, page1, page2).
  3. ExploreLinks écrit FoundFiles.txt contenant les 3 images.
  4. ExploreLinks a déjà écrit BrokenLinks.txt contenant page2.
  5. Logging final ajoute "Finished: files=3 broken=1 totalLinks=3".
  6. FRunning := False et FStoppedEvent.SetEvent. UI StatusBar panels affichent FileCount=3, BrokenCount=1, RobotsBlocked=0, LinksTraversed=3.


Exemple concret de lignes de log séquentielles





TScrapix offre une implémentation complète d'un crawler synchronique orienté application VCL qui normalise les URL, applique robots.txt, extrait liens et ressources, vérifie disponibilité via HEAD/GET, télécharge les ressources en les classant par type, maintient des rapports et met à jour de façon thread-safe l'interface et les logs.
Les primitives d'arrêt, pause et limites garantissent un fonctionnement contrôlé dans des explorations de taille limitée.




Compatibilité générale
TScrapix cible les environnements VCL Windows et nécessite des fonctionnalités RTL/CiE présentes dans les versions modernes de Delphi. En pratique, l’unité est utilisable avec Delphi récents (XE8 et ultérieurs) jusqu’aux versions récentes de RAD Studio

Unités et fonctionnalités minimales requises
  • System.Net.HttpClient et System.Net.URLClient (THTTPClient, IHTTPResponse) pour les requêtes HTTP.
  • System.Threading (TTask, TThread) pour exécution asynchrone et Sleep non bloquant.
  • System.Generics.Collections (TDictionary), System.SyncObjs (TEvent, TCriticalSection).
  • System.Types / System.SysUtils / System.Classes / System.IOUtils (TURI, TPath, TFile, TDirectory, TStringList).
  • System.RegularExpressions (TRegEx).
  • Vcl controls (TListView/TStatusBar/TCheckListBox replacements utilisés ici : TscListView, TscStatusBar, TscCheckListBox, TscListBox — fournis par StyleControls ou à remplacer par composants VCL natifs si nécessaire).





Développé par : XeGregory
IDE : Embarcadero Delphi 11
Composants utilisés Vcl : StyleControls VCL

Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Viadeo Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Twitter Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Google Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Facebook Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Digg Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Delicious Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog MySpace Envoyer le billet « Scrapix, un « Aspirateur » (Web Crawler) » dans le blog Yahoo

Mis à jour 26/10/2025 à 14h48 par XeGregory

Catégories
Programmation , Delphi

Commentaires