Compare commits
970
Commits
v0.9.35rc366
...
v0.9.64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f83a86bcc | ||
|
|
17d1caaff4 | ||
|
|
fa146980ed | ||
|
|
2f9f56fc63 | ||
|
|
35813466d2 | ||
|
|
205f632f84 | ||
|
|
676c76d521 | ||
|
|
43d2aac510 | ||
|
|
4363ae2e3f | ||
|
|
dcca7f9146 | ||
|
|
acf005a292 | ||
|
|
02a45660f6 | ||
|
|
5826e4df87 | ||
|
|
8cf19c829e | ||
|
|
2b327ab2ff | ||
|
|
cdf7ac9dee | ||
|
|
9298b8908c | ||
|
|
ab5f3ba097 | ||
|
|
9f94967660 | ||
|
|
2e0157a466 | ||
|
|
5c459b7ebd | ||
|
|
e236549917 | ||
|
|
9b45afd288 | ||
|
|
aaf31d3634 | ||
|
|
8ccae998af | ||
|
|
7de9d1c83c | ||
|
|
6b3e87f19a | ||
|
|
7816dc357b | ||
|
|
b83ea21a04 | ||
|
|
b5246ccb49 | ||
|
|
64255dab8b | ||
|
|
26fa964dd7 | ||
|
|
f67f5bb254 | ||
|
|
dbdd782d22 | ||
|
|
e3f8ade165 | ||
|
|
e31c51df16 | ||
|
|
9fe980b540 | ||
|
|
32fc0a894c | ||
|
|
326b134bc1 | ||
|
|
03a124fbd1 | ||
|
|
52256c6f42 | ||
|
|
0ec0634843 | ||
|
|
f9919c1777 | ||
|
|
85c5c7695a | ||
|
|
ff0b1276a5 | ||
|
|
36c485bcf4 | ||
|
|
f3f192f34e | ||
|
|
15b112b6fa | ||
|
|
5631c7c296 | ||
|
|
51244c54ad | ||
|
|
0b9dd005e3 | ||
|
|
807356a4fd | ||
|
|
925c197396 | ||
|
|
d96cab7f99 | ||
|
|
1c2187a696 | ||
|
|
5582961895 | ||
|
|
a6b67c0b68 | ||
|
|
6364ee6583 | ||
|
|
89aa93a50d | ||
|
|
3f2c6141a2 | ||
|
|
f2657b2e03 | ||
|
|
3cd6639fd9 | ||
|
|
f835fab44e | ||
|
|
ef426fc80f | ||
|
|
bf8f2358a8 | ||
|
|
3666efd1b3 | ||
|
|
4f66494626 | ||
|
|
0dc192e9bc | ||
|
|
45288b8269 | ||
|
|
ef978e96dd | ||
|
|
4fd10488b8 | ||
|
|
ad9404c6c6 | ||
|
|
5de1d8c329 | ||
|
|
aed7badfa3 | ||
|
|
9846670179 | ||
|
|
aab13f4bc7 | ||
|
|
0f3f4eaa44 | ||
|
|
768aaaa6e3 | ||
|
|
e2c6ffa114 | ||
|
|
d8d8f0416c | ||
|
|
7de25e92ba | ||
|
|
5256c9eee7 | ||
|
|
efef024d05 | ||
|
|
68ba348d61 | ||
|
|
d04474b6c8 | ||
|
|
716f66a0c0 | ||
|
|
0be949796c | ||
|
|
e69c719869 | ||
|
|
9bfee57a40 | ||
|
|
6050d80f1d | ||
|
|
a88a700052 | ||
|
|
2dd935bad4 | ||
|
|
bf24f513d3 | ||
|
|
9cff842298 | ||
|
|
8793a1505b | ||
|
|
4489a67852 | ||
|
|
1151d61cd4 | ||
|
|
b61c0d5f62 | ||
|
|
b3fc391ab7 | ||
|
|
ce8f295690 | ||
|
|
367666b338 | ||
|
|
f48e67b2e6 | ||
|
|
6a59490370 | ||
|
|
a1684b0267 | ||
|
|
5feab49350 | ||
|
|
2963fa9939 | ||
|
|
cb50f9d848 | ||
|
|
98458630c3 | ||
|
|
8473aea817 | ||
|
|
6b9829a62a | ||
|
|
dcda70e697 | ||
|
|
cc92820629 | ||
|
|
12aae53385 | ||
|
|
e5cfa17b3f | ||
|
|
1d39291f48 | ||
|
|
9d84018687 | ||
|
|
602b966f83 | ||
|
|
e8a3202bb2 | ||
|
|
2081895606 | ||
|
|
da539992f7 | ||
|
|
6927dc86e3 | ||
|
|
b0a329dfed | ||
|
|
0d10852fb8 | ||
|
|
dcac00d1a9 | ||
|
|
25e49637e3 | ||
|
|
c6faa4cc2f | ||
|
|
b67d71707d | ||
|
|
03a7cdc4f3 | ||
|
|
544bc01506 | ||
|
|
b6c67ed1bd | ||
|
|
184ba064cd | ||
|
|
ece444796c | ||
|
|
684bbdaffa | ||
|
|
3a912fee44 | ||
|
|
ad141780c2 | ||
|
|
45ee3f291a | ||
|
|
2f8d991bbd | ||
|
|
c814fba6d5 | ||
|
|
a50356862c | ||
|
|
43c5c2ce5b | ||
|
|
e6164ca52a | ||
|
|
637c3ec039 | ||
|
|
f455d75e60 | ||
|
|
5a0fb68cf1 | ||
|
|
4c0dc81030 | ||
|
|
f7ab65e7a7 | ||
|
|
0eb82d3e03 | ||
|
|
80def3957c | ||
|
|
6346392f49 | ||
|
|
04ccd9420d | ||
|
|
d0cf7507a7 | ||
|
|
27965e2762 | ||
|
|
1082e21535 | ||
|
|
aabae0f56b | ||
|
|
df9e47f987 | ||
|
|
d29cfa7291 | ||
|
|
5c298b8ec0 | ||
|
|
ca207173a1 | ||
|
|
e0f0548dc8 | ||
|
|
3ab9ae5d43 | ||
|
|
1519aa4b0a | ||
|
|
41f8788600 | ||
|
|
eb7c08eff2 | ||
|
|
0b2367106c | ||
|
|
b5646cc6e8 | ||
|
|
b6ac5d232e | ||
|
|
1f142fcdde | ||
|
|
daf964ecf2 | ||
|
|
dd7b3008e4 | ||
|
|
6438403b15 | ||
|
|
3c7f247cb9 | ||
|
|
bbeb15e3ad | ||
|
|
cb6ad96ddd | ||
|
|
fe1a3d8a32 | ||
|
|
06b822a0e1 | ||
|
|
9ee55416c8 | ||
|
|
ddd6c49f3a | ||
|
|
6d05c2ef0a | ||
|
|
d8e38bec32 | ||
|
|
77f5221644 | ||
|
|
aa1183662a | ||
|
|
413919ae2b | ||
|
|
34d68554d7 | ||
|
|
ffb48bc0ca | ||
|
|
cf4b37ae3d | ||
|
|
64248a7084 | ||
|
|
2224f1579b | ||
|
|
168e3ebdc4 | ||
|
|
fe26a4bd73 | ||
|
|
125682a71f | ||
|
|
0dd9042033 | ||
|
|
ba52d12301 | ||
|
|
e0bfb9dc7c | ||
|
|
067ca0d711 | ||
|
|
ca84a102c8 | ||
|
|
543f96510d | ||
|
|
0f20d8cb8f | ||
|
|
16eacfafc6 | ||
|
|
bcac032bf1 | ||
|
|
82b48f1c82 | ||
|
|
ee8682bd6b | ||
|
|
da754487dc | ||
|
|
56309da4e2 | ||
|
|
70d56cfa7f | ||
|
|
29a76b89a3 | ||
|
|
0ad98c8f02 | ||
|
|
c902d9d1de | ||
|
|
6dc33dd5e9 | ||
|
|
d621cae682 | ||
|
|
19d3dc9daa | ||
|
|
a75bb7fea0 | ||
|
|
4fdf0e147b | ||
|
|
1f279e7080 | ||
|
|
1fac3a45ab | ||
|
|
448ecda16e | ||
|
|
5abfa46c35 | ||
|
|
71e308ed7b | ||
|
|
5eaa6f6998 | ||
|
|
efb2a042f3 | ||
|
|
bc057afc17 | ||
|
|
1d1086f105 | ||
|
|
79614ed4de | ||
|
|
34eb14bee5 | ||
|
|
5ad350a0f3 | ||
|
|
12e8f23ee4 | ||
|
|
7e0a52a33c | ||
|
|
31938144e1 | ||
|
|
2d4ac1c2ac | ||
|
|
31972e2543 | ||
|
|
d59bab6143 | ||
|
|
b63671158d | ||
|
|
716e1914d9 | ||
|
|
73e66ac7c3 | ||
|
|
805b5fcba9 | ||
|
|
f19b97295a | ||
|
|
fba6b0ba00 | ||
|
|
56be00b47c | ||
|
|
8bea7f63c8 | ||
|
|
d50211e951 | ||
|
|
d9281ca3e0 | ||
|
|
3a2414e8d6 | ||
|
|
18ed3c3baa | ||
|
|
c5e66f0754 | ||
|
|
6460625491 | ||
|
|
9288276bae | ||
|
|
cfb233d6d1 | ||
|
|
7afc41ab9e | ||
|
|
e5be511741 | ||
|
|
d94590f652 | ||
|
|
78847a1ad5 | ||
|
|
32ea249b12 | ||
|
|
0fba2c4d38 | ||
|
|
6bc4f414b3 | ||
|
|
0ec9bb32a8 | ||
|
|
6396d6a387 | ||
|
|
04e084f911 | ||
|
|
49391ac78e | ||
|
|
c54ce1fd73 | ||
|
|
a0ec6c8055 | ||
|
|
f9d75fb0f4 | ||
|
|
f96971536f | ||
|
|
7eae2fd5c3 | ||
|
|
ecad318b80 | ||
|
|
4bc28cdb54 | ||
|
|
61dc8ebd74 | ||
|
|
6a8d6057af | ||
|
|
f7ce1be391 | ||
|
|
d829c3f804 | ||
|
|
9aeb0400f1 | ||
|
|
4f9e89a390 | ||
|
|
7a808bf149 | ||
|
|
3af2ee222c | ||
|
|
3f252f7573 | ||
|
|
a33217ccea | ||
|
|
4504bab7e3 | ||
|
|
83439dfe17 | ||
|
|
761455008c | ||
|
|
7cfdd4c18f | ||
|
|
5fa08d6c14 | ||
|
|
a093ebb929 | ||
|
|
000455f0fe | ||
|
|
0948bd3498 | ||
|
|
82d82a3227 | ||
|
|
5b06e9178c | ||
|
|
4228d5e77a | ||
|
|
4a89e73c7d | ||
|
|
6168cecdb3 | ||
|
|
b8b2c34137 | ||
|
|
23192028df | ||
|
|
2002dcc1e0 | ||
|
|
f8e94649bf | ||
|
|
480c97a560 | ||
|
|
d395de326a | ||
|
|
e0645f96f4 | ||
|
|
b51f9240e8 | ||
|
|
54ae9b3a2f | ||
|
|
19cd801f2a | ||
|
|
0ee5ddd8cc | ||
|
|
875f932670 | ||
|
|
788d6615e5 | ||
|
|
6c5a35dc0f | ||
|
|
2ece14592e | ||
|
|
711371b3e2 | ||
|
|
588d67545b | ||
|
|
875c540548 | ||
|
|
97bf399311 | ||
|
|
ae171e129f | ||
|
|
807289e2aa | ||
|
|
f0baeda72f | ||
|
|
2a8b22854c | ||
|
|
cae4ad8fe5 | ||
|
|
a408f9cfd1 | ||
|
|
997a2eaabd | ||
|
|
583006c3ab | ||
|
|
7e38856270 | ||
|
|
324568351e | ||
|
|
26c6b57f94 | ||
|
|
7f46b7a208 | ||
|
|
c93450a4d5 | ||
|
|
c13e153cb4 | ||
|
|
69129ff433 | ||
|
|
3b85a8bef9 | ||
|
|
02b10f7cdf | ||
|
|
2b6c84c995 | ||
|
|
d767a58f1c | ||
|
|
f428d9dc2c | ||
|
|
841f91bb37 | ||
|
|
314bef9881 | ||
|
|
53f3e33c9a | ||
|
|
67347f3881 | ||
|
|
7650ab9269 | ||
|
|
7f564ab7f8 | ||
|
|
a23d68bfea | ||
|
|
ebc06be775 | ||
|
|
3ed300f1c1 | ||
|
|
2a9ca3cd4f | ||
|
|
d0634661cf | ||
|
|
d1790b9e85 | ||
|
|
21447fec2f | ||
|
|
956422e158 | ||
|
|
95aa25e422 | ||
|
|
597efebf91 | ||
|
|
c9aa76c1b6 | ||
|
|
ccb175eec7 | ||
|
|
aaf104107e | ||
|
|
109326dcec | ||
|
|
56bb67a4da | ||
|
|
c400c7caf1 | ||
|
|
9b78a0a221 | ||
|
|
220637ae9b | ||
|
|
03d8bf9b39 | ||
|
|
8c60511da1 | ||
|
|
2ac4167c27 | ||
|
|
df570ae4fb | ||
|
|
2df6515185 | ||
|
|
193e4183c1 | ||
|
|
848c191d50 | ||
|
|
3aa1f9a340 | ||
|
|
ca3d5f5653 | ||
|
|
4315a2aa55 | ||
|
|
9741f97392 | ||
|
|
cece0309be | ||
|
|
da1aeb7bfe | ||
|
|
9b1bcc4b93 | ||
|
|
d63af05e85 | ||
|
|
41b23b1211 | ||
|
|
079ea29529 | ||
|
|
b2b7a8b700 | ||
|
|
da52ab5ee9 | ||
|
|
49fb004ed6 | ||
|
|
f94c76078d | ||
|
|
b3f8e3f714 | ||
|
|
9978649bcb | ||
|
|
6e82266e93 | ||
|
|
5a83ff351a | ||
|
|
a7b69fa2fa | ||
|
|
72fb4bcdb0 | ||
|
|
9c196be557 | ||
|
|
97fe7da914 | ||
|
|
702924dd2b | ||
|
|
30453378dc | ||
|
|
069d180841 | ||
|
|
006845645d | ||
|
|
27dd467259 | ||
|
|
e1c7a8de49 | ||
|
|
0790d2566d | ||
|
|
319f72ee74 | ||
|
|
1a166e39f0 | ||
|
|
3586fe9aed | ||
|
|
44450dbfac | ||
|
|
7098b97ef4 | ||
|
|
a3f9541535 | ||
|
|
19cb7df303 | ||
|
|
a1261abe94 | ||
|
|
7e0985b54d | ||
|
|
f1e49da2c8 | ||
|
|
f6b011039b | ||
|
|
abab45754c | ||
|
|
a5105b16dd | ||
|
|
eb68405145 | ||
|
|
781f27757b | ||
|
|
ecc756b044 | ||
|
|
9743c31239 | ||
|
|
e50265768e | ||
|
|
335798f16a | ||
|
|
85a6d3d311 | ||
|
|
818ca9a801 | ||
|
|
a5a2ff66bf | ||
|
|
3bfb27d0fd | ||
|
|
d4ab858b3f | ||
|
|
2e29df5b8d | ||
|
|
9228ba3740 | ||
|
|
24ee93d3b4 | ||
|
|
9293ac248b | ||
|
|
1501eb9531 | ||
|
|
c20eb4cfce | ||
|
|
ce1108c275 | ||
|
|
e32f95bf31 | ||
|
|
7ecec0e9d7 | ||
|
|
2a6e2f226e | ||
|
|
ea02473dce | ||
|
|
0221227d0a | ||
|
|
9ed08e262f | ||
|
|
e5f149db41 | ||
|
|
dcd4382e21 | ||
|
|
073410cc74 | ||
|
|
510319b0a1 | ||
|
|
623768177b | ||
|
|
9dd58a4368 | ||
|
|
94402250e0 | ||
|
|
5eba10ddab | ||
|
|
7162af4fb8 | ||
|
|
58cd6a6df9 | ||
|
|
286ef28d5a | ||
|
|
863882fa93 | ||
|
|
f111c193b5 | ||
|
|
ad854323a9 | ||
|
|
0178c5cbe4 | ||
|
|
6d00ab590c | ||
|
|
4d263f208d | ||
|
|
8fae69725c | ||
|
|
436689fd29 | ||
|
|
26b2120d21 | ||
|
|
8b3caa2773 | ||
|
|
e761f76420 | ||
|
|
a20075ef90 | ||
|
|
8ebcb131fb | ||
|
|
8b6b9efbb9 | ||
|
|
2c146ae20b | ||
|
|
2dcfc93b1f | ||
|
|
a192a16314 | ||
|
|
dc4f3ba12c | ||
|
|
0b61d05f14 | ||
|
|
d9414487d2 | ||
|
|
21aeea3a66 | ||
|
|
810a5e4f59 | ||
|
|
372d06fe81 | ||
|
|
98b3b67f36 | ||
|
|
193b699b83 | ||
|
|
273c5d6f3c | ||
|
|
21ed8ea960 | ||
|
|
716178290a | ||
|
|
3177596156 | ||
|
|
0c1d0f561c | ||
|
|
746d487390 | ||
|
|
be35cb45b0 | ||
|
|
afd7576ad8 | ||
|
|
aba8f1342a | ||
|
|
b50f1e7692 | ||
|
|
a056d5daf7 | ||
|
|
4384d09e3f | ||
|
|
3f3166cfea | ||
|
|
3eb01ddd25 | ||
|
|
4b52eed55a | ||
|
|
105c1e793e | ||
|
|
f4b9634863 | ||
|
|
35a2e9c9c6 | ||
|
|
d76083d26c | ||
|
|
f5b2f6a030 | ||
|
|
83c83e53c3 | ||
|
|
0a4f328fa6 | ||
|
|
c4388d2435 | ||
|
|
7af15501f8 | ||
|
|
b9c9daaa65 | ||
|
|
c3f9c1a9ef | ||
|
|
e03db54bca | ||
|
|
23a5c6bba9 | ||
|
|
7a5ad4c99a | ||
|
|
1a93058585 | ||
|
|
85033aa622 | ||
|
|
922bcc05ba | ||
|
|
070b8ae9c5 | ||
|
|
f77ac105a1 | ||
|
|
f41f0afa03 | ||
|
|
31062043fc | ||
|
|
eeb06148ad | ||
|
|
80d3804e56 | ||
|
|
3a4696165d | ||
|
|
28b3bda674 | ||
|
|
8de094435e | ||
|
|
4c993242dc | ||
|
|
c3e6f62d0f | ||
|
|
f141cb841a | ||
|
|
10152bce2a | ||
|
|
d6fe46d327 | ||
|
|
a7d4245c36 | ||
|
|
8b634f9568 | ||
|
|
9cddb2b2dc | ||
|
|
7e7af50eff | ||
|
|
e019a2b07a | ||
|
|
0d2a4bf393 | ||
|
|
8914d95e83 | ||
|
|
0f965972e3 | ||
|
|
7a66e8ef6e | ||
|
|
be857415ad | ||
|
|
bfd6b3c2a7 | ||
|
|
69667e5cdb | ||
|
|
fffbdf0226 | ||
|
|
d44422fccb | ||
|
|
9c7a3f1f16 | ||
|
|
52328ad841 | ||
|
|
92008db9c4 | ||
|
|
e2d3b74dcd | ||
|
|
0b74322ea3 | ||
|
|
4d888e94d0 | ||
|
|
43c6dc2ea7 | ||
|
|
eeb057433a | ||
|
|
f31727e157 | ||
|
|
03c7074f4e | ||
|
|
845822f7ae | ||
|
|
592c79a52e | ||
|
|
d0e01fe00f | ||
|
|
e90aa46d73 | ||
|
|
deddb4d415 | ||
|
|
9a6adf146c | ||
|
|
2b568939ec | ||
|
|
8b98009cb0 | ||
|
|
01f2af413a | ||
|
|
27d243742c | ||
|
|
de595545ba | ||
|
|
22f0009a79 | ||
|
|
ab5458e22f | ||
|
|
7eddcd6e3c | ||
|
|
9b2fc58e5c | ||
|
|
e239984415 | ||
|
|
a7baeae476 | ||
|
|
b4f9633831 | ||
|
|
16b76cac0d | ||
|
|
48ced005a8 | ||
|
|
498d7715b2 | ||
|
|
736a93cb92 | ||
|
|
5c99cdf7c9 | ||
|
|
8da338a887 | ||
|
|
155097ce6f | ||
|
|
eb9bb069d6 | ||
|
|
53523a6a1e | ||
|
|
ffdc16fc22 | ||
|
|
8d14775759 | ||
|
|
cd5f96c26a | ||
|
|
877e5f3104 | ||
|
|
d91ea053a2 | ||
|
|
f326097d33 | ||
|
|
efa29074f8 | ||
|
|
99fcb02963 | ||
|
|
e4981957d4 | ||
|
|
8c83947331 | ||
|
|
13f9564fa4 | ||
|
|
4d8b162099 | ||
|
|
b8d7669c6c | ||
|
|
d08b483c3b | ||
|
|
f3f7d564e3 | ||
|
|
38b65fee63 | ||
|
|
6580ed01f3 | ||
|
|
8cc3d78b3d | ||
|
|
1dd74f3ec8 | ||
|
|
bacd3d053e | ||
|
|
287e792d29 | ||
|
|
383adc4e35 | ||
|
|
ba740b4568 | ||
|
|
e7c419ce78 | ||
|
|
363b79e13f | ||
|
|
05b1c8b829 | ||
|
|
cf03d6c8b3 | ||
|
|
6cfdbbd68b | ||
|
|
2c0d5584e2 | ||
|
|
288af543a6 | ||
|
|
afeb937db7 | ||
|
|
c8a699da0b | ||
|
|
6e49cf279f | ||
|
|
631378a80d | ||
|
|
1ef37a02dd | ||
|
|
b618e20fbf | ||
|
|
6be2bd85e5 | ||
|
|
8149cc0320 | ||
|
|
72b5439137 | ||
|
|
8f87aa9e43 | ||
|
|
e83f2c1808 | ||
|
|
a6d56657b2 | ||
|
|
67af2f3a95 | ||
|
|
b47fc056e3 | ||
|
|
007ecaea80 | ||
|
|
d2eea2e0f1 | ||
|
|
a88fa72b16 | ||
|
|
fead132b5c | ||
|
|
bfef7285f6 | ||
|
|
35efcea657 | ||
|
|
9b58aa30e5 | ||
|
|
38dce9b358 | ||
|
|
bc8daf18c0 | ||
|
|
4cd5d170ea | ||
|
|
4ec52a646b | ||
|
|
ee78c2c58b | ||
|
|
bdfa7f45fc | ||
|
|
1ec5582527 | ||
|
|
75697e4ebf | ||
|
|
7a19792be8 | ||
|
|
601b2db35b | ||
|
|
16506ed55a | ||
|
|
cc218245c3 | ||
|
|
b625cf4958 | ||
|
|
92c0f52324 | ||
|
|
880ab87da2 | ||
|
|
7e34e43cf5 | ||
|
|
4a1e06fda7 | ||
|
|
e1647d8f93 | ||
|
|
0badc70c4d | ||
|
|
9fbddc5b99 | ||
|
|
eb7e6f2ba2 | ||
|
|
a7eb82ffda | ||
|
|
d3f2509c30 | ||
|
|
3e1a31755c | ||
|
|
70bce92bf3 | ||
|
|
80e4a1c4f8 | ||
|
|
ceb3c04aff | ||
|
|
953f29b65e | ||
|
|
404f2efc04 | ||
|
|
c23389c0a8 | ||
|
|
7837373d81 | ||
|
|
3600ac54ea | ||
|
|
ec47877277 | ||
|
|
9a33749eca | ||
|
|
c104931341 | ||
|
|
6a245d70d9 | ||
|
|
4ca3b180a8 | ||
|
|
a520e25cbb | ||
|
|
271a4c1c40 | ||
|
|
c935a2b801 | ||
|
|
d1c692f044 | ||
|
|
f6f7be2ddc | ||
|
|
f38bb4c2e4 | ||
|
|
a577ea147f | ||
|
|
89007da097 | ||
|
|
ea243e1bcb | ||
|
|
f85e8d5dc0 | ||
|
|
400de11916 | ||
|
|
fce31ac965 | ||
|
|
014e193e6b | ||
|
|
505f53c733 | ||
|
|
fe9b59eb63 | ||
|
|
b097af5417 | ||
|
|
27ac1cc66f | ||
|
|
b65151dbbe | ||
|
|
5c0a065242 | ||
|
|
a908323133 | ||
|
|
f5d4645f41 | ||
|
|
422fc5dfef | ||
|
|
d8e6a56738 | ||
|
|
50a462e786 | ||
|
|
d7f67ba5bc | ||
|
|
563ca151f5 | ||
|
|
9f6535006a | ||
|
|
0497d4ff0c | ||
|
|
42ed15b733 | ||
|
|
15f6f7260e | ||
|
|
cd159cf7cf | ||
|
|
1fa2d4dc96 | ||
|
|
00a8105b6c | ||
|
|
d96092d76d | ||
|
|
3f54139240 | ||
|
|
673a1fb4ed | ||
|
|
a711ae7da0 | ||
|
|
680ef683ee | ||
|
|
3350aba3f3 | ||
|
|
83ed0e9a4c | ||
|
|
d3aa90b060 | ||
|
|
e3a5f77981 | ||
|
|
786a9d07df | ||
|
|
510365a282 | ||
|
|
4d12bfd650 | ||
|
|
0f1a89b199 | ||
|
|
c73884e3d8 | ||
|
|
06572f2e38 | ||
|
|
d7e9504a52 | ||
|
|
c09fa8ffd3 | ||
|
|
d00ffe9f3b | ||
|
|
29d40addf4 | ||
|
|
b512553d75 | ||
|
|
a2f8d81b60 | ||
|
|
63326f501a | ||
|
|
79d864915d | ||
|
|
8c5ea7974c | ||
|
|
9a00ac6929 | ||
|
|
5d6fc0cc3c | ||
|
|
86b81452db | ||
|
|
30a1ec8fa3 | ||
|
|
1f3f3b6e0f | ||
|
|
e81802c91b | ||
|
|
57240f8798 | ||
|
|
a9b5fe0911 | ||
|
|
72f011c734 | ||
|
|
af73e93d51 | ||
|
|
9b93a55fcb | ||
|
|
09fb7270c5 | ||
|
|
b9833c962c | ||
|
|
77ce497ad7 | ||
|
|
d3275f445d | ||
|
|
8977cdde4e | ||
|
|
c1e030919a | ||
|
|
cd5202bce0 | ||
|
|
3a32fe9404 | ||
|
|
f973e0a353 | ||
|
|
7265eb0569 | ||
|
|
4ea7acd3c6 | ||
|
|
deb44ea972 | ||
|
|
9e5fbb1374 | ||
|
|
6854990fdf | ||
|
|
9dffbb9d6e | ||
|
|
dd5566bf7a | ||
|
|
39424e50f9 | ||
|
|
d74101d6fa | ||
|
|
f9d82715c0 | ||
|
|
23966521e3 | ||
|
|
fb79e3f80a | ||
|
|
18f69e642f | ||
|
|
936bbc3d47 | ||
|
|
da9b867daf | ||
|
|
3de162548a | ||
|
|
0057e64320 | ||
|
|
0d061392f8 | ||
|
|
2a33e1c46b | ||
|
|
2066f6636b | ||
|
|
04755292df | ||
|
|
d1240c1fb3 | ||
|
|
558a7c75e6 | ||
|
|
f0d7947874 | ||
|
|
ae955386dc | ||
|
|
6f8e3b75a3 | ||
|
|
974cb91a22 | ||
|
|
434a78a02d | ||
|
|
603bc01fa8 | ||
|
|
a59742940b | ||
|
|
5989ddfa41 | ||
|
|
544f67f16c | ||
|
|
7259c8cd06 | ||
|
|
042f725ef2 | ||
|
|
5b3bd229c8 | ||
|
|
fb66ca15d6 | ||
|
|
95441477cf | ||
|
|
b142c2e62d | ||
|
|
a85fe33f27 | ||
|
|
d044f5ee0e | ||
|
|
b960bfb93f | ||
|
|
5b9c9f9b8a | ||
|
|
775b7f4575 | ||
|
|
cfd71387f0 | ||
|
|
eb1822ea55 | ||
|
|
968de79f60 | ||
|
|
2ad4fb5eb4 | ||
|
|
4ae076e0fb | ||
|
|
e7ff7cdad2 | ||
|
|
cde3542961 | ||
|
|
c28d470f9a | ||
|
|
27c2a947b4 | ||
|
|
955f30ae0d | ||
|
|
0b08388e97 | ||
|
|
9a3f058f73 | ||
|
|
c2566e9b4a | ||
|
|
9b1f155507 | ||
|
|
747f4a07ac | ||
|
|
ca55f7f8d6 | ||
|
|
79fc5c34d2 | ||
|
|
57e7820a0b | ||
|
|
57ee874d6e | ||
|
|
f5234360d7 | ||
|
|
e5ac2709f3 | ||
|
|
650697f3c2 | ||
|
|
91aa70cc73 | ||
|
|
8a12e3399c | ||
|
|
944907bfee | ||
|
|
7f1767a2db | ||
|
|
3327edba24 | ||
|
|
a71ef00b79 | ||
|
|
a33b333b6a | ||
|
|
00e867d949 | ||
|
|
69ca67b37d | ||
|
|
4149b6f578 | ||
|
|
2d23264bd6 | ||
|
|
e9feb8162a | ||
|
|
7d7e219962 | ||
|
|
24470ca551 | ||
|
|
ff2766c007 | ||
|
|
75cf3f1261 | ||
|
|
b44ab723d2 | ||
|
|
ce529842e3 | ||
|
|
378133e44c | ||
|
|
3954119a0e | ||
|
|
1e53928ed7 | ||
|
|
6241102ea7 | ||
|
|
9a00c2f0b4 | ||
|
|
4a31c409ca | ||
|
|
72f6c70ffa | ||
|
|
08f8baabbd | ||
|
|
31bdeb4534 | ||
|
|
e7ab7b087b | ||
|
|
480ad08cf0 | ||
|
|
b799742f51 | ||
|
|
5c34a68ad8 | ||
|
|
d6106a456f | ||
|
|
ebaeb9ea3f | ||
|
|
0a9d8fd525 | ||
|
|
a29dd2edbc | ||
|
|
ec4b8ef2ef | ||
|
|
e878007b6a | ||
|
|
ae9399b5fd | ||
|
|
2e0c0f14e9 | ||
|
|
35e014ae90 | ||
|
|
baa3e37c59 | ||
|
|
b7b4f34090 | ||
|
|
ec1d3381b3 | ||
|
|
410d5db1bf | ||
|
|
8bc9f40e9f | ||
|
|
c791eabfb0 | ||
|
|
1a5c6686c2 | ||
|
|
a85d417d0f | ||
|
|
68f5caaa29 | ||
|
|
c3b99ceece | ||
|
|
ac7598aae2 | ||
|
|
9ee0dcf165 | ||
|
|
1c3b4bc5ce | ||
|
|
344dc86d83 | ||
|
|
718beb3020 | ||
|
|
27efbe6bb0 | ||
|
|
94a9a4d444 | ||
|
|
970c1adedf | ||
|
|
40e6ded0f7 | ||
|
|
3416a1f707 | ||
|
|
30998a0d16 | ||
|
|
809bd938a3 | ||
|
|
2bba758314 | ||
|
|
0a8f15e130 | ||
|
|
cb0155f88a | ||
|
|
eeb47938c4 | ||
|
|
1769f8b6e3 | ||
|
|
71c410e791 | ||
|
|
2b6f379453 | ||
|
|
9e9a04bec8 | ||
|
|
f45f7e33a6 | ||
|
|
8c0cb7499f | ||
|
|
bd7775e4f4 | ||
|
|
8dbec73ee6 | ||
|
|
8702aa33cc | ||
|
|
11df983090 | ||
|
|
8ed30dddb0 | ||
|
|
045d7fd886 | ||
|
|
b4421b8f92 | ||
|
|
1594184239 | ||
|
|
79b958e482 | ||
|
|
a512884e9e | ||
|
|
663017924b | ||
|
|
b27f75647c | ||
|
|
6d7d069cc0 | ||
|
|
f4aa4d31c5 | ||
|
|
8ed80ded07 | ||
|
|
f3a8c6945d | ||
|
|
07975eccc7 | ||
|
|
1af2e8f544 | ||
|
|
d6b1b269ca | ||
|
|
e7b3f1c485 | ||
|
|
23b91d6840 | ||
|
|
95a7e64dd7 | ||
|
|
071bc0cce6 | ||
|
|
3e58261cd5 | ||
|
|
0f1a402940 | ||
|
|
5bead960b3 | ||
|
|
d5ff47b7e6 | ||
|
|
457e616d1d | ||
|
|
7f9d9ca2c1 | ||
|
|
dbb9f45f53 | ||
|
|
ef7d590655 | ||
|
|
0c6f017f0a | ||
|
|
fbff88fb0e | ||
|
|
07df9c76a8 | ||
|
|
97136703f6 | ||
|
|
2f761615f0 | ||
|
|
8dc91f90be | ||
|
|
2af88da0d4 | ||
|
|
032c20bdf5 | ||
|
|
d42b530caf | ||
|
|
53c300a387 | ||
|
|
0498a87884 | ||
|
|
e53c9cca7a | ||
|
|
743ca335ad | ||
|
|
2d8de8931b | ||
|
|
c0e09fa22a | ||
|
|
89bb245fac | ||
|
|
f94230e750 | ||
|
|
c5353401ea | ||
|
|
dfa2d84bef | ||
|
|
3a55240f79 | ||
|
|
b0acf27b5f | ||
|
|
9720dbe711 | ||
|
|
c67f0c8ec5 | ||
|
|
048f09a31d | ||
|
|
dda6bfdfb5 | ||
|
|
e1425c0a54 | ||
|
|
c09070c2f3 | ||
|
|
9c8bea8fdf | ||
|
|
92585fe9b5 | ||
|
|
376273270a | ||
|
|
a6eb9b240e | ||
|
|
aaea35f9fd | ||
|
|
64e36a1d57 | ||
|
|
89b6a9c612 | ||
|
|
fa84f2facb | ||
|
|
0302c4a31c | ||
|
|
e1e5b74cc4 | ||
|
|
2bfbfd153d | ||
|
|
6a2eb62295 | ||
|
|
1f6c568ecc | ||
|
|
d142a77c62 | ||
|
|
aeceedbebb | ||
|
|
f9c94c2c52 | ||
|
|
c2ce82964d | ||
|
|
5b99586c1f | ||
|
|
92a939a6a2 | ||
|
|
c817da7af6 | ||
|
|
3fc67705a5 | ||
|
|
97486882bd | ||
|
|
c062a3c54f | ||
|
|
bdc236f076 | ||
|
|
534b467ee8 | ||
|
|
dc61f27632 | ||
|
|
a2b08bdb36 | ||
|
|
ffba3533b8 | ||
|
|
03af59da85 | ||
|
|
9e82159a20 | ||
|
|
e107b42075 | ||
|
|
01a6ed1cd7 | ||
|
|
081f39e004 | ||
|
|
1bd42f37e3 | ||
|
|
00aa3d572f | ||
|
|
8e7dacf74f | ||
|
|
a5fdb9bf7f | ||
|
|
bd4c585bd2 | ||
|
|
d08858dc03 | ||
|
|
df3e23e3e8 | ||
|
|
68c4c2e7a4 | ||
|
|
c6c3d6742e | ||
|
|
cd422b201b | ||
|
|
9618b39d6e | ||
|
|
37a5f205ca | ||
|
|
648db077e8 | ||
|
|
aa1a8fb9f3 | ||
|
|
3830544e13 | ||
|
|
913542c19f | ||
|
|
f603a0dfff | ||
|
|
5e47e055a2 | ||
|
|
d1136d74cb | ||
|
|
54f598789e | ||
|
|
5171eaf31e |
No files matched your search
@@ -30,8 +30,8 @@ body:
|
||||
description: Please provide the exact steps you took to trigger the issue (including any shell commands run, URLs visited, buttons clicked, etc.).
|
||||
render: markdown
|
||||
placeholder: |
|
||||
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox` in iTerm2
|
||||
2. Went to the https://127.0.0.1:8000/add/ page in Google Chrome
|
||||
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 5797:5797 archivebox/archivebox` in iTerm2
|
||||
2. Went to the https://127.0.0.1:5797/add/ page in Google Chrome
|
||||
3. Typed 'https://example.com/xyz' into the 'Add URL' input field
|
||||
4. Clicked the 'Add+' button
|
||||
5. Got a 500 error and saw the errors below in terminal
|
||||
@@ -138,7 +138,7 @@ body:
|
||||
archivebox:
|
||||
image: archivebox/archivebox:latest
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 5797:5797
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
}
|
||||
],
|
||||
"docker_binaries": [
|
||||
{
|
||||
"name": "{BASH_BINARY}",
|
||||
"binproviders": "env,brew,apt"
|
||||
},
|
||||
{
|
||||
"name": "{DOCKER_BINARY}",
|
||||
"binproviders": "env"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# archivebox.io website
|
||||
|
||||
This repository owns its complete website. Build and deploy it without checking
|
||||
out another repository's website, fetching shared snippets, or calling a shared
|
||||
workflow. Common files are ordinary committed copies; update them deliberately.
|
||||
|
||||
## Repository-specific files
|
||||
|
||||
- Content: `publicsite/index.html`.
|
||||
- Site/gallery generation: `bin/generate_ui_screenshot_gallery.py`.
|
||||
- Application capture: `bin/collect_ui_screenshots.sh`.
|
||||
- Deployment branch: `dev`.
|
||||
|
||||
## Common publication contract
|
||||
|
||||
1. Build pull requests without deploying them.
|
||||
2. Build website pushes immediately, independently of application capture CI.
|
||||
3. Restore validated screenshots from trusted default-branch capture jobs.
|
||||
Keep the original capture revision, timestamp, coverage and image checksums.
|
||||
4. Retain `site-screenshots` artifacts for 90 days. After expiry, restore the
|
||||
published image set. Never replace a complete gallery with an empty one when
|
||||
restoration fails. Apple device groups are independently validated artifacts;
|
||||
Android's checked-in bootstrap captures retain their local provenance.
|
||||
5. Render current content and layout around those captures. Site revision and
|
||||
capture revision are separate facts; reusing captures must not relabel them.
|
||||
6. Serialize production restore/build/deploy under one concurrency group and
|
||||
check out the latest default-branch revision when each build starts. Keep the
|
||||
built revision in provenance; later release-bot commits must not block deploys.
|
||||
7. Keep platform-specific build, installation, capture, and completeness checks
|
||||
in the application's existing capture workflow. Site-only edits do not need
|
||||
to rerun those jobs.
|
||||
|
||||
## Common presentation contract
|
||||
|
||||
The base header, Apps menu and icons, footer, brand styles and navigation behavior
|
||||
should remain identical copies across the seven product sites. Put this site's
|
||||
name, navigation, CTA and extra footer resources outside the common base files.
|
||||
Preserve real links, anchor IDs, no-JavaScript navigation, keyboard operation,
|
||||
mobile layout, reduced-motion preferences and existing product-specific content.
|
||||
Screenshots stay in English. Translation content, when added, lives in separate
|
||||
HTML files; no runtime translation service or cross-repository dependency.
|
||||
|
||||
## Editing and building
|
||||
|
||||
`base/` contains identical independent copies across the seven product sites.
|
||||
Common Python scripts share the same behavior; repository formatters may change
|
||||
line wrapping without changing their syntax trees. The base owns the shared logo, Apps menu/icons, footer directory, navigation
|
||||
behavior and styles. `site.json` and `nav.html` contain this site's name, routes,
|
||||
links and CTA. Product content and additional footer resources stay in the native
|
||||
HTML/Jinja/Liquid templates listed above. The `ARCHIVEBOX:*` comments are build
|
||||
slots; the renderer fills them with static HTML, never browser-side fetches.
|
||||
|
||||
The existing builder calls `site.py render` after generating its content. This
|
||||
copies `base/chrome.css` and `base/chrome.js` to `site-base/`, assembles the common
|
||||
HTML, validates local resources and writes the site revision to root `build.json`.
|
||||
Screenshot manifests are left untouched. No build reads another repository.
|
||||
|
||||
Common changes are manual copy/paste edits. Compare `base/` and `site.py` with
|
||||
another product repository when updating them; do not add a synchronization job,
|
||||
package dependency or reusable cross-repository workflow. `nav.html`, `site.json`
|
||||
and the marked workflow build section are intentionally repository-specific.
|
||||
|
||||
Build: `uv run --no-project python .github/pages/site.py render _site --source publicsite` after restoring screenshots and refreshing the gallery.
|
||||
|
||||
Check the built site with real Chromium (also run by Pages CI):
|
||||
|
||||
```sh
|
||||
uv run --no-config --no-project --with playwright==1.63.0 playwright install chromium
|
||||
uv run --no-config --no-project --with playwright==1.63.0 python .github/pages/verify.py SITE_OUTPUT --evidence /tmp/site-evidence
|
||||
```
|
||||
|
||||
Use this workflow's `SITE_OUTPUT` directory. The check covers desktop/mobile
|
||||
layout, keyboard dismissal, no-JavaScript links, local resources and visible
|
||||
images, and uploads its screenshots as `site-verification` for 14 days.
|
||||
|
||||
## Branding and future translations
|
||||
|
||||
`base/chrome.css` owns the shared system font stacks, plum accent and CTA shape,
|
||||
including the existing `.button` and `.ab-btn` classes. Product styles own their
|
||||
layouts, feature sections and platform-specific button states. Shared system
|
||||
fonts need no external font downloads. `site.py` emits the same complete SEO/OG/
|
||||
Twitter tags everywhere while retaining each native template's title, description,
|
||||
canonical URL, locale and social artwork; edit those values with that page's copy.
|
||||
|
||||
For translations, keep complete separate HTML files under `es/`, `fr/` and `zh/`,
|
||||
and list their output paths in `site.json` alongside the English pages. Share CSS,
|
||||
JS, social artwork and the English screenshot gallery. Translate each page's title
|
||||
and description, set its own canonical and `lang` (`zh` for Chinese), and add
|
||||
reciprocal `hreflang` links only for translations that actually exist. The renderer
|
||||
preserves these tags and computes shared asset paths for nested pages. Localized
|
||||
header/footer copy and the small language selector can be added with those pages;
|
||||
there is no language redirect, placeholder translation or translation runtime now.
|
||||
|
||||
## Screenshot strips
|
||||
|
||||
`marquee.py` and `base/marquee.css` / `base/marquee.js` are also identical copies.
|
||||
Product homepages choose their placement with an `ARCHIVEBOX:MARQUEE` comment.
|
||||
The renderer takes real images, captions, dimensions and links from the generated
|
||||
`screenshots/index.html`; there is no second screenshot list or browser fetch.
|
||||
Desktop/tablet/mobile galleries contribute their desktop views. Apple uses
|
||||
`MARQUEE-CLIENT` and `MARQUEE-SERVER` slots to keep its two products separate.
|
||||
Catalog sites without a slot do not load the strip assets.
|
||||
|
||||
The strip scrolls once, pauses on hover, focus or manual interaction, and has an
|
||||
explicit Play/Pause button. Reduced-motion users start paused. Without JavaScript,
|
||||
normal image links and horizontal scrolling remain available. Screenshot capture
|
||||
manifests and provenance are never rewritten by this presentation step.
|
||||
|
||||
Standalone one- or two-image illustrations remain in their authored source.
|
||||
Gallery strips with three or more images use the latest restored captures.
|
||||
|
||||
## Keeping the common layout in sync
|
||||
|
||||
The comments in `base/header.html`, `base/footer-start.html`,
|
||||
`base/footer-end.html`, `base/chrome.css`, and `base/chrome.js` list the peer
|
||||
repositories. Update those committed copies together when shared branding,
|
||||
Apps links, keyboard behavior, or footer resources change. Preserve each site's
|
||||
local navigation and content. The former monorepo `site-chrome` generator is
|
||||
retired; builds must use only this repository's files.
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Common screenshot artifact transport; coverage checks belong to each app."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
def api(repo, path):
|
||||
return json.loads(subprocess.check_output(["gh", "api", f"repos/{repo}/{path}"]))
|
||||
|
||||
|
||||
def trusted(run, repo, workflow, branch, successful=True):
|
||||
return (
|
||||
(run.get("head_repository") or {}).get("full_name", "").lower() == repo.lower()
|
||||
and run["head_branch"] == branch
|
||||
and run["event"] in ("push", "workflow_dispatch")
|
||||
and run["path"] == f".github/workflows/{workflow}"
|
||||
and (not successful or run["conclusion"] == "success")
|
||||
)
|
||||
|
||||
|
||||
def runs(repo, workflow, branch, successful=True, artifact_names=("site-screenshots",)):
|
||||
# Ask for capture artifacts directly instead of checking every historical
|
||||
# workflow run (most app runs never produce a complete screenshot set).
|
||||
# Artifact creation time also includes fresh captures from old rerun jobs.
|
||||
candidates = []
|
||||
for name in artifact_names:
|
||||
rows = subprocess.check_output(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
f"repos/{repo}/actions/artifacts?name={name}&per_page=100",
|
||||
"--paginate",
|
||||
"--jq",
|
||||
".artifacts[] | select(.expired == false) | @json",
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
candidates.extend(json.loads(row) for row in rows.splitlines())
|
||||
seen = set()
|
||||
for artifact in sorted(candidates, key=lambda item: item["created_at"], reverse=True):
|
||||
source = artifact.get("workflow_run") or {}
|
||||
run_id = source.get("id")
|
||||
if (
|
||||
artifact["name"] not in artifact_names
|
||||
or not run_id
|
||||
or run_id in seen
|
||||
or source.get("head_branch") != branch
|
||||
or source.get("head_repository_id") != source.get("repository_id")
|
||||
):
|
||||
continue
|
||||
seen.add(run_id)
|
||||
run = api(repo, f"actions/runs/{run_id}")
|
||||
if trusted(run, repo, workflow, branch, successful):
|
||||
yield run
|
||||
|
||||
|
||||
def names(repo, run):
|
||||
rows = subprocess.check_output(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
f"repos/{repo}/actions/runs/{run['id']}/artifacts?per_page=100",
|
||||
"--paginate",
|
||||
"--jq",
|
||||
".artifacts[] | @json",
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
return {artifact["name"] for row in rows.splitlines() if not (artifact := json.loads(row))["expired"]}
|
||||
|
||||
|
||||
def download(repo, run, name, destination):
|
||||
subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"run",
|
||||
"download",
|
||||
str(run["id"]),
|
||||
"--repo",
|
||||
repo,
|
||||
"--name",
|
||||
name,
|
||||
"--dir",
|
||||
str(destination),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def relative_path(name):
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or ".." in path.parts or any(c in name for c in ":\\?#%"):
|
||||
raise ValueError(f"Unsafe screenshot path: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def fetch(base, name):
|
||||
relative_path(name)
|
||||
with urlopen(base + name, timeout=60) as response:
|
||||
return response.read()
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
Shared ArchiveBox website navigation styles and behavior.
|
||||
Keep this file in sync with .github/pages/base/chrome.css in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
*/
|
||||
/* Common brand tokens. Native templates own product layout and content. */
|
||||
:root {
|
||||
--abx-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--abx-font-mono: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--abx-accent: #9b2854;
|
||||
--abx-accent-hover: #782040;
|
||||
--abx-paper: #fcfaf7;
|
||||
--abx-ink: #28252a;
|
||||
--abx-muted: #716c74;
|
||||
--abx-line: #e9e2e3;
|
||||
--font-sans: var(--abx-font-sans);
|
||||
--font-mono: var(--abx-font-mono);
|
||||
--ab-font-sans: var(--abx-font-sans);
|
||||
--ab-font-serif: var(--abx-font-sans);
|
||||
--ab-font-label: var(--abx-font-sans);
|
||||
--ab-font-mono: var(--abx-font-mono);
|
||||
--ab-accent: var(--abx-accent);
|
||||
--ab-accent-hover: var(--abx-accent-hover);
|
||||
--ab-on-accent: #fff;
|
||||
}
|
||||
body { font-family: var(--abx-font-sans); }
|
||||
code, pre, kbd, samp { font-family: var(--abx-font-mono); }
|
||||
/* Reuse existing CTA classes; platform selection continues to control colors. */
|
||||
.button, .ab-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||
min-height: 44px; height: auto; max-width: 100%; padding: 10px 20px;
|
||||
border-radius: 24px; font-family: var(--abx-font-sans);
|
||||
font-size: 14px; font-weight: 650; line-height: 1.4;
|
||||
text-align: center; white-space: normal; text-decoration: none;
|
||||
}
|
||||
.button.primary, .ab-btn--primary { background: var(--abx-accent); border-color: var(--abx-accent); color: #fff; }
|
||||
.button.primary:hover, .ab-btn--primary:hover { background: var(--abx-accent-hover); border-color: var(--abx-accent-hover); color: #fff; }
|
||||
.button:focus-visible, .ab-btn:focus-visible { outline: 3px solid #d67c9f; outline-offset: 4px; }
|
||||
.button svg, .button img, .ab-btn svg, .ab-btn img { flex-shrink: 0; }
|
||||
@media (prefers-reduced-motion: reduce) { .button, .ab-btn { transition: none; } .button:hover { transform: none; } }
|
||||
|
||||
/* Shared ArchiveBox site chrome. Independent copy; edit alongside the other product sites, without a build dependency. */
|
||||
/* Native anchor navigation leaves room for the heading and its surrounding content. */
|
||||
html { scroll-behavior: smooth; scroll-padding-top: clamp(96px, 25vh, 240px); }
|
||||
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
||||
.abx-header, .abx-footer { box-sizing: border-box; font-family: var(--abx-font-sans); font-size: 14px; line-height: 1.5; letter-spacing: normal; text-align: left; }
|
||||
.abx-header *, .abx-footer * { box-sizing: border-box; }
|
||||
.abx-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; max-width: none; margin: 0; padding: 15px max(24px, calc((100% - 1280px) / 2)); border: 0; border-bottom: 1px solid #e9e2e3; background: #fcfaf7f5; color: #28252a; backdrop-filter: blur(16px); }
|
||||
.abx-header a, .abx-footer a { text-decoration: none; text-underline-offset: .24em; }
|
||||
.abx-header .abx-brand, .abx-footer .abx-brand { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 11px; margin: 0; padding: 0; color: inherit; font-size: 19px; font-weight: 750; line-height: 1.2; letter-spacing: -.5px; white-space: nowrap; }
|
||||
.abx-brand .abx-logo { display: block; flex: 0 0 auto; width: 32px; height: 32px; margin: 0; }
|
||||
.abx-header .abx-subsite { transform: translateY(3px); margin-left: 2px; color: #716c74; font-size: 12px; font-weight: 450; letter-spacing: 0; }
|
||||
.abx-header .abx-nav { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0; width: auto; max-width: none; margin: 0; padding: 0; font-size: 13px; font-weight: 600; }
|
||||
.abx-nav > a, .abx-nav > details { margin: 0 0 0 15px; padding: 0 0 0 15px; border: 0; border-left: 1px solid #ded4d8; border-radius: 0; background: none; }
|
||||
.abx-nav > :first-child { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.abx-header .abx-nav a { display: inline-flex; align-items: center; min-height: 36px; color: #4d424b; white-space: nowrap; }
|
||||
.abx-header .abx-nav a:hover { color: #9b2854; }
|
||||
.abx-header .abx-nav .abx-cta { margin-left: 18px; padding: 9px 17px; border: 1px solid #9b2854; border-radius: 24px; background: #9b2854; color: #fff; }
|
||||
.abx-header .abx-nav .abx-cta:hover { background: #782040; color: #fff; }
|
||||
.abx-header .abx-apps { position: relative; }
|
||||
.abx-apps > summary { display: flex; align-items: center; gap: 7px; min-height: 36px; list-style: none; cursor: pointer; color: #4d424b; }
|
||||
.abx-apps > summary::-webkit-details-marker { display: none; }
|
||||
.abx-apps > summary::before { content: '›'; display: inline-block; color: #9b2854; font-size: 22px; line-height: 1; transition: transform .15s; }
|
||||
.abx-apps[open] > summary::before { transform: rotate(90deg); }
|
||||
.abx-header .abx-app-links { position: absolute; right: 0; top: calc(100% + 12px); width: 440px; max-width: calc(100vw - 24px); max-height: min(70vh, 580px); overflow-y: auto; padding: 8px; border: 1px solid #e9e2e3; border-radius: 16px; background: #fcfaf7; box-shadow: 0 18px 48px #2d152522, 0 3px 8px #2d152508; }
|
||||
.abx-header .abx-app-row { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 4px 6px; border-radius: 10px; transition: background .15s; }
|
||||
.abx-header .abx-app-row:hover, .abx-header .abx-app-row:focus-within { background: #f2e8ed; }
|
||||
.abx-header .abx-app-links .abx-app-name { flex: 1; min-width: 0; min-height: 34px; padding: 5px 4px; border-radius: 6px; font-size: 13px; line-height: 1.35; white-space: normal; }
|
||||
.abx-header .abx-platforms { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
|
||||
.abx-header .abx-app-links .abx-platform, .abx-header .abx-app-links .abx-tool { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; height: 30px; min-height: 30px; padding: 5px; border: 1px solid #e8dfe3; border-radius: 9px; background: #fffdfb; color: #4d424b; box-shadow: 0 2px 3px #2d152510, inset 0 1px 0 #fff; transition: transform .15s, box-shadow .15s, border-color .15s; }
|
||||
.abx-header .abx-app-links .abx-platform { width: 30px; }
|
||||
.abx-header .abx-platform svg { display: block; width: 18px; height: 18px; margin: 0; fill: currentColor; }
|
||||
.abx-header .abx-app-links .abx-platform-docker { color: #2386ce; }
|
||||
.abx-header .abx-app-links .abx-platform-windows, .abx-header .abx-app-links .abx-platform-app-store, .abx-header .abx-app-links .abx-platform-safari { color: #1879c4; }
|
||||
.abx-header .abx-app-links .abx-platform-android { color: #29965d; }
|
||||
.abx-header .abx-app-links .abx-platform-firefox { color: #e46b24; }
|
||||
.abx-header .abx-app-links .abx-platform-brave { color: #e8552b; }
|
||||
.abx-header .abx-app-links .abx-platform-edge { color: #168b95; }
|
||||
.abx-header .abx-app-links .abx-platform:hover, .abx-header .abx-app-links .abx-platform:focus-visible, .abx-header .abx-app-links .abx-tool:hover, .abx-header .abx-app-links .abx-tool:focus-visible { transform: translateY(-2px); border-color: #c99bb0; background: #fff; box-shadow: 0 5px 10px #2d15251c; }
|
||||
.abx-header .abx-app-links .abx-tool { padding: 5px 8px; font-size: 11px; white-space: nowrap; }
|
||||
.abx-header .abx-tools-row { margin-top: 5px; padding-top: 9px; border-top: 1px solid #e9e2e3; border-radius: 0 0 10px 10px; }
|
||||
.abx-header .abx-tools-row .abx-app-name { flex: 0 0 auto; }
|
||||
.abx-header .abx-tools-row .abx-platforms { flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
|
||||
.abx-header .abx-app-links code { display: inline; vertical-align: baseline; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1em; font-weight: inherit; line-height: inherit; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; }
|
||||
.abx-header .abx-icon-stack { isolation: isolate; gap: 0; padding: 4px 5px; }
|
||||
.abx-header .abx-icon-stack .abx-integration { position: relative; z-index: 1; transform: translateY(var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; }
|
||||
.abx-header .abx-icon-stack .abx-integration + .abx-integration { margin-left: -10px; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(2) { --stack-angle: 9deg; --stack-y: -2px; z-index: 2; color: #569a31; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(3) { --stack-angle: -6deg; --stack-y: 2px; z-index: 3; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(4) { --stack-angle: 7deg; --stack-y: -1px; z-index: 4; color: #f00; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(5) { --stack-angle: -10deg; --stack-y: 1px; z-index: 5; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(6) { --stack-angle: 6deg; --stack-y: -2px; z-index: 6; }
|
||||
.abx-header .abx-icon-stack .abx-integration:nth-child(7) { --stack-angle: -5deg; z-index: 7; }
|
||||
.abx-header .abx-icon-stack .abx-integration:not(:hover, :focus-visible):has(~ .abx-integration:is(:hover, :focus-visible)) { transform: translate(-6px, var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); }
|
||||
.abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) ~ .abx-integration:not(:hover, :focus-visible) { transform: translate(6px, var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); }
|
||||
.abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) { z-index: 20; transform: translateY(-7px) rotate(0deg) scale(1.12); border-color: #c99bb0; box-shadow: 0 8px 14px #2d15252b; }
|
||||
@media (hover: none) { .abx-header .abx-icon-stack .abx-integration + .abx-integration { margin-left: -6px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .abx-header .abx-icon-stack .abx-integration { transition: none; } }
|
||||
.abx-footer { position: relative; z-index: 1; display: block; width: 100%; max-width: none; margin: 72px 0 0; padding: 60px max(24px, calc((100% - 1280px) / 2)) 24px; border: 0; border-top: 3px solid #7e284a; background: radial-gradient(ellipse at 12% 0, #54243b55, transparent 48%), linear-gradient(120deg, #201923, #14151b 75%); color: #ddd2dd; color-scheme: dark; }
|
||||
.abx-footer .abx-footer-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin: 0 0 42px; }
|
||||
.abx-footer .abx-brand { color: #fff4ed; font-size: 24px; }
|
||||
.abx-footer .abx-brand .abx-logo { width: 40px; height: 40px; }
|
||||
.abx-footer .abx-tagline { max-width: 430px; margin: 12px 0 0; color: #b8acbb; font-size: 14px; line-height: 1.7; }
|
||||
.abx-footer .abx-footer-motto { margin: 2px 0 0; color: #d8b39f; font: italic 25px/1.45 Georgia, serif; text-align: right; }
|
||||
.abx-footer .abx-footer-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 42px; margin: 0; }
|
||||
.abx-footer .abx-footer-column { margin: 0; padding: 20px 0 0; border: 0; border-top: 1px solid #67505e66; background: none; }
|
||||
.abx-footer .abx-footer-column h2 { margin: 0 0 19px; padding: 0; border: 0; color: #e4acbb; font: 650 12px/1.4 var(--abx-font-sans); letter-spacing: .12em; text-transform: uppercase; }
|
||||
.abx-footer .abx-footer-column ul { display: block; list-style: none; margin: 0; padding: 0; }
|
||||
.abx-footer .abx-footer-column li { display: flex; align-items: center; gap: 10px; margin: 0; padding: 0; min-height: 36px; }
|
||||
.abx-footer .abx-footer-column li::before { content: none; }
|
||||
.abx-footer .abx-footer-column a { color: #ddd2dd; font-size: 13px; line-height: 1.55; }
|
||||
.abx-footer .abx-footer-column a:first-child { padding: 6px 0; }
|
||||
.abx-footer a:hover { color: #fff; text-decoration: underline; }
|
||||
.abx-footer .abx-source { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; width: 28px; height: 28px; border: 1px solid transparent; border-radius: 50%; color: #a58d9f; }
|
||||
.abx-footer .abx-source:hover { color: #f3b5cb; border-color: #80516a; background: #ffffff08; }
|
||||
.abx-source svg { width: 15px; height: 15px; fill: currentColor; }
|
||||
.abx-footer .abx-footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; margin-top: 32px; padding-top: 21px; border-top: 1px solid #67505e66; color: #a99bab; font-size: 12px; }
|
||||
.abx-footer .abx-footer-bottom p { margin: 0; font-size: inherit; color: inherit; }
|
||||
.abx-footer .abx-footer-bottom a { color: #cebbc9; }
|
||||
.abx-footer .abx-footer-local { margin: 24px 0 0; padding: 16px 0 0; border: 0; border-top: 1px solid #67505e66; border-radius: 0; background: none; color: #baacbc; font-size: 12px; }
|
||||
.abx-footer-local > summary { cursor: pointer; color: #d6bfce; }
|
||||
.abx-footer-local > div { padding-top: 18px; }
|
||||
.abx-footer-local a { color: #e5aabd; }
|
||||
.abx-footer-local .brand, .abx-footer-local .footer-intro, .abx-footer-local .footer-grid, .abx-footer-local .footer-meta { color: inherit; }
|
||||
.abx-footer-local .footer-card { background: transparent; border-color: #67505e66; }
|
||||
.abx-footer-local h2, .abx-footer-local h3, .abx-footer-local strong { color: #e4d8e3; }
|
||||
.abx-footer-local .footer-link-copy span, .abx-footer-local .footer-card-subtitle { color: #baacbc; }
|
||||
.abx-header a:focus-visible, .abx-header summary:focus-visible, .abx-footer a:focus-visible, .abx-footer summary:focus-visible { outline: 3px solid #d67c9f; outline-offset: 4px; }
|
||||
@media (max-width: 1100px) {
|
||||
.abx-header { flex-wrap: wrap; gap: 8px 24px; padding: 12px 20px; }
|
||||
.abx-header .abx-nav { justify-content: flex-start; }
|
||||
.abx-header .abx-app-links { position: fixed; top: auto; left: 20px; right: auto; width: 440px; margin-top: 10px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.abx-header { position: relative; padding: 14px 18px; }
|
||||
.abx-header .abx-brand { flex-wrap: wrap; row-gap: 5px; }
|
||||
.abx-header .abx-nav { width: 100%; gap: 4px 0; font-size: 12px; }
|
||||
.abx-nav > a, .abx-nav > details { margin-left: 10px; padding-left: 10px; }
|
||||
.abx-header .abx-nav .abx-cta { margin-left: 10px; padding: 7px 13px; }
|
||||
.abx-header .abx-app-links { left: 12px; right: 12px; width: auto; max-height: 60vh; }
|
||||
.abx-footer { padding: 40px 22px 22px; margin-top: 48px; }
|
||||
.abx-footer .abx-footer-intro { display: block; margin-bottom: 30px; }
|
||||
.abx-footer .abx-footer-motto { margin-top: 20px; text-align: left; font-size: 22px; }
|
||||
.abx-footer .abx-footer-grid { grid-template-columns: 1fr; gap: 26px; }
|
||||
.abx-footer .abx-footer-column li { min-height: 40px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .abx-apps > summary::before, .abx-header .abx-app-row, .abx-header .abx-app-links .abx-platform, .abx-header .abx-app-links .abx-tool { transition: none; } .abx-header .abx-app-links .abx-platform:hover, .abx-header .abx-app-links .abx-tool:hover { transform: none; } }
|
||||
|
||||
.abx-footer-local > div > a + a { margin-left: 12px; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) { transform: none; } }
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Shared ArchiveBox website navigation styles and behavior.
|
||||
Keep this file in sync with .github/pages/base/chrome.js in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
*/
|
||||
(() => {
|
||||
const menu = document.querySelector('.abx-apps');
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && menu?.open) { menu.open = false; menu.querySelector('summary').focus(); }
|
||||
});
|
||||
document.addEventListener('click', event => { if (menu?.open && !menu.contains(event.target)) menu.open = false; });
|
||||
function revealAnchor() {
|
||||
let target;
|
||||
try { target = document.getElementById(decodeURIComponent(window.location.hash.slice(1))); } catch { return; }
|
||||
if (!target) return;
|
||||
let details = target.closest('details');
|
||||
let opened = false;
|
||||
while (details) { if (!details.open) { details.open = true; opened = true; } details = details.parentElement.closest('details'); }
|
||||
if (opened) window.requestAnimationFrame(() => target.scrollIntoView());
|
||||
}
|
||||
window.addEventListener('hashchange', revealAnchor);
|
||||
revealAnchor();
|
||||
})();
|
||||
@@ -0,0 +1,12 @@
|
||||
<!--
|
||||
Shared ArchiveBox website footer.
|
||||
Keep this file in sync with .github/pages/base/footer-end.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<div class="abx-footer-bottom"><p>Built in the open. Supported by our community.</p><p><a href="https://github.com/ArchiveBox">GitHub organization</a> · <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Security & privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
|
||||
</footer>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!--
|
||||
Shared ArchiveBox website footer.
|
||||
Keep this file in sync with .github/pages/base/footer-start.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<footer class="abx-footer" aria-label="ArchiveBox ecosystem">
|
||||
<div class="abx-footer-intro"><div><a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span></a><p class="abx-tagline">Open-source tools for keeping a lasting copy of the web.<br>Your data. Your devices. Your archive.</p></div><p class="abx-footer-motto">A home for the web<br>you want to keep.</p></div>
|
||||
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps & tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://app.archivebox.io/#get-started">ArchiveBox for macOS & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://android.archivebox.io/">ArchiveBox for Android</a><a class="abx-source" href="https://github.com/ArchiveBox/android-archivebox" aria-label="ArchiveBox for Android source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://electron.archivebox.io/">ArchiveBox Desktop</a><a class="abx-source" href="https://github.com/ArchiveBox/electron-archivebox" aria-label="ArchiveBox Desktop source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://abx-dl.archivebox.io/">One-shot abx-Line truncated
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
Shared ArchiveBox website navbar and Apps menu.
|
||||
Keep this file in sync with .github/pages/base/header.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<header class="abx-header">
|
||||
<a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span><span class="abx-subsite">__SITE_NAME__</span></a>
|
||||
<nav class="abx-nav" aria-label="Main navigation">
|
||||
<details class="abx-apps"><summary>Apps</summary><div class="abx-app-links">
|
||||
<div class="abx-app-row"><a class="abx-app-name" href="https://archivebox.io/#install-compose">ArchiveBox Server</a><div class="abx-platforms"><a class="abx-platform abx-platform-docker" href="https://archivebox.io/#install-compose" aria-label="ArchiveBox Server for Docker" title="ArchiveBox Server for Docker"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M349.9 236.3l-66.1 0 0-59.4 66.1 0 0 59.4zm0-204.3l-66.1 0 0 60.7 66.1 0 0-60.7zm78.2 144.8l-66.1 0 0 59.4 66.1 0 0-59.4zM271.8 104.7l-66.1 0 0 60.1 66.1 0 0-60.1zm78.1 0l-66.1 0 0 60.1 66.1 0 0-60.1zm276.8 100c-14.4-9.7-47.6-13.2-73.1-8.4-3.3-24-16.7-44.9-41.1-63.7l-14-9.3-9.3 14c-18.4 27.8-23.4 73.6-3.7 103.8-8.7 4.7-25.8 11.1-48.4 10.7l-434.7 0c-8.7 50.8 5.8 116.8 44 162.1 37.1 43.9 92.7 66.2 165.4 66.2 157.4 0 273.9-72.5 328.4-204.2 21.4 .4 67.6 .1 91.3-45.2 1.5-2.5 6.6-13.2 8.5-17.1l-13.3-8.9zM115.6 176.8l-66 0 0 59.4 66.1 0 0-59.4-.1 0zm78.1 0l-66.1 0 0 59.4 66.1 0 0-59.4zm78.1 0l-66.1 0 0 59.4 66.1 0 0-59.4zm-78.1-72.1l-66.1 0 0 60.1 66.1 0 0-60.1z"/></svg></a><a class="abx-platform abx-platform-apple" href="https://app.archivebox.io/#archivebox-serverapp" aria-label="ArchiveBox Server for macOS" title="ArchiveBox Server for macOS"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M319.1 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7-55.8 .9-115.1 44.5-115.1 133.2 0 26.2 4.8 53.3 14.4 81.2 12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zM262.5 104.5c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg></a><a class="abx-platform abx-platform-windows" href="https://electron.archivebox.io/" aria-label="ArchiveBox Server for Windows" title="ArchiveBox Server for Windows"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 93.7l183.6-25.3 0 177.4-183.6 0 0-152.1zM0 418.3l183.6 25.3 0-175.2-183.6 0 0 149.9zm203.8 28l244.2 33.7 0-211.6-244.2 0 0 177.9zm0-380.6l0 180.1 244.2 0 0-213.8-244.2 33.7z"/></svg></a><a class="abx-platform abx-platform-linux" href="https://archivebox.io/#install-apt" aria-label="ArchiveBox Server for Linux" title="ArchiveBox Server for Linux"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M220.9 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5 .2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4 .2-.8 .7-.6 1.1 .3 1.3 2.3 1.1 3.4 1.7zM199 125c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6 .2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5 .1-1.3 .6-3.4 1.5-3.2 2.9 .1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72 .5-45.9 5.1-131.2-75.8-131.3-102.4-.2-76.8 103.4-77.9 135.2-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9 .6 7.9 1.2 11.8 1.2 8.1 2.5 15.7 .8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1 .6 2.3 1.4 4.6 2.5 6.7l0 .1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3 .4-8.2-4.4-17.3-15.5-29.7zM223.8 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183.1 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4 .7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-1Line truncated
|
||||
<div class="abx-app-row"><a class="abx-app-name" href="https://app.archivebox.io/">ArchiveBox Desktop</a><div class="abx-platforms"><a class="abx-platform abx-platform-apple" href="https://app.archivebox.io/" aria-label="ArchiveBox Desktop for macOS" title="ArchiveBox Desktop for macOS"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M319.1 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7-55.8 .9-115.1 44.5-115.1 133.2 0 26.2 4.8 53.3 14.4 81.2 12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zM262.5 104.5c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg></a><a class="abx-platform abx-platform-windows" href="https://windows.archivebox.io/" aria-label="ArchiveBox Desktop for Windows" title="ArchiveBox Desktop for Windows"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 93.7l183.6-25.3 0 177.4-183.6 0 0-152.1zM0 418.3l183.6 25.3 0-175.2-183.6 0 0 149.9zm203.8 28l244.2 33.7 0-211.6-244.2 0 0 177.9zm0-380.6l0 180.1 244.2 0 0-213.8-244.2 33.7z"/></svg></a><a class="abx-platform abx-platform-linux" href="https://linux.archivebox.io/" aria-label="ArchiveBox Desktop for Linux" title="ArchiveBox Desktop for Linux"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M220.9 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5 .2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4 .2-.8 .7-.6 1.1 .3 1.3 2.3 1.1 3.4 1.7zM199 125c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6 .2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5 .1-1.3 .6-3.4 1.5-3.2 2.9 .1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72 .5-45.9 5.1-131.2-75.8-131.3-102.4-.2-76.8 103.4-77.9 135.2-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9 .6 7.9 1.2 11.8 1.2 8.1 2.5 15.7 .8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1 .6 2.3 1.4 4.6 2.5 6.7l0 .1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3 .4-8.2-4.4-17.3-15.5-29.7zM223.8 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183.1 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4 .7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4l0-.2c2.4-7.6 .6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7 .8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4 .6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1 .8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7 .4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4Line truncated
|
||||
<div class="abx-app-row"><a class="abx-app-name" href="https://app.archivebox.io/">ArchiveBox Mobile</a><div class="abx-platforms"><a class="abx-platform abx-platform-apple" href="https://app.archivebox.io/" aria-label="ArchiveBox Mobile for iOS" title="ArchiveBox Mobile for iOS"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M319.1 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7-55.8 .9-115.1 44.5-115.1 133.2 0 26.2 4.8 53.3 14.4 81.2 12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zM262.5 104.5c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg></a><a class="abx-platform abx-platform-android" href="https://android.archivebox.io/" aria-label="ArchiveBox Mobile for Android" title="ArchiveBox Mobile for Android"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Free 5.0.13 by Fonticons, Inc. https://fontawesome.com License: https://creativecommons.org/licenses/by/4.0/ --><path d="M89.6 204.5v115.8c0 15.4-12.1 27.7-27.5 27.7-15.3 0-30.1-12.4-30.1-27.7V204.5c0-15.1 14.8-27.5 30.1-27.5 15.1 0 27.5 12.4 27.5 27.5zm10.8 157c0 16.4 13.2 29.6 29.6 29.6h19.9l.3 61.1c0 36.9 55.2 36.6 55.2 0v-61.1h37.2v61.1c0 36.7 55.5 36.8 55.5 0v-61.1h20.2c16.2 0 29.4-13.2 29.4-29.6V182.1H100.4v179.4zm248-189.1H99.3c0-42.8 25.6-80 63.6-99.4l-19.1-35.3c-2.8-4.9 4.3-8 6.7-3.8l19.4 35.6c34.9-15.5 75-14.7 108.3 0L297.5 34c2.5-4.3 9.5-1.1 6.7 3.8L285.1 73c37.7 19.4 63.3 56.6 63.3 99.4zm-170.7-55.5c0-5.7-4.6-10.5-10.5-10.5-5.7 0-10.2 4.8-10.2 10.5s4.6 10.5 10.2 10.5c5.9 0 10.5-4.8 10.5-10.5zm113.4 0c0-5.7-4.6-10.5-10.2-10.5-5.9 0-10.5 4.8-10.5 10.5s4.6 10.5 10.5 10.5c5.6 0 10.2-4.8 10.2-10.5zm94.8 60.1c-15.1 0-27.5 12.1-27.5 27.5v115.8c0 15.4 12.4 27.7 27.5 27.7 15.4 0 30.1-12.4 30.1-27.7V204.5c0-15.4-14.8-27.5-30.1-27.5z"/></svg></a></div></div>
|
||||
<div class="abx-app-row"><a class="abx-app-name" href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><div class="abx-platforms"><a class="abx-platform abx-platform-chrome" href="https://chromewebstore.google.com/detail/archivebox-exporter/habonpimjphpdnmcfkaockjnffodikoj" aria-label="ArchiveBox Browser Extension for Chrome" title="ArchiveBox Browser Extension for Chrome"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 256C0 209.4 12.47 165.6 34.27 127.1L144.1 318.3C166 357.5 207.9 384 256 384C270.3 384 283.1 381.7 296.8 377.4L220.5 509.6C95.9 492.3 0 385.3 0 256zM365.1 321.6C377.4 302.4 384 279.1 384 256C384 217.8 367.2 183.5 340.7 160H493.4C505.4 189.6 512 222.1 512 256C512 397.4 397.4 511.1 256 512L365.1 321.6zM477.8 128H256C193.1 128 142.3 172.1 130.5 230.7L54.19 98.47C101 38.53 174 0 256 0C350.8 0 433.5 51.48 477.8 128V128zM168 256C168 207.4 207.4 168 256 168C304.6 168 344 207.4 344 256C344 304.6 304.6 344 256 344C207.4 344 168 304.6 168 256z"/></svg></a><a class="abx-platform abx-platform-safari" href="https://extension.archivebox.io/" aria-label="ArchiveBox Browser Extension for Safari" title="ArchiveBox Browser Extension for Safari"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M274.7 274.7L237.3 237.3 166 346 274.7 274.7zM256 8a248 248 0 1 0 0 496 248 248 0 1 0 0-496zM411.8 182.8l14.8-6.1c1-.4 2-.6 3.1-.6s2.1 .2 3.1 .6 1.8 1 2.6 1.7 1.3 1.6 1.7 2.6 .6 2 .6 3.1-.2 2.1-.6 3.1-1 1.9-1.7 2.6-1.6 1.3-2.6 1.7L418 197.6c-2 .8-4.2 .8-6.1 0s-3.5-2.4-4.3-4.3-.8-4.2 0-6.1 2.4-3.5 4.3-4.3zM314.4 94l6.1-14.8c.8-2 2.4-3.5 4.3-4.3s4.2-.8 6.1 0 3.5 2.4 4.3 4.3 .8 4.2 0 6.1l-6.1 14.8c-.8 2-2.4 3.5-4.3 4.3s-4.2 .8-6.1 0c-1-.4-1.9-1-2.6-1.7s-1.3-1.6-1.7-2.6-.6-2-.6-3.1 .2-2.1 .6-3.1zM256 60c2.1 0 4.2 .8 5.7 2.3S264 65.9 264 68l0 16c0 2.1-.8 4.2-2.3 5.7s-3.5 2.3-5.7 2.3-4.2-.8-5.7-2.3-2.3-3.5-2.3-5.7l0-16c0-2.1 .8-4.2 2.3-5.7S253.9 60 256 60zM181 74.9c1-.4 2-.6 3.1-.6s2.1 .2 3.1 .6 1.9 1 2.6 1.7 1.3 1.6 1.7 2.6L197.6 94c.4 1 .7 2 .7 3.1s-.2 2.1-.6 3.1-1 1.9-1.7 2.6-1.6 1.4-2.6 1.8-2 .6-3.1 .6-2.1-.2-3.1-.6-1.9-1-2.6-1.8-1.3-1.7-1.7-2.7l-6.1-14.8c-.8-2-.8-4.2 0-6.1s2.4-3.5 4.3-4.3zm-63.6 42.5c1.5-1.5 3.5-2.3 5.7-2.3s4.2 .8 5.7 2.3L140 128.7c1.5 1.5 2.3 3.5 2.3 5.6s-.8 4.1-2.3 5.6c-1.5 1.5-3.5 2.3-5.7 2.3s-4.2-.8-5.7-2.3l-11.3-11.3c-1.5-1.5-2.3-3.5-2.3-5.6s.8-4.1 2.3-5.6l0 0zM60 256c0-2.1 .8-4.2 2.3-5.7S65.9 248 68 248l16 0c2.1 0 4.2 .8 5.7 2.3s2.3 3.5 2.3 5.7-.8 4.2-2.3 5.7-3.5 2.3-5.7 2.3l-16 0c-2.1 0-4.2-.8-5.7-2.3S60 258.1 60 256zm40.2 73.2l-14.8 6.1c-1 .4-2 .6-3.1 .6s-2.1-.2-3.1-.6-1.9-1-2.6-1.7-1.3-1.6-1.7-2.6-.6-2-.6-3.1 .2-2.1 .6-3.1 1-1.9 1.7-2.6 1.6-1.3 2.6-1.7L94 314.4c2-.8 4.2-.8 6.1 0s3.5 2.4 4.3 4.3 .8 4.2 0 6.1-2.4 3.5-4.3 4.3zm4.3-136c-.4 1-1 1.9-1.7 2.6s-1.6 1.3-2.6 1.7-2 .6-3.1 .6-2.1-.2-3.1-.6l-14.8-6.1c-2-.8-3.5-2.4-4.3-4.3s-.8-4.2 0-6.1 2.4-3.5 4.3-4.3 4.2-.8 6.1 0l14.8 6.1c2 .8 3.5 2.4 4.3 4.3s.8 4.2 0 6.1l0 0zM197.6 418l-6.1 14.8c-.4 1-1 1.9-1.7 2.6s-1.6 1.3-2.6 1.7-2 .6-3.1 .6-2.1-.2-3.1-.6-1.9-1-2.6-1.7-1.3-1.6-1.7-2.6-.6-2-.6-3.1 .2-2.1 .6-3.1l6.1-14.8c.4-1 1-1.9 1.7-2.7s1.6-1.4 2.6-1.8 2-.6 3.1-.6 2.1 .2 3.1 .6 1.9 1 2.6 1.8 1.3 1.7 1.7 2.6 .6 2 .6 3.1-.2 2.1-.7 3.1zM264 444c0 2.1-.8 4.2-2.3 5.7s-3.5 2.3-5.7 2.3-4.2-.8-5.7-2.3-2.3-3.5-2.3-5.7l0-16c0-2.1 .8-4.2 2.3-5.7s3.5-2.3 5.7-2.3 4.2 .8 5.7 2.3 2.3 3.5 2.3 5.7l0 16zm67-6.9c-1 .4-2 .6-3.1 .6s-2.1-.2-3.1-.6-1.9-1-2.6-1.7-1.3-1.6-1.7-2.6L314.4 418c-.8-2-.8-4.2 0-6.1s2.4-3.5 4.3-4.3 4.2-.8 6.1 0 3.5 2.4 4.3 4.3l6.1 14.8c.8 2 .8 4.2 0 6.1s-2.4 3.5-4.3 4.3zm63.6-42.5c-1.5 1.5-3.5 2.3-5.7 2.3s-4.2-.8-5.7-2.3L372 383.3c-1.5-1.5-2.3-3.5-2.3-5.6s.8-4.1 2.3-5.6 3.5-2.3 5.7-2.3 4.2 .8 5.7 2.3l11.3 11.3c1.5 1.5 2.3 3.5 2.3 5.6s-.8 4.1-2.3 5.6l0 0zM286.2 286.2L110.3 401.7 225.8 225.8 401.7 110.3 286.2 286.2zM437.1 331c-.8 2-2.4 3.5-4.3 4.3s-4.2 .8-6.1 0l-14.8-6.1c-2-.8-3.5-2.4-4.3-4.3s-.8-4.2 0-6.1c.4-1 1-1.9 1.7-2.6s1.6-1.3 2.6-1.7 2-.6 3.1-.6 2.1 .2 3.1 .6l14.8 6.1c2 .8 3.5 2.4 4.3 4.3s.8 4.2 0 6.1zm6.9-67l-16 0c-2.1 0-4.2-.8-5.7-2.3s-2.3-3.5-2.3-5.7 .8-4.2 2.3-5.7 3.5-2.3 5.7-2.3l16 0c2.1 0 4.2 .8 5.7 2.3s2.3 3.5 2.3 5.7-.8 4.2-2.3 5.7-3.5 2.3-5.7 2.3z"/></svg></a><a class="abx-platform abx-platform-firefox" href="https://addons.mozilla.org/firefox/addon/archivebox-exporter/" aria-label="ArchiveBox Browser Extension for Firefox" title="ArchiveBox Browser Extension for Firefox"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512Line truncated
|
||||
<div class="abx-app-row abx-plugin-row"><a class="abx-app-name" href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><div class="abx-platforms abx-icon-stack"><a class="abx-platform abx-integration" href="https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-Up-Storage#amazon-s3--backblaze-b2--google-drive--etc-rclone" aria-label="Google Drive storage" title="Google Drive storage"><svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m38.7 419.3 22.6 39c4.7 8.2 11.4 14.7 19.4 19.4l80.6-139.6H0c0 9.1 2.3 18.2 7 26.4z" style="fill:#0066da"/><path d="M256 173.9 175.4 34.3c-7.9 4.7-14.7 11.1-19.4 19.4L7 311.7c-4.6 8-7 17.1-7 26.4h161.3z" style="fill:#00ac47"/><path d="M431.4 477.7c7.9-4.7 14.7-11.1 19.4-19.4l9.4-16.1 44.9-77.7c4.7-8.2 7-17.3 7-26.4H350.7l34.3 67.4z" style="fill:#ea4335"/><path d="m256 173.9 80.6-139.6c-7.9-4.7-17-7-26.4-7H201.8c-9.4 0-18.5 2.6-26.4 7z" style="fill:#00832d"/><path d="M350.7 338.1H161.3L80.6 477.7c7.9 4.7 17 7 26.4 7h298c9.4 0 18.5-2.6 26.4-7z" style="fill:#2684fc"/><path d="M430.5 182.7 356 53.7c-4.7-8.2-11.4-14.7-19.4-19.4L256 173.9l94.7 164.2h161c0-9.1-2.3-18.2-7-26.4z" style="fill:#ffba00"/></svg></a><a class="abx-platform abx-integration" href="https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-Up-Storage#amazon-s3--backblaze-b2--google-drive--etc-rclone" aria-label="Amazon S3 storage" title="Amazon S3 storage"><svg aria-hidden="true" focusable="false" fill="#569A31" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.913 13.147l.12-.895c.947.576 1.258.922 1.354 1.071-.16.031-.562.046-1.474-.176zm-2.174 7.988a.547.547 0 0 0-.005.073c0 .084-.207.405-1.124.768a10.28 10.28 0 0 1-1.438.432c-1.405.325-3.128.504-4.853.504-4.612 0-7.412-1.184-7.412-1.704a.547.547 0 0 0-.005-.073L1.81 5.602c.135.078.28.154.432.227.042.02.086.038.128.057.134.062.272.122.417.18l.179.069c.154.058.314.114.478.168.043.013.084.029.13.043.207.065.423.127.646.187l.176.044c.175.044.353.087.534.127a23.414 23.414 0 0 0 .843.17l.121.023c.252.045.508.085.768.122.071.011.144.02.216.03.2.027.4.053.604.077l.24.027c.245.026.49.05.74.07l.081.009c.275.022.552.04.83.056l.233.012c.21.01.422.018.633.025a33.088 33.088 0 0 0 2.795-.026l.232-.011c.278-.016.555-.034.83-.056l.08-.008c.25-.02.497-.045.742-.072l.238-.026c.205-.024.408-.05.609-.077.07-.01.141-.019.211-.03.261-.037.519-.078.772-.122l.111-.02c.215-.04.427-.082.634-.125l.212-.047c.186-.041.368-.085.546-.13l.166-.042c.225-.06.444-.122.654-.189.04-.012.077-.026.115-.038a10.6 10.6 0 0 0 .493-.173c.058-.021.114-.044.17-.066.15-.06.293-.12.43-.185.038-.017.079-.034.116-.052.153-.073.3-.15.436-.228l-.976 7.245c-2.488-.78-5.805-2.292-7.311-3a1.09 1.09 0 0 0-1.088-1.085c-.6 0-1.088.489-1.088 1.088 0 .6.488 1.089 1.088 1.089.196 0 .378-.056.537-.148 1.72.812 5.144 2.367 7.715 3.15zm-7.42-20.047c5.677 0 9.676 1.759 9.75 2.736l-.014.113c-.01.033-.031.067-.048.101-.015.028-.026.057-.047.087-.024.033-.058.068-.09.102-.028.03-.051.06-.084.09-.038.035-.087.07-.133.105-.04.03-.074.06-.119.091-.053.036-.116.071-.177.107-.05.03-.095.06-.15.09-.068.036-.147.073-.222.11-.059.028-.114.057-.177.085-.084.038-.177.074-.268.111-.068.027-.13.054-.203.082-.097.036-.205.072-.31.107-.075.026-.148.053-.228.079-.111.035-.233.069-.35.103-.085.024-.165.05-.253.073-.124.034-.258.065-.389.098-.093.022-.181.046-.278.068-.139.032-.287.061-.433.091-.098.02-.191.041-.293.06-.155.03-.32.057-.482.084-.1.018-.198.036-.302.052-.166.026-.342.048-.515.072-.11.014-.213.03-.325.044-.181.023-.372.041-.56.06-.11.012-.218.025-.332.036-.188.016-.386.029-.58.043-.122.009-.24.02-.364.028-.207.012-.422.02-.635.028-.12.005-.234.012-.354.016a35.605 35.605 0 0 1-2.069 0c-.12-.004-.234-.011-.352-.016-.214-.008-.43-.016-.637-.028-.122-.008-.238-.02-.36-.027-.195-.015-.394-.028-.584-.044-.11-.01-.215-.024-.324-.035-.19-.02-.384-.038-.568-.06l-.315-.044c-.176-.024-.355-.046-.525-.073-.1-.015-.192-.033-.29-.05-.167-.028-.335-.055-.494-.086-.096-.018-.183-.038-.276-.056-.151-.032-.305-.062-.45-.095-.09-.02-.173-.043-.26-.064-.138-.034-.277-.067-.407-.102-.082-.022-.157-.046-.235-.069a11.75 11.75 0 0 1-.368-.108c-.075-.024-.141-.049-.213-.073-.11-.037-.223-.075-.325-.113-.067-.025-.125-.051-.188-.077-.096-.038-.195-.076-.282-.115-.06-.027-.11-.054-.166-.08-.08-.039-.162-.077-.233-.116-.052-.028-.094-.055-.142-.084-.063-.038-.13-.075-.185-.113-.043-.029-.075-.058-.113-.086-.048-.037-.098-.073-.139-.11-.032-.029-.054-.057-.08-.087-.033-.035-.069-.07-.093-.104-.02-.03-.031-.058-.046-.086-.018-.035-.039-.068-.049-.102l-.015-.113c.076-.977 4.074-2.736 9.748-2.736zm12.182 12.124c-.118-.628-.84-1.291-2.31-2.128l.963-7.16a.531.531 0 0 0 .005-.073C22.16 1.581 16.447 0 11.32 0 6.194 0 .482 1.581.482 3.851a.58.58 0 0 0 .005.072L2.819 21.25c.071 2.002 5.236 2.75 8.5 2.75 1.805 0 3.615-.188 5.098-.531.598-.138 1.133-.3 1.592-.48 1.18-.467 1.789-1.053 1.813-1.739l.945-7.018c.557.131 1.016.197 1.389.197.54 0 .902-.137 1.134-.413aLine truncated
|
||||
<div class="abx-app-row abx-tools-row"><a class="abx-app-name" href="https://abx-dl.archivebox.io/">Tools</a><div class="abx-platforms"><a class="abx-tool" href="https://abx-dl.archivebox.io/"><span>One-shot <code>abx-dl</code> CLI</span></a><a class="abx-tool" href="https://github.com/ArchiveBox/ArchiveBox/tree/dev/archivebox/mcp">MCP</a><a class="abx-tool" href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#python-api-usage">API</a><a class="abx-tool" href="https://web.archivebox.io">Demo</a></div></div>
|
||||
</div></details>
|
||||
__SITE_NAV__
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,11 @@
|
||||
/* Identical standalone gallery strip across ArchiveBox product sites. */
|
||||
/* The root query container measures the usable width, excluding scrollbars. */
|
||||
html { container-type: inline-size; }
|
||||
.abx-marquee { min-width: 0; width: 100cqw; max-width: none; margin: 24px calc(50% - 50cqw) 40px; padding: 0; overflow: hidden; }
|
||||
.abx-marquee .abx-marquee-viewport { width: 100%; overflow-x: auto; scroll-behavior: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding: 16px 24px 28px; box-sizing: border-box; }
|
||||
.abx-marquee .abx-marquee-track { display: flex; align-items: flex-start; gap: 24px; width: max-content; margin-inline: auto; }
|
||||
.abx-marquee .abx-marquee-card { display: block; flex: none; width: min-content; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 0 0 1px rgb(0 0 0 / 12%), 0 2px 4px rgb(0 0 0 / 10%), 0 8px 16px rgb(0 0 0 / 16%); text-decoration: none; color: #533344; }
|
||||
.abx-marquee .abx-marquee-card img { display: block; height: clamp(240px, 30vw, 400px); width: auto; max-width: none; max-height: none; object-fit: contain; box-shadow: none; border: 0; border-radius: 0; background: transparent; padding: 0; margin: 0; }
|
||||
.abx-marquee :focus-visible { outline: 2px solid #9b2854; outline-offset: 3px; }
|
||||
.abx-marquee .abx-marquee-viewport:focus-visible { outline-offset: -3px; }
|
||||
@media (max-width: 600px) { .abx-marquee .abx-marquee-track { gap: 16px; } .abx-marquee .abx-marquee-card img { height: 240px; } }
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Scroll real gallery links once; keep them usable without JavaScript. */
|
||||
(() => {
|
||||
document.querySelectorAll('.abx-marquee').forEach(gallery => {
|
||||
const viewport = gallery.querySelector('.abx-marquee-viewport');
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let paused = reducedMotion.matches;
|
||||
let hovering = false;
|
||||
let focused = false;
|
||||
let visible = false;
|
||||
let frame;
|
||||
let previous;
|
||||
let offset = viewport.scrollLeft;
|
||||
function tick(time) {
|
||||
const distance = viewport.scrollWidth - viewport.clientWidth;
|
||||
if (distance <= 0) return;
|
||||
if (previous !== undefined) {
|
||||
offset = Math.min(offset + Math.min(time - previous, 50) * 0.035, distance);
|
||||
viewport.scrollTo({ left: offset, behavior: 'instant' });
|
||||
}
|
||||
if (offset >= distance - 1) { paused = true; return; }
|
||||
previous = time;
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
}
|
||||
function sync() {
|
||||
window.cancelAnimationFrame(frame);
|
||||
previous = undefined;
|
||||
offset = viewport.scrollLeft;
|
||||
if (!paused && !hovering && !focused && visible && !document.hidden) frame = window.requestAnimationFrame(tick);
|
||||
}
|
||||
viewport.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') { hovering = true; sync(); } });
|
||||
viewport.addEventListener('pointerleave', () => { hovering = false; sync(); });
|
||||
viewport.addEventListener('focusin', () => { focused = true; sync(); });
|
||||
viewport.addEventListener('focusout', event => { focused = viewport.contains(event.relatedTarget); sync(); });
|
||||
function pauseForInteraction() { paused = true; sync(); }
|
||||
viewport.addEventListener('pointerdown', pauseForInteraction);
|
||||
viewport.addEventListener('wheel', pauseForInteraction, { passive: true });
|
||||
viewport.addEventListener('keydown', pauseForInteraction);
|
||||
reducedMotion.addEventListener('change', () => { paused = reducedMotion.matches; sync(); });
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
new window.IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(gallery);
|
||||
new window.ResizeObserver(sync).observe(viewport);
|
||||
viewport.querySelectorAll('img').forEach(image => image.addEventListener('load', sync));
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Render homepage screenshot strips from the site's existing gallery HTML."""
|
||||
|
||||
import re
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
class Gallery(HTMLParser):
|
||||
"""Only gallery figures are captures; navigation and decorative images are not."""
|
||||
|
||||
def __init__(self, base):
|
||||
super().__init__()
|
||||
self.base = base
|
||||
self.stack = []
|
||||
self.captures = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == "img" and any(name == "figure" for name, _ in self.stack):
|
||||
context = [item for _, item in self.stack]
|
||||
profile = next(
|
||||
(
|
||||
item.get("data-profile") or item.get("data-viewport")
|
||||
for item in reversed(context)
|
||||
if item.get("data-profile") or item.get("data-viewport")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if profile not in (None, "desktop"):
|
||||
return
|
||||
source = attrs.get("src", "")
|
||||
if not source or not attrs.get("alt"):
|
||||
return
|
||||
anchor = next((item["id"] for name, item in reversed(self.stack) if name in ("article", "figure") and item.get("id")), "")
|
||||
server = any(item.get("data-platform") == "server" for item in context)
|
||||
self.captures.append(
|
||||
{
|
||||
"src": urljoin(self.base, source),
|
||||
"href": self.base + "#" + anchor if anchor else urljoin(self.base, source),
|
||||
"title": attrs["alt"],
|
||||
"width": attrs.get("width", ""),
|
||||
"height": attrs.get("height", ""),
|
||||
"product": "server" if server else "client",
|
||||
},
|
||||
)
|
||||
if tag not in {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"}:
|
||||
self.stack.append((tag, attrs))
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
for index in range(len(self.stack) - 1, -1, -1):
|
||||
if self.stack[index][0] == tag:
|
||||
del self.stack[index:]
|
||||
break
|
||||
|
||||
|
||||
def render(output, baseurl="", featured=()):
|
||||
homepage = output / "index.html"
|
||||
text = homepage.read_text()
|
||||
slots = re.findall(r"<!-- ARCHIVEBOX:(MARQUEE(?:-CLIENT|-SERVER)?) -->", text)
|
||||
if not slots:
|
||||
return False
|
||||
base = "/" + baseurl.strip("/") + "/" if baseurl.strip("/") else "/"
|
||||
gallery = Gallery(base + "screenshots/")
|
||||
gallery.feed((output / "screenshots/index.html").read_text())
|
||||
for slot in slots:
|
||||
product = slot.removeprefix("MARQUEE-").lower() if "-" in slot else None
|
||||
captures = [capture for capture in gallery.captures if product is None or capture["product"] == product]
|
||||
priority = {title: index for index, title in enumerate(featured)}
|
||||
captures.sort(key=lambda capture: priority.get(capture["title"], len(priority)))
|
||||
cards = []
|
||||
for capture in captures:
|
||||
dimensions = "".join(f' {key}="{escape(capture[key])}"' for key in ("width", "height") if capture[key])
|
||||
cards.append(
|
||||
f'<a class="abx-marquee-card" href="{escape(capture["href"])}"><img src="{escape(capture["src"])}" alt="{escape(capture["title"])}"{dimensions} loading="lazy" decoding="async"></a>',
|
||||
)
|
||||
viewport_id = slot.lower()
|
||||
content = (
|
||||
f'<section class="abx-marquee" aria-label="App screenshots"><div class="abx-marquee-viewport" id="{viewport_id}" tabindex="0" aria-label="Screenshots. Scroll sideways to explore."><div class="abx-marquee-track">{"".join(cards)}</div></div></section>'
|
||||
if cards
|
||||
else ""
|
||||
)
|
||||
start, end = f"<!-- ARCHIVEBOX:{slot} -->", f"<!-- /ARCHIVEBOX:{slot} -->"
|
||||
replacement = start + "\n" + content + "\n" + end
|
||||
if end in text:
|
||||
text = re.sub(
|
||||
re.escape(start) + r".*?" + re.escape(end),
|
||||
lambda _, replacement=replacement: replacement,
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
else:
|
||||
text = text.replace(start, replacement, 1)
|
||||
homepage.write_text(text)
|
||||
return True
|
||||
@@ -0,0 +1,4 @@
|
||||
<!-- Repository-specific navigation and primary CTA. -->
|
||||
<a href="__SITE_BASE__#use-cases">Use cases</a><a href="__SITE_BASE__#configuration">Configuration</a><a href="__SITE_BASE__#layout-security">Layout & security</a><a href="https://github.com/ArchiveBox/ArchiveBox/wiki">Docs</a>
|
||||
<a href="https://github.com/ArchiveBox/ArchiveBox">GitHub ↗</a>
|
||||
<a class="abx-cta" href="__SITE_BASE__#install">Get started</a>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Web Archive",
|
||||
"url": "https://archivebox.io/",
|
||||
"pages": [
|
||||
"index.html",
|
||||
"screenshots/index.html"
|
||||
],
|
||||
"marquee_featured": [
|
||||
"Snapshots grid — desktop",
|
||||
"Snapshot View (header collapsed) — desktop",
|
||||
"AI agent — desktop",
|
||||
"Archive results — desktop",
|
||||
"Add URLs — desktop",
|
||||
"Snapshots table — desktop"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Assemble and verify this repository's static Pages site (standard library only)."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from html import escape, unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from marquee import render as render_marquee
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
|
||||
def replace_block(text, name, content):
|
||||
start, end = f"<!-- ARCHIVEBOX:{name} -->", f"<!-- /ARCHIVEBOX:{name} -->"
|
||||
pattern = re.escape(start) + r".*?" + re.escape(end)
|
||||
if re.search(pattern, text, re.DOTALL):
|
||||
return re.sub(
|
||||
pattern,
|
||||
lambda _: f"{start}\n{content}\n{end}",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
if text.count(start) != 1:
|
||||
raise ValueError(f"Expected one {name} placeholder")
|
||||
return text.replace(start, f"{start}\n{content}\n{end}", 1)
|
||||
|
||||
|
||||
def render_metadata(text):
|
||||
"""Keep product copy in native templates; render one consistent SEO block."""
|
||||
page = Page()
|
||||
page.feed(text)
|
||||
title = unescape(re.search(r"<title>(.*?)</title>", text, re.DOTALL)[1])
|
||||
meta = page.metadata
|
||||
description = meta.get("description", meta.get("og:description", ""))
|
||||
image = meta.get("og:image", meta.get("twitter:image", ""))
|
||||
if not description or not image or not page.canonical:
|
||||
raise ValueError("Each page needs a description, social image and canonical")
|
||||
values = {
|
||||
"description": description,
|
||||
"robots": "index,follow,max-image-preview:large",
|
||||
"theme-color": "#9b2854",
|
||||
"og:type": "website",
|
||||
"og:site_name": meta.get("og:site_name", "ArchiveBox"),
|
||||
"og:locale": meta.get("og:locale", "en_US"),
|
||||
"og:url": page.canonical,
|
||||
"og:title": title,
|
||||
"og:description": description,
|
||||
"og:image": image,
|
||||
"og:image:type": meta.get("og:image:type", "image/png"),
|
||||
"og:image:width": meta.get("og:image:width", "1200"),
|
||||
"og:image:height": meta.get("og:image:height", "630"),
|
||||
"og:image:alt": meta.get("og:image:alt", title),
|
||||
"twitter:card": "summary_large_image",
|
||||
"twitter:site": "@ArchiveBoxApp",
|
||||
"twitter:title": title,
|
||||
"twitter:description": description,
|
||||
"twitter:image": image,
|
||||
"twitter:image:alt": meta.get("og:image:alt", title),
|
||||
}
|
||||
|
||||
def strip_managed_tag(match):
|
||||
tag = Page()
|
||||
tag.feed(match[0])
|
||||
if tag.canonicals or any(key in values for key in tag.metadata):
|
||||
return ""
|
||||
# System fonts are shared across sites, without a network font dependency.
|
||||
if "fonts.googleapis.com" in match[0] or "fonts.gstatic.com" in match[0]:
|
||||
return ""
|
||||
return match[0]
|
||||
|
||||
text = re.sub(r"<(?:meta|link)\b[^>]*>", strip_managed_tag, text)
|
||||
text = re.sub(r"<title>.*?</title>", "", text, flags=re.DOTALL)
|
||||
tags = [
|
||||
f"<title>{escape(title)}</title>",
|
||||
f'<link rel="canonical" href="{escape(page.canonical, quote=True)}">',
|
||||
]
|
||||
for key, value in values.items():
|
||||
attr = "property" if key.startswith("og:") else "name"
|
||||
tags.append(f'<meta {attr}="{key}" content="{escape(value, quote=True)}">')
|
||||
if "<!-- ARCHIVEBOX:METADATA -->" not in text:
|
||||
text = text.replace("</head>", "<!-- ARCHIVEBOX:METADATA -->\n</head>", 1)
|
||||
return replace_block(text, "METADATA", "\n".join(tags))
|
||||
|
||||
|
||||
def render(output, source=None, baseurl=""):
|
||||
config = json.loads((HERE / "site.json").read_text())
|
||||
output = output.resolve()
|
||||
if source:
|
||||
source = source.resolve()
|
||||
if output == source or output in source.parents or source in output.parents:
|
||||
raise ValueError("Build output must be separate from the source directory")
|
||||
if output == ROOT or output in ROOT.parents:
|
||||
raise ValueError("Build output must not replace a repository")
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
shutil.copytree(source, output)
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
|
||||
assets = output / "site-base"
|
||||
assets.mkdir(parents=True, exist_ok=True)
|
||||
has_marquee = render_marquee(output, baseurl, config.get("marquee_featured", []))
|
||||
common_assets = ["chrome.css", "chrome.js"] + (["marquee.css", "marquee.js"] if has_marquee else [])
|
||||
for name in common_assets:
|
||||
shutil.copyfile(HERE / "base" / name, assets / name)
|
||||
for name in config["pages"]:
|
||||
page = output / name
|
||||
text = render_metadata(page.read_text())
|
||||
base = "../" * (len(Path(name).parts) - 1) or "./"
|
||||
header = (HERE / "base/header.html").read_text().replace("__SITE_NAME__", escape(config["name"]))
|
||||
header = header.replace("__SITE_NAV__", (HERE / "nav.html").read_text().strip())
|
||||
text = replace_block(text, "HEADER", header)
|
||||
for kind in ["FOOTER-START", "FOOTER-END"]:
|
||||
text = replace_block(text, kind, (HERE / "base" / f"{kind.lower()}.html").read_text().strip())
|
||||
text = text.replace("__SITE_BASE__", base).replace("__SITE_REVISION__", revision)
|
||||
resources = f'<link rel="stylesheet" href="{base}site-base/chrome.css?v={revision}">\n<script src="{base}site-base/chrome.js?v={revision}" defer></script>'
|
||||
if has_marquee and name == "index.html":
|
||||
resources += f'\n<link rel="stylesheet" href="{base}site-base/marquee.css?v={revision}">\n<script src="{base}site-base/marquee.js?v={revision}" defer></script>'
|
||||
if "<!-- ARCHIVEBOX:ASSETS -->" not in text:
|
||||
text = text.replace("</head>", "<!-- ARCHIVEBOX:ASSETS -->\n</head>", 1)
|
||||
text = replace_block(text, "ASSETS", resources)
|
||||
page.write_text(text)
|
||||
# Site build provenance is independent of all original screenshot manifests.
|
||||
provenance_path = output / "build.json"
|
||||
provenance = json.loads(provenance_path.read_text()) if provenance_path.exists() else {}
|
||||
provenance.update(
|
||||
revision=revision,
|
||||
generatedAt=datetime.now(UTC).isoformat(),
|
||||
site=config["url"],
|
||||
)
|
||||
provenance_path.write_text(json.dumps(provenance, indent=2) + "\n")
|
||||
(output / ".nojekyll").touch()
|
||||
verify(output, baseurl)
|
||||
print(f"Assembled {len(config['pages'])} pages at {output} from {revision}")
|
||||
|
||||
|
||||
class Page(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.ids = set()
|
||||
self.metadata = {}
|
||||
self.canonical = ""
|
||||
self.resources = []
|
||||
self.headers = self.footers = self.canonicals = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
if tag == "meta":
|
||||
for key in ["name", "property"]:
|
||||
if attrs.get(key):
|
||||
self.metadata[attrs[key]] = attrs.get("content", "")
|
||||
if tag == "link" and attrs.get("rel") == "canonical":
|
||||
self.canonical = attrs["href"]
|
||||
if attrs.get("id"):
|
||||
self.ids.add(attrs["id"])
|
||||
classes = attrs.get("class", "").split()
|
||||
self.headers += "abx-header" in classes
|
||||
self.footers += "abx-footer" in classes
|
||||
self.canonicals += tag == "link" and attrs.get("rel") == "canonical"
|
||||
if tag in ["img", "script"] and attrs.get("src"):
|
||||
self.resources.append(attrs["src"])
|
||||
if tag == "link" and attrs.get("rel") in [
|
||||
"stylesheet",
|
||||
"icon",
|
||||
"apple-touch-icon",
|
||||
]:
|
||||
self.resources.append(attrs["href"])
|
||||
|
||||
|
||||
def verify(output, baseurl=""):
|
||||
config = json.loads((HERE / "site.json").read_text())
|
||||
output = output.resolve()
|
||||
prefix = "/" + baseurl.strip("/") if baseurl.strip("/") else ""
|
||||
for name in config["pages"]:
|
||||
path = output / name
|
||||
text = path.read_text()
|
||||
page = Page()
|
||||
page.feed(text)
|
||||
if (page.headers, page.footers, page.canonicals) != (1, 1, 1):
|
||||
raise ValueError(f"{name}: expected exactly one shared header, footer and canonical")
|
||||
if "__SITE_" in text:
|
||||
raise ValueError(f"{name}: unresolved site placeholder")
|
||||
for resource in page.resources:
|
||||
url = urlsplit(resource)
|
||||
if url.scheme or url.netloc or not url.path:
|
||||
continue
|
||||
resource_path = unquote(url.path)
|
||||
if prefix and resource_path.startswith(prefix + "/"):
|
||||
resource_path = resource_path[len(prefix) :]
|
||||
local = (output / resource_path.lstrip("/") if resource_path.startswith("/") else path.parent / resource_path).resolve()
|
||||
if not local.is_relative_to(output) or not local.is_file():
|
||||
raise ValueError(f"{name}: missing local resource {resource}")
|
||||
for asset in ["chrome.css", "chrome.js"]:
|
||||
if (output / "site-base" / asset).read_bytes() != (HERE / "base" / asset).read_bytes():
|
||||
raise ValueError(f"{name}: stale common asset {asset}")
|
||||
print(f"Verified shared layout and local resources for {len(config['pages'])} pages")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=["render", "verify"])
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--source", type=Path)
|
||||
parser.add_argument("--baseurl", default="")
|
||||
args = parser.parse_args()
|
||||
if args.command == "render":
|
||||
render(args.output, args.source, args.baseurl)
|
||||
else:
|
||||
verify(args.output, args.baseurl)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Check the built site in real Chromium at desktop and phone widths."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def handle(self):
|
||||
# Browser navigation can cancel an in-flight lazy image response.
|
||||
try:
|
||||
super().handle()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class ScreenshotReferences(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.references = []
|
||||
self.ids = set()
|
||||
|
||||
def handle_starttag(self, tag, attributes):
|
||||
attrs = dict(attributes)
|
||||
if attrs.get("id"):
|
||||
self.ids.add(attrs["id"])
|
||||
for attribute in ("src", "href"):
|
||||
if attrs.get(attribute):
|
||||
self.references.append(attrs[attribute])
|
||||
|
||||
|
||||
def verify_screenshot_references(output, canonical):
|
||||
"""Check gallery embeds on every static page, including optional translations."""
|
||||
pages = {}
|
||||
for path in output.rglob("*.html"):
|
||||
page = ScreenshotReferences()
|
||||
page.feed(path.read_text())
|
||||
pages[path.resolve()] = page
|
||||
for path, page in pages.items():
|
||||
page_url = urljoin(canonical, path.relative_to(output.resolve()).as_posix())
|
||||
for reference in page.references:
|
||||
url = urlsplit(urljoin(page_url, reference))
|
||||
if url.netloc != urlsplit(canonical).netloc or "/screenshots/" not in url.path:
|
||||
continue
|
||||
target = output / unquote(url.path).lstrip("/")
|
||||
if target.is_dir():
|
||||
target /= "index.html"
|
||||
target = target.resolve()
|
||||
assert target.is_relative_to(output.resolve()) and target.is_file(), f"{path}: missing screenshot target {reference}"
|
||||
assert not re.match(r"^\d+-", target.name), f"{path}: screenshot filename depends on capture order: {reference}"
|
||||
if url.fragment and target in pages:
|
||||
assert unquote(url.fragment) in pages[target].ids, f"{path}: missing screenshot anchor {reference}"
|
||||
|
||||
|
||||
def verify(output, evidence):
|
||||
config = json.loads((HERE / "site.json").read_text())
|
||||
verify_screenshot_references(output, config["url"])
|
||||
evidence.mkdir(parents=True, exist_ok=True)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(output.resolve())))
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
origin = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(ignore_default_args=["--hide-scrollbars"])
|
||||
for name in config["pages"]:
|
||||
route = name.removesuffix("index.html")
|
||||
page = browser.new_page(reduced_motion="reduce")
|
||||
errors = []
|
||||
missing = []
|
||||
page.on("pageerror", lambda error, errors=errors: errors.append(str(error)))
|
||||
page.on(
|
||||
"response",
|
||||
lambda response, missing=missing: (
|
||||
missing.append(response.url) if response.url.startswith(origin) and response.status >= 400 else None
|
||||
),
|
||||
)
|
||||
for width in [1716, 2560, 390]:
|
||||
page.set_viewport_size({"width": width, "height": 900})
|
||||
page.goto(f"{origin}/{route}", wait_until="domcontentloaded")
|
||||
header = page.locator(".abx-header")
|
||||
expect(header).to_have_count(1)
|
||||
expect(header).to_be_visible()
|
||||
expect(header).to_be_in_viewport()
|
||||
brand = header.locator(".abx-brand")
|
||||
expect(brand).to_be_visible()
|
||||
expect(brand).to_contain_text("ArchiveBox")
|
||||
expect(brand.locator(".abx-logo")).to_be_visible()
|
||||
expect(brand.locator(".abx-logo")).to_be_in_viewport()
|
||||
expect(header).to_have_css("display", "flex")
|
||||
expect(page.locator(".abx-footer")).to_have_count(1)
|
||||
expect(page.locator(".abx-footer-column")).to_have_count(3)
|
||||
expect(header.locator(".abx-cta")).to_be_visible()
|
||||
menu = header.locator(".abx-apps")
|
||||
menu.locator("summary").focus()
|
||||
page.keyboard.press("Enter")
|
||||
expect(menu).to_have_attribute("open", "")
|
||||
expect(menu.locator("a").first).to_be_visible()
|
||||
page.keyboard.press("Escape")
|
||||
expect(menu).not_to_have_attribute("open", "")
|
||||
menu.locator("summary").click()
|
||||
box = header.bounding_box()
|
||||
assert box
|
||||
page.mouse.click(box["x"] + 2, box["y"] + 5)
|
||||
expect(menu).not_to_have_attribute("open", "")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth"), (
|
||||
f"{name}: overflow at {width}"
|
||||
)
|
||||
if not route and "screenshots/index.html" in config["pages"]:
|
||||
strips = page.locator(".abx-marquee")
|
||||
assert strips.count() > 0, "Homepage must use its screenshot gallery"
|
||||
for strip in strips.all():
|
||||
strip.scroll_into_view_if_needed()
|
||||
bounds = strip.bounding_box()
|
||||
usable_width = page.evaluate("document.documentElement.clientWidth")
|
||||
assert bounds and abs(bounds["x"]) < 1
|
||||
assert abs(bounds["width"] - usable_width) < 1
|
||||
assert not strip.inner_text().strip(), "Screenshot strips should have no visible captions or links"
|
||||
cards = strip.locator(".abx-marquee-card")
|
||||
assert cards.count() > 0, "Screenshot strip is empty"
|
||||
expect(strip.locator("button")).to_have_count(0)
|
||||
expect(strip.locator("a:not(.abx-marquee-card)")).to_have_count(0)
|
||||
viewport = strip.locator(".abx-marquee-viewport")
|
||||
overflowing = viewport.evaluate(
|
||||
"node => node.scrollWidth > node.clientWidth",
|
||||
)
|
||||
if overflowing:
|
||||
page.mouse.move(0, 0)
|
||||
page.emulate_media(reduced_motion="no-preference")
|
||||
initial = viewport.evaluate("node => node.scrollLeft")
|
||||
page.wait_for_function(
|
||||
"([node, initial]) => node.scrollLeft > initial",
|
||||
arg=[viewport.element_handle(), initial],
|
||||
)
|
||||
viewport.hover()
|
||||
stopped = viewport.evaluate("node => node.scrollLeft")
|
||||
viewport.evaluate(
|
||||
"node => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
||||
)
|
||||
assert viewport.evaluate("node => node.scrollLeft") == stopped
|
||||
page.emulate_media(reduced_motion="reduce")
|
||||
page.mouse.move(0, 0)
|
||||
stopped = viewport.evaluate("node => node.scrollLeft")
|
||||
viewport.evaluate(
|
||||
"node => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
||||
)
|
||||
assert viewport.evaluate("node => node.scrollLeft") == stopped
|
||||
viewport.focus()
|
||||
page.keyboard.press("ArrowRight")
|
||||
assert viewport.evaluate("node => node.scrollWidth > node.clientWidth")
|
||||
else:
|
||||
centered = viewport.evaluate(
|
||||
"""node => {
|
||||
const view = node.getBoundingClientRect();
|
||||
const track = node.querySelector('.abx-marquee-track').getBoundingClientRect();
|
||||
return Math.abs((track.left - view.left) - (view.right - track.right)) < 2;
|
||||
}""",
|
||||
)
|
||||
assert centered, "Short screenshot strips must be centered"
|
||||
for card in cards.all():
|
||||
href = card.get_attribute("href")
|
||||
assert href and "/screenshots/" in href
|
||||
response = page.request.get(urljoin(page.url, href))
|
||||
assert response.ok, f"Broken screenshot link: {href}"
|
||||
anchor = urlsplit(href).fragment
|
||||
if anchor:
|
||||
assert f'id="{anchor}"' in response.text(), f"Missing screenshot anchor: {href}"
|
||||
# Decode every local content image using the real browser loader,
|
||||
# including images in offscreen carousel tracks and lazy galleries.
|
||||
images = page.locator("main img").evaluate_all("nodes => [...new Set(nodes.map(image => image.src))]")
|
||||
local_images = [url for url in images if url.startswith(origin + "/")]
|
||||
page.evaluate(
|
||||
"""async urls => {
|
||||
for (const url of urls) {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
if (!image.naturalWidth) throw new Error(`Broken image: ${url}`);
|
||||
}
|
||||
}""",
|
||||
local_images,
|
||||
)
|
||||
page.evaluate("scrollTo({top: 0, behavior: 'instant'})")
|
||||
page.evaluate(
|
||||
"() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))",
|
||||
)
|
||||
page.screenshot(path=str(evidence / f"{route.replace('/', '-') or 'home-'}{width}.png"))
|
||||
page.locator(".abx-footer").screenshot(path=str(evidence / f"{route.replace('/', '-') or 'home-'}footer-{width}.png"))
|
||||
for link in header.locator(".abx-nav > a").all():
|
||||
url = urlsplit(link.get_attribute("href") or "")
|
||||
if url.scheme or url.netloc:
|
||||
continue
|
||||
response = page.request.get(urljoin(page.url, url.geturl()))
|
||||
assert response.ok, f"Broken navigation: {url.geturl()}"
|
||||
if url.fragment:
|
||||
body = response.text()
|
||||
assert f'id="{url.fragment}"' in body or f"id='{url.fragment}'" in body, (
|
||||
f"Missing navigation anchor: {url.geturl()}"
|
||||
)
|
||||
if route == "" and page.locator("#resources").count():
|
||||
page.goto(f"{origin}/#resources")
|
||||
expect(page.locator("#resources")).to_be_visible()
|
||||
assert not errors, errors
|
||||
assert not missing, missing
|
||||
page.close()
|
||||
context = browser.new_context(java_script_enabled=False, viewport={"width": 390, "height": 900})
|
||||
plain = context.new_page()
|
||||
plain.goto(f"{origin}/{route}", wait_until="domcontentloaded")
|
||||
expect(plain.locator(".abx-header")).to_be_visible()
|
||||
expect(plain.locator(".abx-header .abx-brand")).to_be_in_viewport()
|
||||
expect(plain.locator(".abx-header .abx-logo")).to_be_visible()
|
||||
plain.locator(".abx-apps summary").click()
|
||||
expect(plain.locator(".abx-app-links a").first).to_be_visible()
|
||||
expect(plain.locator(".abx-footer-column a").first).to_be_visible()
|
||||
if not route and "screenshots/index.html" in config["pages"]:
|
||||
assert plain.locator(".abx-marquee-card").count() > 0
|
||||
for strip in plain.locator(".abx-marquee").all():
|
||||
bounds = strip.bounding_box()
|
||||
usable_width = plain.evaluate("document.documentElement.clientWidth")
|
||||
assert bounds and abs(bounds["x"]) < 1
|
||||
assert abs(bounds["width"] - usable_width) < 1
|
||||
assert plain.evaluate("document.documentElement.scrollWidth <= document.documentElement.clientWidth")
|
||||
expect(plain.locator(".abx-marquee button")).to_have_count(0)
|
||||
expect(plain.locator(".abx-marquee-card").first).to_be_visible()
|
||||
context.close()
|
||||
print(
|
||||
f"PASS {name}: desktop/mobile, keyboard, no-JS, images and resources",
|
||||
flush=True,
|
||||
)
|
||||
browser.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--evidence", type=Path, default=Path("test-results/site"))
|
||||
args = parser.parse_args()
|
||||
verify(args.output, args.evidence)
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
name: Tested Docker images
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
push_digests: false
|
||||
push_digests: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
|
||||
@@ -1,79 +1,87 @@
|
||||
name: Deploy Publicsite to GitHub Pages
|
||||
name: Publish website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- publicsite/**
|
||||
- archivebox/**
|
||||
- bin/collect_ui_screenshots.*
|
||||
- bin/generate_ui_screenshot_gallery.py
|
||||
- bin/take_screenshot.js
|
||||
- docs/Screenshots.md
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- .github/workflows/deploy-publicsite.yml
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ['Capture publicsite screenshots']
|
||||
types: [completed]
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# Repository-specific output; all publication logic below is a local copy.
|
||||
SITE_OUTPUT: _site
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
|
||||
# One production queue covers restore, build and deploy. PRs have separate queues.
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('github-pages-publicsite-manual-{0}', github.run_id) || 'github-pages-publicsite' }}
|
||||
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
group: pages-${{ github.event_name == 'pull_request' && github.ref || 'production' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
revision: ${{ steps.source.outputs.revision }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.ref || env.DEFAULT_BRANCH }}
|
||||
persist-credentials: false
|
||||
- name: Record website source revision
|
||||
id: source
|
||||
run: echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# BEGIN REPOSITORY-SPECIFIC BUILD
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Restore latest complete screenshots
|
||||
run: uv run --no-project python bin/restore_site_screenshots.py publicsite/screenshots
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
- name: Render current gallery layout without recapturing images
|
||||
run: uv run --no-project python bin/generate_ui_screenshot_gallery.py refresh publicsite/screenshots/index.html
|
||||
- name: Assemble website with the local base components
|
||||
run: uv run --no-project python .github/pages/site.py render _site --source publicsite
|
||||
# END REPOSITORY-SPECIFIC BUILD
|
||||
|
||||
- name: Verify website in Chromium
|
||||
run: |
|
||||
uv run --no-config --no-project --with playwright==1.63.0 playwright install --with-deps chromium
|
||||
uv run --no-config --no-project --with playwright==1.63.0 python .github/pages/verify.py "$SITE_OUTPUT" --evidence "$RUNNER_TEMP/site-evidence"
|
||||
- name: Save website verification evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: site-verification
|
||||
path: ${{ runner.temp }}/site-evidence
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload website
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ${{ env.SITE_OUTPUT }}
|
||||
|
||||
deploy:
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Python and uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Install ArchiveBox and screenshot dependencies
|
||||
run: uv sync --no-cache --frozen
|
||||
|
||||
- name: Build UI screenshot gallery
|
||||
run: bin/collect_ui_screenshots.sh
|
||||
env:
|
||||
BASE_URL: http://archivebox.localhost:8000
|
||||
|
||||
- name: Verify gallery was generated from this exact revision
|
||||
run: |
|
||||
uv run --no-cache --no-sync python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
|
||||
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
|
||||
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
|
||||
assert provenance["version"] == version, provenance
|
||||
assert provenance["capture_count"] >= 3 * 35, provenance
|
||||
assert len(provenance["files"]) == provenance["capture_count"], provenance
|
||||
PY
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: ./publicsite
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
- uses: actions/configure-pages@v5
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
@@ -13,6 +13,22 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: Exact 40-character commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
push_digests:
|
||||
description: Push validated per-platform images by digest for the release workflow
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
full_tests:
|
||||
description: Run storage, CLI, and size validation in addition to release smoke checks
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
@@ -42,6 +58,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.sha }}
|
||||
|
||||
- name: Verify exact source commit
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_REF" ]]
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -183,7 +211,7 @@ jobs:
|
||||
{
|
||||
echo 'labels<<EOF'
|
||||
echo "org.opencontainers.image.version=${VERSION}"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.revision=${{ inputs.source_ref || github.sha }}"
|
||||
echo 'org.opencontainers.image.source=https://github.com/ArchiveBox/ArchiveBox'
|
||||
echo "io.archivebox.abx-dl.image=${{ steps.abx_dl_image.outputs.image }}"
|
||||
echo 'EOF'
|
||||
@@ -203,7 +231,7 @@ jobs:
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
build-args: |
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
# Keep the released image as the one cache authority. A second GHA
|
||||
@@ -232,7 +260,7 @@ jobs:
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
build-args: |
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
# Test the same layer lineage users receive. Reusing :dev avoids a
|
||||
@@ -246,13 +274,34 @@ jobs:
|
||||
if: inputs.push_digests
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
|
||||
- name: Verify OpenCode install with a read-only browser profile
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
PROFILE_DIR="$(mktemp -d)"
|
||||
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' \
|
||||
--volume "$PROFILE_DIR:/home/archivebox/.config/chromium:ro" \
|
||||
"$IMAGE" bash -c '
|
||||
set -Eeuo pipefail
|
||||
test -w "$XDG_CONFIG_HOME"
|
||||
test ! -w "$XDG_CONFIG_HOME/chromium"
|
||||
# Check the shipped executable before the installer can repair
|
||||
# an image that accidentally omitted this opt-in dependency.
|
||||
"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version
|
||||
archivebox install opencode
|
||||
"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version
|
||||
'
|
||||
|
||||
- name: Validate exact built image version and commit
|
||||
if: ${{ !inputs.push_digests }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
IMAGE="${{ matrix.local_tag }}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}"
|
||||
SHORT_SHA="${{ (inputs.source_ref || github.sha) }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
|
||||
DATA_DIR="$(mktemp -d)"
|
||||
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
|
||||
@@ -296,8 +345,8 @@ jobs:
|
||||
MANIFEST="$("$DOCKER_BINARY" buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}@${IMAGE_DIGEST}")"
|
||||
fi
|
||||
COMPRESSED_SIZE="$(MANIFEST="$MANIFEST" "$UV_BINARY" run --no-cache --no-project python -c 'import json, os; manifest = json.loads(os.environ["MANIFEST"]); print(manifest["config"]["size"] + sum(layer["size"] for layer in manifest["layers"]))')"
|
||||
LIMIT=$((750 * 1024 * 1024))
|
||||
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=750 MiB"
|
||||
LIMIT=$((1025 * 1024 * 1024))
|
||||
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=1025 MiB"
|
||||
(( COMPRESSED_SIZE < LIMIT ))
|
||||
|
||||
- name: Resolve real Docker CLI test dependencies through abxpkg
|
||||
@@ -357,7 +406,7 @@ jobs:
|
||||
"$SUDO_BINARY" "$MKDIR_BINARY" -p /etc/exports.d
|
||||
"$SUDO_BINARY" "$CHMOD_BINARY" 0777 "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT"
|
||||
"$SUDO_BINARY" "$BASH_BINARY" -c \
|
||||
"printf '%s *(rw,sync,no_subtree_check,no_root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports"
|
||||
"printf '%s *(rw,sync,no_subtree_check,root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports"
|
||||
"$SUDO_BINARY" "$BASH_BINARY" -c \
|
||||
"printf '\\n[archivebox-ci]\\npath = %s\\nbrowseable = yes\\nread only = no\\nguest ok = yes\\nguest only = yes\\nforce user = nobody\\ncreate mask = 0777\\ndirectory mask = 0777\\n' '$SMB_SOURCE' >> /etc/samba/smb.conf"
|
||||
|
||||
@@ -375,14 +424,20 @@ jobs:
|
||||
|
||||
NFS_TEST_DIR="$NFS_MOUNT" \
|
||||
SMB_TEST_DIR="$SMB_MOUNT" \
|
||||
IMAGE="${{ matrix.local_tag }}" \
|
||||
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}" \
|
||||
"$BASH_BINARY" bin/validate_docker_uid_gid.sh --local-only
|
||||
|
||||
- name: Validate Docker port compatibility
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
|
||||
run: '"$BASH_BINARY" bin/validate_docker_ports.sh'
|
||||
|
||||
- name: Validate Docker CLI add/list and compose behavior
|
||||
if: inputs.full_tests && matrix.platform == 'linux/amd64'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHIVEBOX_IMAGE: ${{ matrix.local_tag }}
|
||||
ARCHIVEBOX_IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
"$MKDIR_BINARY" "${{ github.workspace }}/data"
|
||||
@@ -401,13 +456,13 @@ jobs:
|
||||
"$DOCKER_BINARY" compose run --rm archivebox init
|
||||
"$DOCKER_BINARY" compose up -d --wait
|
||||
"$DOCKER_BINARY" network connect archivebox_default www-nginx
|
||||
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
|
||||
admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js')"
|
||||
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/public/')"
|
||||
admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/static/admin/js/jquery.init.js')"
|
||||
[[ "$index_html" == *ArchiveBox* ]]
|
||||
[[ "$admin_js" == *window.django* ]]
|
||||
|
||||
"$DOCKER_BINARY" compose run --rm archivebox add --plugins=parse_txt_urls 'http://www-nginx/#test_docker'
|
||||
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
|
||||
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/public/')"
|
||||
[[ "$index_html" == *'http://www-nginx/#test_docker'* ]]
|
||||
"$DOCKER_BINARY" compose down
|
||||
|
||||
@@ -423,7 +478,7 @@ jobs:
|
||||
'${{ runner.temp }}/archivebox-image.tar' \
|
||||
'${{ steps.docker_build_pr.outputs.digest }}' \
|
||||
"$LOCAL_IMAGE_ID" \
|
||||
"$((750 * 1024 * 1024))" <<'PY'
|
||||
"$((1025 * 1024 * 1024))" <<'PY'
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
@@ -503,6 +558,7 @@ jobs:
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.docker_build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
printf '%s\n' '${{ inputs.source_ref || github.sha }}' > /tmp/digests/source-ref
|
||||
|
||||
- name: Upload digest
|
||||
if: inputs.push_digests
|
||||
|
||||
@@ -99,9 +99,10 @@ jobs:
|
||||
if [[ '${{ matrix.environment }}' != macos ]]; then
|
||||
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries")
|
||||
fi
|
||||
if [[ '${{ matrix.environment }}' == ubuntu ]]; then
|
||||
if [[ '${{ matrix.name }}' == ubuntu-AGENTS || '${{ matrix.name }}' == ubuntu-skills-archivebox-SKILL || '${{ matrix.name }}' == ubuntu-docs-Configuration || '${{ matrix.name }}' == ubuntu-docs-Setting-up-Authentication ]]; then
|
||||
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries")
|
||||
elif [[ '${{ matrix.environment }}' == docker ]]; then
|
||||
fi
|
||||
if [[ '${{ matrix.environment }}' == docker ]]; then
|
||||
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries")
|
||||
elif [[ '${{ matrix.environment }}' == macos ]]; then
|
||||
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-brew.json:required_binaries")
|
||||
@@ -145,7 +146,9 @@ jobs:
|
||||
fi
|
||||
- name: Build the exact checkout's documentation image
|
||||
if: ${{ matrix.environment == 'docker' }}
|
||||
run: '"$DOCKER_BINARY" build --tag archivebox/archivebox:dev .'
|
||||
run: |
|
||||
ABX_DL_VERSION="$(uv run --no-cache --no-project python -c 'import tomllib; print(next(dep.removeprefix("abx-dl==") for dep in tomllib.load(open("pyproject.toml", "rb"))["project"]["dependencies"] if dep.startswith("abx-dl==")))')"
|
||||
"$DOCKER_BINARY" build --build-arg "ABX_DL_IMAGE=archivebox/abx-dl:${ABX_DL_VERSION}" --tag archivebox/archivebox:dev .
|
||||
- name: Run the Ubuntu, Docker, or macOS documentation lane
|
||||
if: ${{ !contains(fromJSON('["root", "freebsd", "openbsd"]'), matrix.environment) }}
|
||||
run: uv run --no-cache --no-sync python docs/test_codeblocks_manifest.py run-group '${{ matrix.snippet_ids }}'
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Duplicate Issue Detection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
||||
with:
|
||||
version: "0.11.3"
|
||||
enable-cache: false
|
||||
|
||||
- name: Resolve OpenCode and workflow tools through abxpkg
|
||||
env:
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
uv sync --no-cache --locked --no-sources --no-install-project
|
||||
OPENCODE_CONFIG="$(uv run --no-cache --no-sync --no-sources python -c 'from abx_plugins import get_plugins_dir; print(get_plugins_dir() / "opencode" / "config.json")')"
|
||||
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
|
||||
export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH"
|
||||
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
|
||||
echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH"
|
||||
opencode_env="$(
|
||||
uv run --no-cache --no-sync --no-sources abxpkg env \
|
||||
--install \
|
||||
--json \
|
||||
--lib="$ABXPKG_LIB_DIR" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:github_binaries" \
|
||||
--deps-from="$OPENCODE_CONFIG:required_binaries"
|
||||
)"
|
||||
jq_binary="$ABXPKG_LIB_DIR/env/bin/jq"
|
||||
"$jq_binary" -r 'to_entries[] | "\(.key)=\(.value)"' \
|
||||
<<< "$opencode_env" >> "$GITHUB_ENV"
|
||||
|
||||
opencode_binary="$ABXPKG_LIB_DIR/env/bin/opencode"
|
||||
echo "OPENCODE_BINARY=$opencode_binary" >> "$GITHUB_ENV"
|
||||
test -L "$opencode_binary"
|
||||
test -x "$opencode_binary"
|
||||
|
||||
- name: Check for duplicate issues
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"gh issue*": "allow",
|
||||
"gh pr*": "allow",
|
||||
"*": "deny"
|
||||
},
|
||||
"webfetch": "allow"
|
||||
}
|
||||
run: |
|
||||
"$OPENCODE_BINARY" run -m anthropic/claude-haiku-4-5 "A new issue has been created:
|
||||
|
||||
Issue number:
|
||||
${{ github.event.issue.number }}
|
||||
|
||||
Lookup this issue and search through existing issues and PRs (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find any potential duplicates, please comment on the new issue with:
|
||||
- A brief explanation of why it might be a duplicate
|
||||
- Links to the potentially duplicate issues or PRs
|
||||
- A suggestion to check those issues first
|
||||
|
||||
Use this format for the comment:
|
||||
'This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
Feel free to ignore if none of these address your specific case.'
|
||||
|
||||
If no clear duplicates are found, do not comment."
|
||||
@@ -2,7 +2,7 @@ name: Release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [dev, main]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-candidate-dev
|
||||
group: release-candidate-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -25,17 +25,31 @@ jobs:
|
||||
submodules: true
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN }}
|
||||
|
||||
- name: Keep synchronized stable branches on the released version
|
||||
id: channel
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
|
||||
MAIN_SHA="$(git ls-remote origin refs/heads/main | cut -f1)"
|
||||
if [[ "$GITHUB_REF_NAME" == dev && "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$MAIN_SHA" == "$GITHUB_SHA" ]]; then
|
||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'stable=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- id: version
|
||||
if: steps.channel.outputs.stable != 'true'
|
||||
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
|
||||
with:
|
||||
package: archivebox
|
||||
branch: dev
|
||||
branch: ${{ github.ref_name }}
|
||||
tag-prefix: v
|
||||
version-scheme: rc
|
||||
version-scheme: ${{ github.ref_name == 'main' && 'patch' || 'rc' }}
|
||||
|
||||
python-artifacts:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
|
||||
uses: ./.github/workflows/pip.yml
|
||||
with:
|
||||
full_tests: false
|
||||
@@ -43,9 +57,23 @@ jobs:
|
||||
|
||||
docker-digests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
push_digests: true
|
||||
full_tests: false
|
||||
secrets: inherit
|
||||
|
||||
opencode:
|
||||
needs: prepare
|
||||
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test_file:
|
||||
- archivebox/tests/test_opencode_agent.py
|
||||
- archivebox/tests/test_opencode_browser.py
|
||||
uses: ./.github/workflows/test-parallel.yml
|
||||
with:
|
||||
test_file: ${{ matrix.test_file }}
|
||||
secrets: inherit
|
||||
+186
-19
@@ -5,30 +5,53 @@ on:
|
||||
workflows: [CI, Release candidate]
|
||||
types: [completed]
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_branch:
|
||||
description: Branch being promoted
|
||||
required: true
|
||||
type: choice
|
||||
options: [main, dev]
|
||||
release_sha:
|
||||
description: Exact tested commit to release
|
||||
required: true
|
||||
type: string
|
||||
ci_run_id:
|
||||
description: Successful CI run containing the tested Python artifacts
|
||||
required: true
|
||||
type: string
|
||||
docker_digest_run_id:
|
||||
description: Manual Docker digest build run containing the tested image digests
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.repository }}-${{ github.event.workflow_run.head_branch }}
|
||||
group: release-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
GHCR_IMAGE: ghcr.io/archivebox/archivebox
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
CI_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RELEASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
|
||||
RELEASE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.release_sha || github.event.workflow_run.head_sha }}
|
||||
|
||||
jobs:
|
||||
candidate:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
((github.event.workflow_run.head_branch == 'dev' && github.event.workflow_run.name == 'Release candidate') ||
|
||||
(github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.name == 'CI'))
|
||||
(github.event.workflow_run.head_branch == 'dev' || github.event.workflow_run.head_branch == 'main') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.release_branch == 'main')
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
ready: ${{ steps.owner.outputs.ready }}
|
||||
ready: ${{ steps.verified.outputs.ready }}
|
||||
artifact_run_id: ${{ steps.verified.outputs.artifact_run_id }}
|
||||
digest_run_id: ${{ steps.verified.outputs.digest_run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -37,19 +60,90 @@ jobs:
|
||||
- id: owner
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
set -Eeuo pipefail
|
||||
BRANCH_SHA="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1)"
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && "$BRANCH_SHA" != "$RELEASE_SHA" ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to publish non-stable version $VERSION as a main release" >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --no-tags origin main
|
||||
git merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
|
||||
echo "Refusing to publish $RELEASE_SHA: it is not on main" >&2
|
||||
exit 1
|
||||
}
|
||||
TAG_TARGET="$(git ls-remote origin "refs/tags/v$VERSION" | cut -f1)"
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && -n "$TAG_TARGET" && "$TAG_TARGET" != "$RELEASE_SHA" ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# A stable release fast-forwards dev to main. Only main publishes it;
|
||||
# the resulting dev workflow is not a new release candidate.
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
TARGET="$(git ls-remote origin "refs/tags/release-candidate/$VERSION" | awk '{print $1}')"
|
||||
[[ "$TARGET" == "$RELEASE_SHA" ]] && echo 'ready=true' >> "$GITHUB_OUTPUT" || echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require successful full CI for this commit
|
||||
id: verified
|
||||
if: steps.owner.outputs.ready == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MANUAL_CI_RUN_ID: ${{ inputs.ci_run_id }}
|
||||
MANUAL_DIGEST_RUN_ID: ${{ inputs.docker_digest_run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||
CI_RUN_ID="$MANUAL_CI_RUN_ID"
|
||||
DIGEST_RUN_ID="$MANUAL_DIGEST_RUN_ID"
|
||||
CI_STATUS="$(gh run view "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status,conclusion,headSha,event,name \
|
||||
--jq '[.status, .conclusion, .headSha, .event, .name] | @tsv')"
|
||||
[[ "$CI_STATUS" == "$(printf 'completed\tsuccess\t%s\tpush\tCI' "$RELEASE_SHA")" ]]
|
||||
else
|
||||
CANDIDATE_RUN_ID="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow release-candidate.yml \
|
||||
--commit "$RELEASE_SHA" --branch "$RELEASE_BRANCH" --event push --limit 1 \
|
||||
--json databaseId,status,conclusion \
|
||||
--jq '.[0] | select(.status == "completed" and .conclusion == "success") | .databaseId // empty')"
|
||||
CI_RUN_ID="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml \
|
||||
--commit "$RELEASE_SHA" --branch "$RELEASE_BRANCH" --event push --limit 1 \
|
||||
--json databaseId,status,conclusion \
|
||||
--jq '.[0] | select(.status == "completed" and .conclusion == "success") | .databaseId // empty')"
|
||||
[[ -n "$CANDIDATE_RUN_ID" && -n "$CI_RUN_ID" ]] || exit 0
|
||||
if [[ "$RELEASE_BRANCH" == dev ]]; then
|
||||
DIGEST_RUN_ID="$CANDIDATE_RUN_ID"
|
||||
else
|
||||
DIGEST_RUN_ID="$CI_RUN_ID"
|
||||
fi
|
||||
fi
|
||||
if [[ "$RELEASE_BRANCH" == dev && "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
|
||||
ARTIFACT_RUN_ID="$CANDIDATE_RUN_ID"
|
||||
else
|
||||
ARTIFACT_RUN_ID="$CI_RUN_ID"
|
||||
fi
|
||||
{
|
||||
echo 'ready=true'
|
||||
echo "artifact_run_id=$ARTIFACT_RUN_ID"
|
||||
echo "digest_run_id=$DIGEST_RUN_ID"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
python-release:
|
||||
name: Publish tested Python artifacts
|
||||
needs: candidate
|
||||
if: needs.candidate.outputs.ready == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
CI_RUN_ID: ${{ needs.candidate.outputs.artifact_run_id }}
|
||||
environment: pypi
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -125,6 +219,10 @@ jobs:
|
||||
VERSION='${{ steps.version.outputs.version }}'
|
||||
case "$RELEASE_BRANCH" in
|
||||
main)
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to publish non-stable version $VERSION from main" >&2
|
||||
exit 1
|
||||
}
|
||||
;;
|
||||
dev)
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]] || {
|
||||
@@ -162,20 +260,37 @@ jobs:
|
||||
docker-release:
|
||||
name: Publish tested multiarch image
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
needs: python-release
|
||||
(github.event_name == 'workflow_dispatch') ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push')
|
||||
needs: [candidate, python-release]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout the tested commit
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.RELEASE_SHA }}
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Fast-forward dev to the tested stable release
|
||||
if: env.RELEASE_BRANCH == 'main' && needs.python-release.outputs.release_ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git fetch --no-tags origin main dev
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
DEV_SHA="$(git rev-parse origin/dev)"
|
||||
if [[ "$MAIN_SHA" == "$RELEASE_SHA" && "$DEV_SHA" != "$RELEASE_SHA" ]] &&
|
||||
git merge-base --is-ancestor "$DEV_SHA" "$RELEASE_SHA"; then
|
||||
git push origin "$RELEASE_SHA:refs/heads/dev"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -198,13 +313,14 @@ jobs:
|
||||
--lib="$ABXPKG_LIB_DIR" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" >/dev/null
|
||||
for name in uv git jq docker; do
|
||||
for name in uv gh git jq docker; do
|
||||
test -L "$ABXPKG_LIB_DIR/env/bin/$name"
|
||||
test -x "$ABXPKG_LIB_DIR/env/bin/$name"
|
||||
done
|
||||
{
|
||||
echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv"
|
||||
echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git"
|
||||
echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh"
|
||||
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
|
||||
echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker"
|
||||
} >> "$GITHUB_ENV"
|
||||
@@ -232,16 +348,31 @@ jobs:
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ env.CI_RUN_ID }}
|
||||
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
|
||||
|
||||
- name: Verify exact successful digest build run
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
RUN_STATUS="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status --jq '.status')"
|
||||
RUN_CONCLUSION="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')"
|
||||
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]]
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
|
||||
RUN_SHA="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json headSha --jq '.headSha')"
|
||||
[[ "$RUN_SHA" == "$RELEASE_SHA" ]]
|
||||
fi
|
||||
|
||||
- name: Verify digest set and collect tags
|
||||
id: docker_meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
[[ "${#DIGESTS[@]}" -eq 2 ]]
|
||||
for digest in "${DIGESTS[@]}"; do [[ "$digest" =~ ^[0-9a-f]{64}$ ]]; done
|
||||
[[ "$(< /tmp/digests/source-ref)" == "$RELEASE_SHA" ]]
|
||||
|
||||
VERSION='${{ needs.python-release.outputs.version }}'
|
||||
SHORT_SHA="${RELEASE_SHA::12}"
|
||||
@@ -251,12 +382,45 @@ jobs:
|
||||
TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}")"
|
||||
TAG_TARGET="${TAG_TARGET%%[[:space:]]*}"
|
||||
fi
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to tag :latest with prerelease $VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] || {
|
||||
echo "Refusing to tag :latest: v$VERSION does not point to $RELEASE_SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
$GIT_BINARY fetch --no-tags origin main
|
||||
$GIT_BINARY merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
|
||||
echo "Refusing to tag :latest: $RELEASE_SHA is not on main" >&2
|
||||
exit 1
|
||||
}
|
||||
MAIN_VERSION="$($GIT_BINARY show origin/main:pyproject.toml | sed -nE 's/^version = "([^"]+)".*/\1/p' | head -n 1)"
|
||||
[[ "$MAIN_VERSION" == "$VERSION" ]] || {
|
||||
echo "Refusing to tag :latest: main is now $MAIN_VERSION, not $VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
SYNC_DEV=false
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
$GIT_BINARY fetch --no-tags origin main dev
|
||||
MAIN_TARGET="$($GIT_BINARY rev-parse origin/main)"
|
||||
DEV_TARGET="$($GIT_BINARY rev-parse origin/dev)"
|
||||
# Recovery can add CI/docs fixes after the immutable release tag.
|
||||
# Both branches and every shipped application file must agree.
|
||||
if [[ "$MAIN_TARGET" == "$DEV_TARGET" ]] &&
|
||||
$GIT_BINARY diff --quiet "$RELEASE_SHA" "$MAIN_TARGET" -- . ':!.github' ':!README.md'; then
|
||||
SYNC_DEV=true
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo 'dockerhub_tags<<EOF'
|
||||
if [[ "$RELEASE_BRANCH" == dev ]]; then
|
||||
echo "${DOCKERHUB_IMAGE}:dev"
|
||||
elif [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo "${DOCKERHUB_IMAGE}:latest"
|
||||
[[ "$SYNC_DEV" == true ]] && echo "${DOCKERHUB_IMAGE}:dev"
|
||||
fi
|
||||
echo "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
@@ -266,6 +430,7 @@ jobs:
|
||||
echo "${GHCR_IMAGE}:dev"
|
||||
elif [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo "${GHCR_IMAGE}:latest"
|
||||
[[ "$SYNC_DEV" == true ]] && echo "${GHCR_IMAGE}:dev"
|
||||
fi
|
||||
echo "${GHCR_IMAGE}:sha-${SHORT_SHA}"
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${GHCR_IMAGE}:${VERSION}"
|
||||
@@ -276,7 +441,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
TAG_ARGS=()
|
||||
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}'
|
||||
REFS=()
|
||||
@@ -287,7 +452,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
TAG_ARGS=()
|
||||
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}'
|
||||
REFS=()
|
||||
@@ -317,6 +482,7 @@ jobs:
|
||||
name: Cascade verified release
|
||||
if: needs.python-release.outputs.release_ready == 'true'
|
||||
needs:
|
||||
- candidate
|
||||
- python-release
|
||||
- docker-release
|
||||
permissions:
|
||||
@@ -327,5 +493,6 @@ jobs:
|
||||
upstream_package: archivebox
|
||||
upstream_version: ${{ needs.python-release.outputs.version }}
|
||||
upstream_artifact_name: python-distributions
|
||||
upstream_run_id: ${{ needs.candidate.outputs.artifact_run_id }}
|
||||
secrets:
|
||||
release_token: ${{ secrets.RELEASE_GH_TOKEN }}
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Capture publicsite screenshots
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- archivebox/**
|
||||
- bin/collect_ui_screenshots.*
|
||||
- bin/generate_ui_screenshot_gallery.py
|
||||
- bin/take_screenshot.js
|
||||
- docs/Screenshots.md
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- .github/workflows/screenshots.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('publicsite-captures-manual-{0}', github.run_id) || 'publicsite-captures' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Python and uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Install ArchiveBox and screenshot dependencies
|
||||
run: uv sync --no-cache --frozen
|
||||
|
||||
- name: Build UI screenshot gallery
|
||||
run: bin/collect_ui_screenshots.sh
|
||||
env:
|
||||
BASE_URL: http://archivebox.localhost:5797
|
||||
|
||||
- name: Verify gallery was generated from this exact revision
|
||||
run: |
|
||||
uv run --no-cache --no-sync python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
|
||||
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
|
||||
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
|
||||
assert provenance["version"] == version, provenance
|
||||
assert provenance["capture_count"] >= 3 * 35, provenance
|
||||
assert len(provenance["files"]) == provenance["capture_count"], provenance
|
||||
assert all(not re.match(r"^\d+-", name) for name in provenance["files"]), provenance
|
||||
PY
|
||||
|
||||
- name: Save validated screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: site-screenshots
|
||||
path: publicsite/screenshots
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
@@ -1,7 +1,20 @@
|
||||
name: Parallel Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_file:
|
||||
description: Run one discovered test file
|
||||
required: true
|
||||
default: archivebox/tests/test_cli_list.py
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
test_file:
|
||||
description: Run one discovered test file; empty runs the complete matrix
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
@@ -13,11 +26,25 @@ env:
|
||||
jobs:
|
||||
discover-tests:
|
||||
name: Discover every test
|
||||
# Selection must run on GitHub so an offline NAS cannot block the fallback.
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tests: ${{ steps.discover.outputs.tests }}
|
||||
runner: ${{ steps.runner.outputs.use-runner || '["ubuntu-24.04"]' }}
|
||||
|
||||
steps:
|
||||
- name: Select available CI capacity
|
||||
id: runner
|
||||
if: vars.UGNAS_CI_ENABLED == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
uses: mikehardy/runner-fallback-action@dc7732f9e532c451b2527b288ba8d58fd4b0a4ca # v1.1.0
|
||||
with:
|
||||
github-token: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
organization: ArchiveBox
|
||||
primary-runner: self-hosted,ugnas-linux
|
||||
fallback-runner: ubuntu-24.04
|
||||
primaries-required: 1
|
||||
fallback-on-error: true
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
submodules: true
|
||||
@@ -58,10 +85,15 @@ jobs:
|
||||
|
||||
- name: Discover every test exactly once
|
||||
id: discover
|
||||
env:
|
||||
TEST_FILE: ${{ inputs.test_file }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py)
|
||||
test "${#output[@]}" -eq 2
|
||||
if [[ -n "$TEST_FILE" ]]; then
|
||||
output[1]="$("$JQ_BINARY" -ce '[.[] | select(.paths == [env.TEST_FILE])] | select(length == 1)' <<< "${output[1]}")"
|
||||
fi
|
||||
echo "tests=${output[1]}" >> "$GITHUB_OUTPUT"
|
||||
echo "${output[0]}"
|
||||
"$JQ_BINARY" -e 'length > 0 and length <= 256 and ([.[].paths[]] | length == (unique | length))' <<< "${output[1]}"
|
||||
@@ -69,13 +101,13 @@ jobs:
|
||||
tests:
|
||||
name: ${{ matrix.test.name }}
|
||||
needs: discover-tests
|
||||
runs-on: ubuntu-24.04
|
||||
# Opt in trusted branches only; PRs always use GitHub-hosted runners.
|
||||
# UGNAS_CI_TESTS is a JSON array of matrix names, e.g. ["main/test_cli_list"].
|
||||
# The hosted discovery job falls back when workers are offline/busy or the
|
||||
# availability API fails. UGNAS_CI_ENABLED=false disables the pool entirely.
|
||||
runs-on: ${{ fromJSON(contains(fromJSON(vars.UGNAS_CI_TESTS || '[]'), matrix.test.name) && needs.discover-tests.outputs.runner || '["ubuntu-24.04"]') }}
|
||||
env:
|
||||
ABXPKG_LIB_DIR: /tmp/archivebox-lib
|
||||
CHROME_HEADLESS: "true"
|
||||
DATA_DIR: /tmp/archivebox-test-data
|
||||
PERSONAS_DIR: /tmp/abx-personas
|
||||
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -83,6 +115,15 @@ jobs:
|
||||
test: ${{ fromJson(needs.discover-tests.outputs.tests) }}
|
||||
|
||||
steps:
|
||||
- name: Set job-local runtime paths
|
||||
run: |
|
||||
{
|
||||
echo "ABXPKG_LIB_DIR=$RUNNER_TEMP/archivebox-lib"
|
||||
echo "DATA_DIR=$RUNNER_TEMP/archivebox-test-data"
|
||||
echo "PERSONAS_DIR=$RUNNER_TEMP/abx-personas"
|
||||
echo "CHROME_USER_DATA_DIR=$RUNNER_TEMP/abx-personas/Default/chrome_profile"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
submodules: true
|
||||
@@ -132,6 +173,9 @@ jobs:
|
||||
extra_args=(--extra "$TEST_EXTRA")
|
||||
fi
|
||||
uv sync --no-cache --locked --dev "${extra_args[@]}"
|
||||
- name: Install Playwright Chromium for browser tests
|
||||
if: contains(matrix.test.paths_arg, 'test_proof_previews') || contains(matrix.test.paths_arg, 'test_snapshot_output_stacks') || contains(matrix.test.paths_arg, 'test_ui_admin_snapshot') || contains(matrix.test.paths_arg, 'test_cli_extract')
|
||||
run: uv run --no-cache --no-sync --no-sources playwright install chromium
|
||||
|
||||
- name: Run ${{ matrix.test.name }}
|
||||
env:
|
||||
|
||||
@@ -68,3 +68,5 @@ ArchiveBox.conf
|
||||
|
||||
# Local Claude Code task locks (not project state)
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
/_site/
|
||||
@@ -56,6 +56,8 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: fix-byte-order-marker
|
||||
- id: end-of-file-fixer
|
||||
# Preserve the exact bytes of captured cryptographic proof fixtures.
|
||||
exclude: ^archivebox/tests/fixtures/legacy_proofs/.*\.json$
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: forbid-submodules
|
||||
|
||||
@@ -15,6 +15,13 @@ ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev
|
||||
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
|
||||
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
|
||||
|
||||
## Concurrency Contract
|
||||
|
||||
- A collection has one orchestrator at a time. Local PID/process checks may warn about obvious same-machine duplicates, but must not claim to enforce ownership across machines or shared filesystems.
|
||||
- SQLite remains supported with concurrent short writes from CLI and server processes. Keep the SQLite database on a local filesystem, never NFS/SMB, and use short autocommit/CAS updates instead of long transactions or database locks.
|
||||
- Network calls, hook execution, filesystem migrations, and other long work belong in the orchestrator. Never hold a database transaction or lock across that work.
|
||||
- PostgreSQL and shared data directories are the path to future multi-machine scheduling. Coordinate that work through database-backed per-crawl/per-snapshot claims; do not introduce file leases or timer-based orchestrator election.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
@@ -77,4 +84,4 @@ uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_ta
|
||||
uv run prek run --all-files
|
||||
```
|
||||
|
||||
Releases are published only by `.github/workflows/release.yml` after the complete `dev` CI workflow succeeds. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
|
||||
Releases are published only by `.github/workflows/release.yml` after complete CI succeeds for the exact commit. Pushes to `main` prepare stable patch releases automatically; `dev` prepares release candidates. Version selection and dependency repinning are owned by CI and the monorepo coordinator. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
|
||||
+71
-20
@@ -1,8 +1,38 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
|
||||
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
|
||||
# ArchiveBox owns sonic, supervisor, Django, and the app runtime.
|
||||
# WHY: abx-dl owns Python, Node, Chromium, and extraction dependencies so the
|
||||
# standalone downloader stays independent of ArchiveBox's server features.
|
||||
# ArchiveBox-only dependencies (Sonic, OpenCode, supervisor, Django) belong in
|
||||
# the layers added by this Dockerfile, never in the shared abx-dl base image.
|
||||
# REQUIRED INSTALLATION CONTRACT — do not work around failures:
|
||||
# 1. IMAGE CONSTRUCTION: preinstall ALL plugins supported by runtime="archivebox"
|
||||
# and ALL their required_binaries, regardless of default enabled state.
|
||||
# Inherit the complete downloader dependencies from the tested abx-dl image;
|
||||
# install every remaining ArchiveBox-only dependency in this image's layers,
|
||||
# including OpenCode and Sonic. Never put server dependencies in abx-dl.
|
||||
# Use the normal plugin dependency pipeline: install command -> config.json
|
||||
# required_binaries -> abxpkg. Never add manual apt/pip/npm installs to hide
|
||||
# failures in that pipeline. OS/interpreter bootstrap is a separate concern,
|
||||
# not a second hand-maintained list of plugin dependencies.
|
||||
# 2. FINISHED IMAGE: archivebox install and the full plugin installation check
|
||||
# must discover everything already present and be NO-OPS offline as UID 911.
|
||||
# No downloads, package installs, sudo, or root repair may be needed to enable
|
||||
# ANY plugin or start a new crawl. Never run verification as root or manually
|
||||
# preinstall packages before it. If the inherited image lacks a downloader
|
||||
# dependency, fix/release abx-dl and consume that tested image; do not patch
|
||||
# over the missing base dependency here or repair deployed containers by SSH.
|
||||
# 3. NON-CONTAINER CI: exercise real runtime auto-installation through the same
|
||||
# CLI/required_binaries/abxpkg path users invoke. GitHub's runner has
|
||||
# passwordless sudo; abxpkg must escalate automatically when needed. Never
|
||||
# manually preinstall dependencies or wrap the tested command in sudo to make
|
||||
# a failure disappear. This is distinct from the finished-image no-op test.
|
||||
# 4. SIZE: the complete compressed image budget is 1025 MiB. Preserve ALL
|
||||
# user-facing functionality. Only mechanical/internal
|
||||
# optimizations are allowed. Never remove dependencies, disable features or
|
||||
# plugins, weaken assertions, or skip checks to meet a size limit. If the
|
||||
# complete image exceeds the configured limit, LET CI FAIL and explicitly
|
||||
# ask the maintainer what to do. Do not raise the limit unilaterally.
|
||||
# Build abx-dl first, then point this file at it:
|
||||
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
|
||||
# --build-context abxbus=../abxbus \
|
||||
@@ -10,10 +40,10 @@
|
||||
# --build-context abx-plugins=../abx-plugins \
|
||||
# -t archivebox/abx-dl:dev
|
||||
# docker buildx build . -f Dockerfile \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.225 \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:dev \
|
||||
# -t archivebox:multistage
|
||||
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.225
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:main
|
||||
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
|
||||
@@ -47,6 +77,7 @@ ENV ARCHIVEBOX_USER=archivebox \
|
||||
IN_DOCKER=True
|
||||
|
||||
ENV CODE_DIR=/app \
|
||||
ABX_RUNTIME=archivebox \
|
||||
DATA_DIR=/data \
|
||||
CONFIG_DIR=/opt/archivebox \
|
||||
ABXPKG_LIB_DIR=/opt/archivebox/lib \
|
||||
@@ -96,17 +127,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
|
||||
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
|
||||
<<'EOF'
|
||||
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
|
||||
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
|
||||
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
|
||||
export UV_NO_CACHE=true
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update -qq
|
||||
apt-get install -qq -y --no-install-recommends \
|
||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev
|
||||
apt-get install -qq -y --no-install-recommends x11-utils
|
||||
# Extend abx-dl's existing venv instead of clearing it. Clearing and later
|
||||
# copying a complete replacement over the base duplicates every venv byte in
|
||||
# the final overlay history even when most packages are unchanged.
|
||||
/usr/bin/uv pip install --no-cache setuptools pip wheel
|
||||
# Do not rewrite inherited native libraries merely to strip them again.
|
||||
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print > /tmp/archivebox-inherited-libraries
|
||||
|
||||
mkdir -p /tmp/archivebox-uv-project
|
||||
/venv/bin/python - <<'PY'
|
||||
@@ -155,25 +184,31 @@ PY
|
||||
--inexact \
|
||||
--no-install-project \
|
||||
--no-install-workspace \
|
||||
--no-install-package abxbus \
|
||||
--no-install-package abxpkg \
|
||||
--no-install-package abx-plugins \
|
||||
--no-install-package abx-dl \
|
||||
--no-sources
|
||||
builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
|
||||
ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
|
||||
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
|
||||
while IFS= read -r -d '' native_library; do
|
||||
if grep -Fxq "$native_library" /tmp/archivebox-inherited-libraries; then
|
||||
continue
|
||||
fi
|
||||
magic=''
|
||||
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
|
||||
"$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
|
||||
fi
|
||||
done < /tmp/archivebox-native-libraries
|
||||
rm -f /tmp/archivebox-native-libraries
|
||||
rm -f /tmp/archivebox-native-libraries /tmp/archivebox-inherited-libraries
|
||||
rm -f /venv/bin/uv /venv/bin/uvx
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils
|
||||
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
|
||||
rm -rf "$builder_abxpkg_lib_dir"
|
||||
rm -rf /venv/lib/python3.*/site-packages/pip* \
|
||||
/venv/lib/python3.*/site-packages/wheel* \
|
||||
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "0";' > /etc/apt/apt.conf.d/99keep-cache
|
||||
rm -rf /var/lib/apt/lists/* /tmp/archivebox-uv-project
|
||||
EOF
|
||||
|
||||
@@ -202,7 +237,7 @@ RUN echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
|
||||
&& test -f "$(/venv/bin/python -c 'import archivebox; print(archivebox.__cached__)')" \
|
||||
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
|
||||
|
||||
# The builder installs and purges compilers in one layer, so its final
|
||||
# The builder installs and purges stripping tools in one layer, so its final
|
||||
# filesystem is already runtime-clean. Preserve that ancestry: starting again
|
||||
# from archivebox-runtime-base and COPYing /venv would bake a second full venv
|
||||
# over the inherited one instead of recording only ArchiveBox's package delta.
|
||||
@@ -225,7 +260,8 @@ COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
|
||||
# The builder invokes abxpkg as root for temporary ELF stripping, which can
|
||||
# rewrite derived state ownership. Restore UID 911 before resolving binaries
|
||||
# as the runtime user; the old copy-based final stage hid this dependency.
|
||||
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
|
||||
RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777,id=archivebox-runtime-config-$TARGETARCH \
|
||||
echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
|
||||
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
|
||||
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
|
||||
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
|
||||
@@ -239,12 +275,27 @@ RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..."
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
|
||||
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
|
||||
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
|
||||
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
|
||||
&& find "$ABXPKG_LIB_DIR" \( ! -user "$DEFAULT_ARCHIVEBOX_UID" -o ! -group "$DEFAULT_ARCHIVEBOX_GID" \) -exec chown -h "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" {} + \
|
||||
&& UV_NO_CACHE=true setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
|
||||
&& openssl rand -hex 16 > /etc/machine-id \
|
||||
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
|
||||
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
|
||||
|
||||
# Install the complete ArchiveBox catalog, including disabled server plugins.
|
||||
# Explicit plugin names make installation independent of enabled defaults.
|
||||
RUN --mount=type=cache,target=/var/tmp/abxpkg-cache,sharing=locked,mode=1777,id=archivebox-opencode-$TARGETARCH \
|
||||
chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" /var/tmp/abxpkg-cache \
|
||||
&& chmod 1777 /var/tmp/abxpkg-cache \
|
||||
&& export ABX_DOCKER_PLUGINS="$(/venv/bin/python3 -c 'from abx_dl.catalog import PluginCatalog; print(" ".join(PluginCatalog.discover(runtime="archivebox")))')" \
|
||||
&& env ABXPKG_TMP_CACHE_DIR=/var/tmp/abxpkg-cache XDG_CACHE_HOME=/var/tmp/abxpkg-cache \
|
||||
setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abx-dl install $ABX_DOCKER_PLUGINS \
|
||||
# pnpm includes musl variants that Debian's glibc runtime cannot use.
|
||||
&& find "$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules" -type l -name 'opencode-linux-*-musl' -delete \
|
||||
&& find "$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.pnpm" -maxdepth 1 -type d -name 'opencode-linux-*-musl@*' -exec rm -rf {} +
|
||||
RUN --network=none export ABX_DOCKER_PLUGINS="$(/venv/bin/python3 -c 'from abx_dl.catalog import PluginCatalog; print(" ".join(PluginCatalog.discover(runtime="archivebox")))')" \
|
||||
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups \
|
||||
bash -c '"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version && abx-dl install $ABX_DOCKER_PLUGINS'
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
RUN echo "[+] Initializing image collection..." \
|
||||
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
|
||||
@@ -277,10 +328,10 @@ RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully
|
||||
|
||||
WORKDIR "$DATA_DIR"
|
||||
VOLUME "$DATA_DIR"
|
||||
EXPOSE 8000
|
||||
EXPOSE 5797
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
|
||||
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:8000/health/' | grep -q 'OK'
|
||||
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:5797/health/' | grep -q 'OK'
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
|
||||
CMD ["archivebox", "server", "--init", "0.0.0.0:8000"]
|
||||
CMD ["archivebox", "server", "--init", "0.0.0.0:5797"]
|
||||
@@ -73,15 +73,15 @@ mkdir -p ~/archivebox/data && cd ~/archivebox
|
||||
curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml
|
||||
docker compose pull
|
||||
docker compose up -d --wait # initializes new collections automatically
|
||||
# open http://admin.archivebox.localhost:8000 to finish setup
|
||||
# open http://admin.archivebox.localhost:5797 to finish setup
|
||||
# docker compose run --rm archivebox add 'https://example.com'
|
||||
# docker compose run --rm archivebox help
|
||||
<br/>
|
||||
<br/>
|
||||
# Option B: Or use it as a plain Docker container:
|
||||
mkdir -p ~/archivebox/data && cd ~/archivebox/data
|
||||
docker run -d --name archivebox -v "$PWD:/data" -p 8000:8000 archivebox/archivebox:dev
|
||||
# open http://admin.archivebox.localhost:8000 to finish setup
|
||||
docker run -d --name archivebox -v "$PWD:/data" -p 5797:5797 archivebox/archivebox:dev
|
||||
# open http://admin.archivebox.localhost:5797 to finish setup
|
||||
# docker run -it -v $PWD:/data archivebox/archivebox:dev add 'https://example.com'
|
||||
# docker run -it -v $PWD:/data archivebox/archivebox:dev help
|
||||
<br/>
|
||||
@@ -93,14 +93,14 @@ archivebox init
|
||||
archivebox install
|
||||
# archivebox add 'https://example.com'
|
||||
# archivebox help
|
||||
# archivebox server 0.0.0.0:8000
|
||||
# archivebox server 0.0.0.0:5797
|
||||
<br/>
|
||||
<br/>
|
||||
# Option D: Or use the uv install shortcut for Option C
|
||||
curl -fsSL 'https://get.archivebox.io' | bash
|
||||
</code></pre>
|
||||
<br/>
|
||||
<sub>Open <a href="http://web.archivebox.localhost:8000"><code>http://web.archivebox.localhost:8000</code></a> for the public UI and <a href="http://admin.archivebox.localhost:8000"><code>http://admin.archivebox.localhost:8000</code></a> for the admin UI ➡️</sub><br/>
|
||||
<sub>Open <a href="http://web.archivebox.localhost:5797"><code>http://web.archivebox.localhost:5797</code></a> for the public UI and <a href="http://admin.archivebox.localhost:5797"><code>http://admin.archivebox.localhost:5797</code></a> for the admin UI ➡️</sub><br/>
|
||||
<sub>Set <code>BASE_URL</code> to change the public base domain. The default <code>auto</code> mode uses <code>web.</code> and <code>admin.</code> subdomains on <code>*.localhost</code>, but one host for ordinary DNS names. <code>BIND_ADDR</code> only controls the local listen address.</sub>
|
||||
</details>
|
||||
<br/>
|
||||
@@ -129,10 +129,10 @@ curl -fsSL 'https://get.archivebox.io' | bash
|
||||
- [**Extracts a wide variety of content out-of-the-box**](https://github.com/ArchiveBox/ArchiveBox/issues/51): [media (yt-dlp), articles (readability), code (git), etc.](#output-formats)
|
||||
- [**Supports scheduled/realtime importing**](https://github.com/ArchiveBox/ArchiveBox/wiki/Scheduled-Archiving) from [many types of sources](#input-formats)
|
||||
- [**Uses standard, durable, long-term formats**](#output-formats) like HTML, JSON, PDF, PNG, MP4, TXT, and WARC
|
||||
- [**Powerful CLI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#CLI-Usage), [**self-hosted web UI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#UI-Usage), [Python API](https://docs.archivebox.io/en/dev/apidocs/archivebox/archivebox.html) (BETA), [REST API](https://github.com/ArchiveBox/ArchiveBox/issues/496) (ALPHA), or [desktop app](https://github.com/ArchiveBox/electron-archivebox)
|
||||
- [**Powerful CLI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#CLI-Usage), [**self-hosted web UI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#UI-Usage), [Python API](https://docs.archivebox.io/en/dev/apidocs/archivebox/archivebox.html) (BETA), [REST API](https://github.com/ArchiveBox/ArchiveBox/issues/496) (ALPHA), or [desktop app](https://electron.archivebox.io/)
|
||||
- [**Saves all pages to archive.org as well**](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#save_archive_dot_org) by default for redundancy (can be [disabled](https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#stealth-mode) for local-only mode)
|
||||
- Advanced users: support for archiving [content requiring login/paywall/cookies](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#chrome_user_data_dir) (see wiki security caveats!)
|
||||
- Planned: support for running [JS during archiving](https://github.com/ArchiveBox/ArchiveBox/issues/51) to adblock, [autoscroll](https://github.com/ArchiveBox/ArchiveBox/issues/80), [modal-hide](https://github.com/ArchiveBox/ArchiveBox/issues/175), [thread-expand](https://github.com/ArchiveBox/ArchiveBox/issues/345)
|
||||
- Support for archiving [content requiring login/paywall/cookies](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#chrome_user_data_dir) (see wiki security caveats!)
|
||||
- Support for running [JS during archiving](https://github.com/ArchiveBox/ArchiveBox/issues/51) to adblock, [infiniscroll](https://github.com/ArchiveBox/ArchiveBox/issues/80), [modal-hide](https://github.com/ArchiveBox/ArchiveBox/issues/175), [thread-expand](https://github.com/ArchiveBox/ArchiveBox/issues/345)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -151,6 +151,17 @@ curl -fsSL 'https://get.archivebox.io' | bash
|
||||
|
||||
#### ✳️ Easy Setup
|
||||
|
||||
<details>
|
||||
<summary><b><img src="https://app.archivebox.io/docs/icons/apple.svg" alt="Apple" height="28px" align="top"/> <code>ArchiveBox Server.app</code></b> (macOS)</summary>
|
||||
<br/>
|
||||
<ol>
|
||||
<li><a href="https://github.com/ArchiveBox/ios-archivebox/releases">Download <code>ArchiveBox Server.app</code></a>, move it to Applications, and open it.</li>
|
||||
<li>Set up an admin account and generate an API Key.</li>
|
||||
<li>Use the Web UI or <a href="https://app.archivebox.io/"><code>ArchiveBox.app</code></a>, and optionally connect REST API clients, the <a href="https://app.archivebox.io/">mobile app</a>, the <a href="https://extension.archivebox.io/">browser extension</a>, and other integrations to your server using your API Key.</li>
|
||||
</ol>
|
||||
<br/>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b><img src="https://user-images.githubusercontent.com/511499/117447182-29758200-af0b-11eb-97bd-58723fee62ab.png" alt="Docker" height="28px" align="top"/> <code>docker-compose</code></b> (macOS/Linux) <b>👈 recommended</b> <i>(click to expand)</i></summary>
|
||||
<br/>
|
||||
@@ -166,7 +177,7 @@ docker compose pull
|
||||
<li>Start the server, which initializes a new collection automatically.
|
||||
<pre lang="bash"><code style="white-space: pre-line">docker compose up -d --wait
|
||||
</code></pre></li>
|
||||
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
|
||||
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
|
||||
<pre lang="bash"><code style="white-space: pre-line">
|
||||
# run CLI commands inside the server container started above
|
||||
docker compose exec archivebox archivebox add 'https://example.com'
|
||||
@@ -187,10 +198,10 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the C
|
||||
<li>Install <a href="https://docs.docker.com/get-docker/">Docker</a> on your system (if not already installed).</li>
|
||||
<li>Create a new empty directory and start the server, which initializes the collection automatically (can be anywhere).
|
||||
<pre lang="bash"><code style="white-space: pre-line">mkdir -p ~/archivebox/data && cd ~/archivebox/data
|
||||
docker run -d --name archivebox -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
|
||||
docker run -d --name archivebox -v $PWD:/data -p 5797:5797 archivebox/archivebox:dev
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
|
||||
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
|
||||
<pre lang="bash"><code style="white-space: pre-line">
|
||||
# completely optional, CLI can always be used without running a server
|
||||
# docker exec archivebox archivebox [subcommand] [--help]
|
||||
@@ -245,8 +256,8 @@ archivebox init # initialize a new collection
|
||||
archivebox install # install all the runtime dependencies (e.g. chrome, single-file, yt-dlp, etc.)
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
|
||||
# completely optional, CLI can always be used without running a server
|
||||
# archivebox [subcommand] [--help]
|
||||
archivebox help
|
||||
@@ -281,8 +292,8 @@ archivebox add 'https://example.com'
|
||||
</code></pre>
|
||||
<br/>
|
||||
</li>
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
|
||||
# completely optional, CLI can always be used without running a server
|
||||
# archivebox [subcommand] [--help]
|
||||
archivebox help
|
||||
@@ -314,8 +325,8 @@ archivebox init
|
||||
archivebox install
|
||||
</code></pre>
|
||||
</li>
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
|
||||
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
|
||||
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
|
||||
# completely optional, CLI can always be used without running a server
|
||||
# archivebox [subcommand] [--help]
|
||||
archivebox help
|
||||
@@ -354,17 +365,12 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, W
|
||||
<br/>
|
||||
<ol>
|
||||
<li>Install <a href="https://docs.docker.com/get-docker/">Docker</a> on your system (if not already installed).</li>
|
||||
<li>Download a binary release for your OS or build the native app from source<br/>
|
||||
<ul>
|
||||
<li>macOS: <a href="https://github.com/ArchiveBox/ArchiveBox/releases/download/v0.6.2/Electron-ArchiveBox-macOS-x64-0.6.2.app.zip" download><code>ArchiveBox.app.zip</code></a></li>
|
||||
<li>Linux: <code>ArchiveBox.deb</code> (alpha: <a href="https://github.com/ArchiveBox/electron-archivebox#quickstart">build manually</a>)</li>
|
||||
<li>Windows: <code>ArchiveBox.exe</code> (beta: <a href="https://github.com/ArchiveBox/electron-archivebox#quickstart">build manually</a>)</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="https://electron.archivebox.io/#get-started">Download the latest installer for Windows, Linux, or Mac</a>.</li>
|
||||
<li>Install and open ArchiveBox Desktop, then create your local administrator account to start archiving.</li>
|
||||
</ol>
|
||||
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/575ef92f-bb3e-4a7c-a4ba-986c1fd76ecf" width="320px">
|
||||
<br/>
|
||||
<i>✨ Alpha (contributors wanted!)</i>: for more info, see the: <a href="https://github.com/ArchiveBox/electron-archivebox">Electron ArchiveBox</a> repo.
|
||||
<a href="https://electron.archivebox.io/">Desktop app guide</a> · <a href="https://electron.archivebox.io/screenshots/">Screenshots</a>
|
||||
<br/>
|
||||
</details>
|
||||
|
||||
@@ -426,7 +432,7 @@ For more discussion on third-party hosting options see here: <a href="https://gi
|
||||
#### ➡️ Next Steps
|
||||
|
||||
- Import URLs from some of the supported [Input Formats](#input-formats) or view the supported [Output Formats](#output-formats)...
|
||||
- (Optional) Create a persona and import browser cookies to archive logged-in sites: `archivebox persona create --import=chrome personal`
|
||||
- (Optional) [Import browser cookies and settings into a persona](docs/Chromium-Install.md#import-an-existing-browser-profile).
|
||||
- Tweak your UI or archiving behavior [Configuration](#configuration), read about some of the [Caveats](#caveats), or [Troubleshoot](https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting)
|
||||
- Read about the [Dependencies](#dependencies) used for archiving, the [Upgrading Process](https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives), or the [Archive Layout](#archive-layout) on disk...
|
||||
- Or check out our full [Documentation](#documentation) or [Community Wiki](#internet-archiving-ecosystem)...
|
||||
@@ -454,11 +460,6 @@ docker compose run --rm archivebox help
|
||||
# equivalent: docker run -it -v $PWD:/data archivebox/archivebox:dev [subcommand] [--help]
|
||||
docker run -it -v $PWD:/data archivebox/archivebox:dev help
|
||||
|
||||
# optional: import your browser cookies into a persona for logged-in archiving
|
||||
archivebox persona create --import=chrome personal
|
||||
# supported: chrome/chromium/brave/edge (Chromium-based only)
|
||||
# use --profile to target a specific profile (e.g. Default, Profile 1)
|
||||
# re-running import merges/dedupes cookies.txt (by domain/path/name) but replaces chrome_user_data
|
||||
```
|
||||
|
||||
#### ArchiveBox Subcommands
|
||||
@@ -538,16 +539,16 @@ find ./archive/users -path '*/snapshots/*/*/*/index.html' # inspect snapshot da
|
||||
<summary><b>🖥 Web UI & API Usage</b></summary>
|
||||
<pre lang="bash"><code style="white-space: pre-line">
|
||||
# Start the server on bare metal (uv/apt/brew):
|
||||
archivebox server 0.0.0.0:8000 # start the server
|
||||
archivebox server 0.0.0.0:5797 # start the server
|
||||
<br/>
|
||||
# Or with Docker Compose:
|
||||
docker compose up # start the server
|
||||
<br/>
|
||||
# Or with a Docker container:
|
||||
docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox:dev
|
||||
docker run -v $PWD:/data -it -p 5797:5797 archivebox/archivebox:dev
|
||||
</code></pre>
|
||||
|
||||
<sup>Open <a href="http://admin.archivebox.localhost:8000/admin/"><code>http://admin.archivebox.localhost:8000/admin/</code></a> to create the first admin and finish web setup. Use <a href="http://web.archivebox.localhost:8000"><code>http://web.archivebox.localhost:8000</code></a> for the public UI. ➡️</sup><br/>
|
||||
<sup>Open <a href="http://admin.archivebox.localhost:5797/admin/"><code>http://admin.archivebox.localhost:5797/admin/</code></a> to create the first admin and finish web setup. Use <a href="http://web.archivebox.localhost:5797"><code>http://web.archivebox.localhost:5797</code></a> for the public UI. ➡️</sup><br/>
|
||||
<sup>Advanced: <code>archivebox manage createsuperuser</code> remains available for creating accounts from the CLI.</sup><br/>
|
||||
<sup>Set <code>BASE_URL</code> to change the public base domain. The default <code>auto</code> mode uses <code>web.</code> and <code>admin.</code> subdomains on <code>*.localhost</code>, but one host for ordinary DNS names. <code>BIND_ADDR</code> only controls the local listen address.</sup>
|
||||
<br/><br/>
|
||||
@@ -564,6 +565,22 @@ docker compose run --rm archivebox config --set PERMISSIONS=public
|
||||
|
||||
# restart the server to apply any config changes
|
||||
</code></pre>
|
||||
|
||||
Snapshot caching follows each snapshot's saved `PERMISSIONS`, including inherited defaults and snapshot overrides. Public content can be cached by browsers and shared proxies/CDNs; private and unlisted content uses `Cache-Control: private` so only the user's browser can cache it. Both retain normal artifact cache lifetimes and validators for fast repeat viewing. Authentication handoffs are not stored. Changing permissions does not revoke copies already cached under the previous policy; purge any affected shared cache when needed.
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
<details>
|
||||
<summary><b><img src="https://app.archivebox.io/docs/icons/apple.svg" alt="Apple" height="22px" align="top"/> <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.17.0/icons/windows11/windows11-original.svg" alt="Windows" height="22px" align="top"/> 🐧 Desktop / Mobile / Extension Usage</b></summary>
|
||||
<br/>
|
||||
|
||||
- 📱 [<code>ArchiveBox</code> iOS Mobile App](https://app.archivebox.io/)
|
||||
- 🖥️ [<code>ArchiveBox.app</code> macOS Desktop App](https://app.archivebox.io/)
|
||||
- 🧩 [ArchiveBox Browser Extension](https://extension.archivebox.io/)
|
||||
- 💻 [Linux/Windows Desktop App (Requires Docker Desktop)](https://electron.archivebox.io/)
|
||||
|
||||
<br/>
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
@@ -619,7 +636,10 @@ docker run -it -v $PWD:/data archivebox/archivebox:dev add --depth=1 'https://ex
|
||||
|
||||
|
||||
- <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/ff20d251-5347-4b85-ae9b-83037d0ac01e" height="28px"/> <b>From the official <a href="https://github.com/ArchiveBox/archivebox-extension">ArchiveBox Browser Extension</a></b>
|
||||
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox browsers.</i>
|
||||
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox/Safari browsers.</i>
|
||||
|
||||
- <img src="https://app.archivebox.io/App/AppIcon.icon/Assets/ArchiveBox.png" alt="ArchiveBox app icon" height="28px"/> <b>From the official <a href="https://app.archivebox.io/">ArchiveBox macOS Desktop App or iOS Mobile App</a></b><br/>
|
||||
<i>Save directly from the Share menu or Siri/Shortcuts to your server.</i>
|
||||
|
||||
- <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/64078483-21d7-4eb1-aa6e-9ad55afe45b8" height="22px"/> From manual imports of URLs from RSS, JSON, CSV, TXT, SQL, HTML, Markdown, etc. files
|
||||
<i>ArchiveBox supports injesting URLs in [any text-based format](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Import-a-list-of-URLs-from-a-text-file).</i>
|
||||
@@ -932,7 +952,7 @@ archivebox manage createsuperuser
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive">Wiki: Publishing Your Archive</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Wiki: Security Overview</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#setting-up-a-chromium-user-profile">Wiki: Chromium Install (Setting Up a User Profile)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Personas">Wiki: Personas (browser profiles and cookies)</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#import-an-existing-browser-profile">Wiki: Chrome profiles and cookies</a></li>
|
||||
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#default_persona">Wiki: Configuration (<code>DEFAULT_PERSONA</code>)</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -985,14 +1005,11 @@ For various reasons, many large sites (Reddit, Twitter, Cloudflare, etc.) active
|
||||
|
||||
|
||||
<ul>
|
||||
<li>Set <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#curl_user_agent"><code>CHROME_USER_AGENT</code>, <code>WGET_USER_AGENT</code>, <code>CURL_USER_AGENT</code></a> to impersonate a real browser (by default, ArchiveBox reveals that it's a bot when using the default user agent settings)</li>
|
||||
<li>Set up a logged-in browser session for archiving by <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Personas">importing a dedicated browser profile into a persona</a></li>
|
||||
<li>Set up a logged-in browser session for archiving by <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#import-an-existing-browser-profile">importing a chrome profile into a persona</a></li>
|
||||
<li>Rewrite your URLs before archiving to swap in alternative frontends that are more bot-friendly e.g.<br>
|
||||
<code>reddit.com/some/url</code> -> <code>teddit.net/some/url</code>: <a href="https://github.com/mendel5/alternative-front-ends">https://github.com/mendel5/alternative-front-ends</a></li>
|
||||
</ul>
|
||||
|
||||
In the future we plan on adding support for running JS scripts during archiving to block ads, cookie popups, modals, and fix other issues. Follow here for progress: <a href="https://github.com/ArchiveBox/ArchiveBox/issues/51">Issue #51</a>.
|
||||
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
@@ -1041,8 +1058,8 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
|
||||
</li>
|
||||
<li><strong>Don't store large collections on older filesystems like EXT3/FAT</strong> as they may not be able to handle more than 50k directory entries in the <code>data/archive/</code> folder.
|
||||
</li>
|
||||
<li><strong>Try to keep the <code>data/index.sqlite3</code> file on local drive (not a network mount)</strong> or SSD for maximum performance, however the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
|
||||
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, make sure the mounted data directory is writable by its intended owner and consider disabling <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304"><code>root_squash</code></a> on your fileshare server.
|
||||
<li><strong>Keep the <code>data/index.sqlite3</code> file on a reliable local drive (not a network mount)</strong> or SSD when using SQLite; the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
|
||||
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, configure the server-side UID/GID mapping or ACL so ArchiveBox's non-root user can create and remove files. <code>root_squash</code> is compatible when the selected non-root identity has write access; do not rely on container root to override server-side permissions.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1323,10 +1340,10 @@ archivebox init
|
||||
archivebox install # detect and install all extractor dependencies
|
||||
|
||||
# Run the development server w/ autoreloading (but no bg workers)
|
||||
archivebox server --debug --reload 0.0.0.0:8000
|
||||
archivebox server --debug --reload 0.0.0.0:5797
|
||||
|
||||
# Run the production server (with bg workers but no autoreloading)
|
||||
archivebox server 0.0.0.0:8000
|
||||
archivebox server 0.0.0.0:5797
|
||||
```
|
||||
|
||||
#### 2. Option B: Build the docker container and use that for development instead
|
||||
@@ -1338,10 +1355,10 @@ archivebox server 0.0.0.0:8000
|
||||
./bin/build_docker.sh dev
|
||||
|
||||
# Run the development server w/ autoreloading (but no bg workers)
|
||||
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init --debug --reload 0.0.0.0:8000
|
||||
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 5797:5797 archivebox/archivebox:dev server --init --debug --reload 0.0.0.0:5797
|
||||
|
||||
# Run the production server (with bg workers but no autoreloading)
|
||||
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init
|
||||
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 5797:5797 archivebox/archivebox:dev server --init
|
||||
|
||||
# (remove the --reload flag and add the --nothreading flag when profiling with the django debug toolbar)
|
||||
# When using --reload, make sure any files you create can be read by the user in the Docker container, eg with 'chmod a+rX'.
|
||||
@@ -1363,11 +1380,11 @@ You can also run all these in Docker. For more examples see the GitHub Actions C
|
||||
archivebox config --set DEBUG=True
|
||||
|
||||
# OR you can run a dev server with DEBUG=True in a few ways:
|
||||
archivebox server --debug --reload 0.0.0.0:8000
|
||||
archivebox server --debug --reload 0.0.0.0:5797
|
||||
# or
|
||||
archivebox server --debug 0.0.0.0:8000
|
||||
archivebox server --debug 0.0.0.0:5797
|
||||
# or
|
||||
env DEBUG=True daphne -b 0.0.0.0 -p 8000 archivebox.core.asgi:application
|
||||
env DEBUG=True daphne -b 0.0.0.0 -p 5797 archivebox.core.asgi:application
|
||||
```
|
||||
|
||||
https://stackoverflow.com/questions/1074212/how-can-i-see-the-raw-sql-queries-django-is-running
|
||||
@@ -1402,7 +1419,7 @@ services:
|
||||
|
||||
# or with plain Docker:
|
||||
docker build -t archivebox:dev https://github.com/ArchiveBox/ArchiveBox.git#dev
|
||||
docker run -it -v $PWD:/data -p 8000:8000 archivebox:dev
|
||||
docker run -it -v $PWD:/data -p 5797:5797 archivebox:dev
|
||||
|
||||
# or with uv:
|
||||
uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
|
||||
|
||||
@@ -7,6 +7,11 @@ class APIConfig(AppConfig):
|
||||
name = "archivebox.api"
|
||||
label = "api"
|
||||
|
||||
def ready(self):
|
||||
from archivebox.api.webhooks import register_delete_webhook_field_loader
|
||||
|
||||
register_delete_webhook_field_loader()
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
from archivebox.api.admin import register_admin
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.http import HttpRequest
|
||||
|
||||
from ninja import Router, Schema
|
||||
|
||||
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token
|
||||
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token, HeaderTokenAuth, BearerTokenAuth
|
||||
|
||||
|
||||
router = Router(tags=["Authentication"], auth=None)
|
||||
@@ -63,3 +63,44 @@ def check_api_token(request: HttpRequest, token_data: TokenAuthSchema):
|
||||
return {"success": True, "user_id": str(user.pk)}
|
||||
|
||||
return {"success": False, "user_id": None}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/browser_session",
|
||||
auth=[HeaderTokenAuth(), BearerTokenAuth()],
|
||||
summary="Exchange an administrator API key for a normal browser session",
|
||||
)
|
||||
def browser_session(request: HttpRequest):
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from ninja.errors import HttpError
|
||||
from archivebox.core.routes_util import get_admin_base_url
|
||||
|
||||
user = request.auth
|
||||
if not user.is_active or not user.is_superuser:
|
||||
raise HttpError(403, "An active administrator is required")
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
# A browser session must not outlive the key used to establish it.
|
||||
token = request._api_token
|
||||
lifetime = settings.SESSION_COOKIE_AGE
|
||||
if token.expires:
|
||||
lifetime = min(lifetime, max(0, int((token.expires - timezone.now()).total_seconds())))
|
||||
request.session.set_expiry(lifetime)
|
||||
request.session.save()
|
||||
response = JsonResponse(
|
||||
{
|
||||
"admin_url": get_admin_base_url(request=request).rstrip("/") + "/admin/",
|
||||
"cookie": {
|
||||
"name": settings.SESSION_COOKIE_NAME,
|
||||
"value": request.session.session_key,
|
||||
"expires": request.session.get_expiry_date().timestamp(),
|
||||
"secure": settings.SESSION_COOKIE_SECURE,
|
||||
},
|
||||
},
|
||||
)
|
||||
# The native client installs a host-only HttpOnly cookie on the admin origin;
|
||||
# API and admin hosts can differ, so a Set-Cookie on the API host is insufficient.
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -133,6 +133,15 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
|
||||
validate_url(args.urls[0])
|
||||
except ValueError:
|
||||
submitted_urls = args.urls[0]
|
||||
else:
|
||||
submitted_urls = []
|
||||
for url in args.urls:
|
||||
try:
|
||||
submitted_urls.append(validate_url(url))
|
||||
except ValueError:
|
||||
continue
|
||||
if not submitted_urls:
|
||||
raise HttpError(400, "No valid URLs were submitted")
|
||||
crawl, snapshots = add(
|
||||
urls=submitted_urls,
|
||||
snapshot_ids=args.snapshot_ids,
|
||||
@@ -156,7 +165,7 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
|
||||
"crawl_id": str(crawl.id),
|
||||
"num_snapshots": len(snapshot_ids),
|
||||
"snapshot_ids": snapshot_ids,
|
||||
"queued_urls": args.urls,
|
||||
"queued_urls": args.urls if isinstance(submitted_urls, str) else submitted_urls,
|
||||
}
|
||||
stdout = request.__dict__.get("stdout")
|
||||
stderr = request.__dict__.get("stderr")
|
||||
|
||||
+125
-136
@@ -11,7 +11,7 @@ from uuid import UUID
|
||||
from typing import Union, Any, Annotated
|
||||
from datetime import datetime, time
|
||||
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Model, Q
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.multipartparser import MultiPartParser, MultiPartParserError
|
||||
@@ -32,7 +32,7 @@ from archivebox.core.models import Snapshot, ArchiveResult, Tag
|
||||
from archivebox.core.permissions import public_snapshots_queryset
|
||||
from archivebox.api.auth import authenticated_user_from_request
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.routes_util import build_web_url
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_web_url
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length
|
||||
from archivebox.core.tag_util import (
|
||||
add_snapshot_counts,
|
||||
@@ -247,7 +247,7 @@ def get_archiveresult(request: HttpRequest, archiveresult_id: str):
|
||||
|
||||
def _normalize_uploaded_archiveresult_plugin(plugin: str) -> str:
|
||||
normalized = str(plugin or "").strip().strip("/")
|
||||
if not ARCHIVERESULT_UPLOAD_PLUGIN_RE.fullmatch(normalized):
|
||||
if normalized in (".", "..") or not ARCHIVERESULT_UPLOAD_PLUGIN_RE.fullmatch(normalized):
|
||||
raise HttpError(400, "Invalid ArchiveResult plugin name")
|
||||
return normalized
|
||||
|
||||
@@ -342,21 +342,10 @@ def _parse_archiveresult_upload_int(value: str, field_name: str, *, default: int
|
||||
|
||||
|
||||
def _summarize_archiveresult_output_files(output_files: dict[str, dict[str, Any]]) -> tuple[int, str]:
|
||||
mime_sizes: dict[str, int] = defaultdict(int)
|
||||
total_size = 0
|
||||
for metadata in output_files.values():
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
try:
|
||||
size = max(int(metadata.get("size") or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
mime_type = str(metadata.get("mimetype") or "").strip()
|
||||
total_size += size
|
||||
if mime_type and size:
|
||||
mime_sizes[mime_type] += size
|
||||
output_mimetypes = ",".join(mime for mime, _size in sorted(mime_sizes.items(), key=lambda item: item[1], reverse=True))
|
||||
return total_size, output_mimetypes
|
||||
from abx_dl.output_files import OutputManifest
|
||||
|
||||
manifest = OutputManifest.from_value(output_files)
|
||||
return manifest.total_size, ",".join(manifest.mimetypes)
|
||||
|
||||
|
||||
def _get_snapshot_by_ref(snapshot_id: str):
|
||||
@@ -397,6 +386,7 @@ def _write_archiveresult_files(
|
||||
existing_output_files: dict[str, dict[str, Any]] | None = None,
|
||||
allow_empty: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
plugin_name = _normalize_uploaded_archiveresult_plugin(plugin_name)
|
||||
files = _get_archiveresult_upload_files(request, allow_empty=allow_empty)
|
||||
output_paths = _get_archiveresult_upload_form_values(request, "output_paths", "output_path")
|
||||
mime_types = _get_archiveresult_upload_form_values(request, "mime_types", "mime_type")
|
||||
@@ -528,52 +518,53 @@ def create_archiveresult(
|
||||
normalized_status = ArchiveResult.normalize_status(status)
|
||||
parsed_output_json = _parse_archiveresult_output_json(output_json)
|
||||
hook = hook_name or ARCHIVERESULT_UPLOAD_HOOK_NAME
|
||||
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
|
||||
existing_output_files = dict(existing_result.output_files or {}) if existing_result else {}
|
||||
output_files = _write_archiveresult_files(
|
||||
result_lookup = {
|
||||
"snapshot": snapshot,
|
||||
"plugin": plugin_name,
|
||||
"hook_name": hook,
|
||||
}
|
||||
uploaded_output_files = _write_archiveresult_files(
|
||||
request,
|
||||
snapshot,
|
||||
plugin_name,
|
||||
existing_output_files=existing_output_files,
|
||||
allow_empty=True,
|
||||
)
|
||||
now = timezone.now()
|
||||
|
||||
with transaction.atomic():
|
||||
Snapshot.objects.select_for_update().get(pk=snapshot.pk)
|
||||
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
|
||||
if existing_result:
|
||||
output_files = {
|
||||
**dict(existing_result.output_files or {}),
|
||||
**output_files,
|
||||
}
|
||||
result = existing_result
|
||||
else:
|
||||
existing_result = None
|
||||
result = ArchiveResult(
|
||||
snapshot=snapshot,
|
||||
plugin=plugin_name,
|
||||
hook_name=hook,
|
||||
)
|
||||
|
||||
if (
|
||||
existing_result
|
||||
and normalized_status == ArchiveResult.StatusChoices.STARTED
|
||||
and existing_result.status != ArchiveResult.StatusChoices.STARTED
|
||||
):
|
||||
normalized_status = existing_result.status
|
||||
result = ArchiveResult.objects.filter(**result_lookup).first()
|
||||
for _attempt in range(3):
|
||||
output_files = {
|
||||
**(result.output_file_map() if result else {}),
|
||||
**uploaded_output_files,
|
||||
}
|
||||
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
|
||||
output_file_paths = list(output_files.keys())
|
||||
result.status = normalized_status
|
||||
result.hook_name = hook
|
||||
result.output_str = output_str or (output_file_paths[0] if output_file_paths else "")
|
||||
result.output_json = parsed_output_json
|
||||
result.output_files = output_files
|
||||
result.output_size = output_size
|
||||
result.output_mimetypes = output_mimetypes
|
||||
result.start_ts = result.start_ts or now
|
||||
result.end_ts = now
|
||||
result.save()
|
||||
result_status = normalized_status
|
||||
if result and result_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
|
||||
result_status = result.status
|
||||
now = timezone.now()
|
||||
values = {
|
||||
"status": result_status,
|
||||
"output_str": output_str or (output_file_paths[0] if output_file_paths else ""),
|
||||
"output_json": parsed_output_json,
|
||||
"output_files": output_files,
|
||||
"output_size": output_size,
|
||||
"output_mimetypes": output_mimetypes,
|
||||
"start_ts": result.start_ts or now if result else now,
|
||||
"end_ts": now,
|
||||
}
|
||||
if result:
|
||||
if result.safe_update(values):
|
||||
break
|
||||
continue
|
||||
result, created = ArchiveResult.get_or_create_by_hook(
|
||||
snapshot,
|
||||
plugin_name,
|
||||
hook,
|
||||
defaults=values,
|
||||
)
|
||||
if created:
|
||||
break
|
||||
else:
|
||||
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
|
||||
|
||||
if result.status != ArchiveResult.StatusChoices.STARTED:
|
||||
_queue_archiveresult_snapshot_maintenance(snapshot)
|
||||
@@ -587,44 +578,46 @@ def patch_archiveresult(
|
||||
):
|
||||
"""Append or replace files on an existing ArchiveResult."""
|
||||
result = ArchiveResult.objects.select_related("snapshot__crawl__created_by").get(_uuid_ref_query("id", archiveresult_id))
|
||||
output_files = _write_archiveresult_files(
|
||||
uploaded_output_files = _write_archiveresult_files(
|
||||
request,
|
||||
result.snapshot,
|
||||
result.plugin,
|
||||
existing_output_files=result.output_file_map(),
|
||||
)
|
||||
latest_result = ArchiveResult.objects.only("output_files", "status").get(pk=result.pk)
|
||||
output_files = {
|
||||
**latest_result.output_file_map(),
|
||||
**output_files,
|
||||
}
|
||||
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
|
||||
|
||||
update_fields = ["output_files", "output_size", "output_mimetypes", "end_ts", "modified_at"]
|
||||
result.output_files = output_files
|
||||
result.output_size = output_size
|
||||
result.output_mimetypes = output_mimetypes
|
||||
result.end_ts = timezone.now()
|
||||
output_str = _get_archiveresult_upload_form_value(request, "output_str")
|
||||
status = _get_archiveresult_upload_form_value(request, "status")
|
||||
output_json = _get_archiveresult_upload_form_value(request, "output_json")
|
||||
if output_str:
|
||||
result.output_str = output_str
|
||||
update_fields.append("output_str")
|
||||
if status:
|
||||
normalized_status = ArchiveResult.normalize_status(status)
|
||||
if normalized_status == ArchiveResult.StatusChoices.STARTED and latest_result.status != ArchiveResult.StatusChoices.STARTED:
|
||||
normalized_status = latest_result.status
|
||||
result.status = normalized_status
|
||||
update_fields.append("status")
|
||||
elif latest_result.status == ArchiveResult.StatusChoices.QUEUED and ArchiveResult.output_files_upload_complete(output_files):
|
||||
result.status = ArchiveResult.StatusChoices.SUCCEEDED
|
||||
update_fields.append("status")
|
||||
if output_json:
|
||||
result.output_json = _parse_archiveresult_output_json(output_json)
|
||||
update_fields.append("output_json")
|
||||
parsed_output_json = _parse_archiveresult_output_json(output_json) if output_json else None
|
||||
|
||||
if not ArchiveResult.output_files_upload_complete(uploaded_output_files):
|
||||
result.output_files = {**result.output_file_map(), **uploaded_output_files}
|
||||
result.output_size, result.output_mimetypes = _summarize_archiveresult_output_files(result.output_files)
|
||||
return result
|
||||
|
||||
for _attempt in range(3):
|
||||
output_files = {**result.output_file_map(), **uploaded_output_files}
|
||||
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
|
||||
values: dict[str, Any] = {
|
||||
"output_files": output_files,
|
||||
"output_size": output_size,
|
||||
"output_mimetypes": output_mimetypes,
|
||||
"end_ts": timezone.now(),
|
||||
}
|
||||
if output_str:
|
||||
values["output_str"] = output_str
|
||||
if status:
|
||||
normalized_status = ArchiveResult.normalize_status(status)
|
||||
if normalized_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
|
||||
normalized_status = result.status
|
||||
values["status"] = normalized_status
|
||||
elif result.status == ArchiveResult.StatusChoices.QUEUED:
|
||||
values["status"] = ArchiveResult.StatusChoices.SUCCEEDED
|
||||
if output_json:
|
||||
values["output_json"] = parsed_output_json
|
||||
if result.safe_update(values):
|
||||
break
|
||||
else:
|
||||
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
|
||||
|
||||
result.save(update_fields=update_fields)
|
||||
if result.status != ArchiveResult.StatusChoices.STARTED:
|
||||
_queue_archiveresult_snapshot_maintenance(result.snapshot)
|
||||
|
||||
@@ -807,7 +800,7 @@ def _snapshots_rss_response(
|
||||
)
|
||||
|
||||
for snapshot in snapshots:
|
||||
archived_url = build_web_url(f"/{snapshot.archive_path_from_db}", request=request)
|
||||
archived_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
tags = [tag.name for tag in snapshot.tags.all()]
|
||||
crawl_user = snapshot.crawl.created_by if snapshot.crawl_id else None
|
||||
description = f"Original URL: {snapshot.url}\nArchived snapshot: {archived_url}"
|
||||
@@ -941,52 +934,47 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
|
||||
created_by=request.user if isinstance(request.user, User) else None,
|
||||
)
|
||||
|
||||
# Browser clients sync metadata and upload capture artifacts immediately
|
||||
# after queueing a URL. The runner holds the crawl lifecycle lock for the
|
||||
# whole crawl, so idempotently posting an already-created snapshot must not
|
||||
# wait behind that potentially long-running archive job.
|
||||
existing_snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
|
||||
if existing_snapshot is not None and (status is None or existing_snapshot.status == status):
|
||||
if data.title is not None and existing_snapshot.title != data.title:
|
||||
Snapshot.objects.filter(pk=existing_snapshot.pk).update(title=data.title, modified_at=timezone.now())
|
||||
existing_snapshot.title = data.title
|
||||
if tags:
|
||||
existing_snapshot.save_tags(tags)
|
||||
setattr(request, "with_archiveresults", False)
|
||||
return existing_snapshot
|
||||
# Browser uploads must not wait behind the runner's crawl-wide lifecycle
|
||||
# lock. The unique insert recovery and CAS update below are the request-side
|
||||
# coordination boundary for this idempotent metadata sync.
|
||||
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
|
||||
if snapshot is None:
|
||||
try:
|
||||
snapshot = Snapshot.objects.create(
|
||||
url=data.url,
|
||||
crawl=crawl,
|
||||
depth=data.depth,
|
||||
title=data.title,
|
||||
timestamp=str(timezone.now().timestamp()),
|
||||
status=status or Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
except IntegrityError:
|
||||
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
|
||||
if snapshot is None:
|
||||
raise
|
||||
|
||||
with crawl_lifecycle_lock(str(crawl.id)):
|
||||
snapshot_defaults = {
|
||||
"depth": data.depth,
|
||||
"title": data.title,
|
||||
"timestamp": str(timezone.now().timestamp()),
|
||||
"status": status or Snapshot.StatusChoices.QUEUED,
|
||||
"retry_at": timezone.now(),
|
||||
}
|
||||
snapshot, _ = Snapshot.objects.get_or_create(
|
||||
url=data.url,
|
||||
crawl=crawl,
|
||||
defaults=snapshot_defaults,
|
||||
for _attempt in range(3):
|
||||
updates: dict[str, Any] = {}
|
||||
if data.title is not None and snapshot.title != data.title:
|
||||
updates["title"] = data.title
|
||||
if status is not None and snapshot.status != status:
|
||||
updates["status"] = status
|
||||
if not updates or snapshot.safe_update(updates, extra_filter={"modified_at": snapshot.modified_at}):
|
||||
break
|
||||
else:
|
||||
raise HttpError(409, "Snapshot changed while metadata was being updated")
|
||||
|
||||
if tags:
|
||||
snapshot.save_tags(
|
||||
tags,
|
||||
created_by=request.user if isinstance(request.user, User) else None,
|
||||
)
|
||||
|
||||
update_fields: list[str] = []
|
||||
if data.title is not None and snapshot.title != data.title:
|
||||
snapshot.title = data.title
|
||||
update_fields.append("title")
|
||||
if status is not None and snapshot.status != status:
|
||||
snapshot.status = status
|
||||
update_fields.append("status")
|
||||
if update_fields:
|
||||
update_fields.append("modified_at")
|
||||
snapshot.save(update_fields=update_fields)
|
||||
|
||||
if tags:
|
||||
snapshot.save_tags(tags)
|
||||
|
||||
try:
|
||||
snapshot.ensure_crawl_symlink()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
snapshot.ensure_crawl_symlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
setattr(request, "with_archiveresults", False)
|
||||
return snapshot
|
||||
@@ -1034,7 +1022,10 @@ def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateS
|
||||
update_fields.append("retry_at")
|
||||
|
||||
if tags is not None:
|
||||
snapshot.save_tags(normalize_tag_list(tags))
|
||||
snapshot.save_tags(
|
||||
normalize_tag_list(tags),
|
||||
created_by=request.user if isinstance(request.user, User) else None,
|
||||
)
|
||||
|
||||
if payload.get("status") == Snapshot.StatusChoices.SEALED:
|
||||
snapshot.cancel()
|
||||
@@ -1421,8 +1412,7 @@ def tags_add_to_snapshot(request: HttpRequest, data: TagSnapshotRequestSchema):
|
||||
else:
|
||||
raise HttpError(400, "Either tag_name or tag_id is required")
|
||||
|
||||
# Add the tag to the snapshot
|
||||
snapshot.tags.add(tag.pk)
|
||||
snapshot.add_tag_ids([tag.pk])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1450,8 +1440,7 @@ def tags_remove_from_snapshot(request: HttpRequest, data: TagSnapshotRequestSche
|
||||
else:
|
||||
raise HttpError(400, "Either tag_name or tag_id is required")
|
||||
|
||||
# Remove the tag from the snapshot
|
||||
snapshot.tags.remove(tag.pk)
|
||||
snapshot.remove_tag_ids([tag.pk])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
__package__ = "archivebox.api"
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -23,7 +22,7 @@ from archivebox.core.permissions import (
|
||||
)
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url
|
||||
|
||||
from .auth import API_AUTH_METHODS, authenticated_user_from_request
|
||||
|
||||
@@ -113,7 +112,10 @@ def get_crawls(request: HttpRequest):
|
||||
|
||||
@router.post("/crawls", response=CrawlSchema, url_name="create_crawl")
|
||||
def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
|
||||
urls = [url.strip() for url in data.urls if url and url.strip()]
|
||||
try:
|
||||
urls = [validate_url(url) for url in data.urls if url and url.strip()]
|
||||
except ValueError as err:
|
||||
raise HttpError(400, str(err)) from err
|
||||
if not urls:
|
||||
raise HttpError(400, "At least one URL is required")
|
||||
if data.max_depth not in (0, 1, 2, 3, 4):
|
||||
@@ -121,9 +123,10 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
|
||||
|
||||
tags = normalize_tag_list(data.tags, data.tags_str)
|
||||
config = dict(data.config or {})
|
||||
config.setdefault("PARSER", "url_list")
|
||||
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
|
||||
crawl = Crawl.objects.create(
|
||||
urls="\n".join(json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in urls),
|
||||
urls="\n".join(urls),
|
||||
max_depth=data.max_depth,
|
||||
tags_str=",".join(tags),
|
||||
label=data.label,
|
||||
|
||||
@@ -2,7 +2,7 @@ __package__ = "archivebox.api"
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from django.db.models import Q
|
||||
@@ -23,6 +23,7 @@ class PersonaBrowserSettingsSchema(Schema):
|
||||
user_agent: str = ""
|
||||
viewport_size: str = ""
|
||||
viewport_device_scale_factor: float | None = None
|
||||
color_scheme: Literal["", "light", "dark"] = ""
|
||||
language: str = ""
|
||||
timezone: str = ""
|
||||
geolocation: dict[str, Any] | None = None
|
||||
@@ -94,6 +95,8 @@ def browser_settings_to_config(extension_persona_id: str, settings: PersonaBrows
|
||||
)
|
||||
if settings.viewport_device_scale_factor is not None:
|
||||
config["BROWSER_DEVICE_SCALE_FACTOR"] = settings.viewport_device_scale_factor
|
||||
if "color_scheme" in settings.model_fields_set:
|
||||
config["BROWSER_COLOR_SCHEME"] = settings.color_scheme
|
||||
if settings.language:
|
||||
config["BROWSER_LANGUAGE"] = settings.language
|
||||
if settings.timezone:
|
||||
@@ -150,12 +153,12 @@ def sync_persona(request: HttpRequest, payload: PersonaSyncSchema):
|
||||
persona.ensure_dirs()
|
||||
|
||||
cookies_written = False
|
||||
if payload.cookies_txt.strip():
|
||||
if "cookies_txt" in payload.model_fields_set:
|
||||
(persona.path / "cookies.txt").write_text(payload.cookies_txt)
|
||||
cookies_written = True
|
||||
|
||||
auth_written = False
|
||||
if payload.auth_json:
|
||||
if "auth_json" in payload.model_fields_set:
|
||||
(persona.path / "auth.json").write_text(json.dumps(payload.auth_json, indent=2, sort_keys=True) + "\n")
|
||||
auth_written = True
|
||||
|
||||
|
||||
@@ -5,12 +5,40 @@ from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import pre_delete
|
||||
from signal_webhooks.handlers import sync_task_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_deferred_fields_for_delete_webhook(sender, instance, using, **kwargs) -> None:
|
||||
"""Materialize deferred fields while DELETE webhook rows still exist.
|
||||
|
||||
Admin lists defer large fields, but signal_webhooks serializes all concrete
|
||||
fields in post_delete. Loading one then would query an already-deleted row
|
||||
and drop the event. Hydrate only deferred fields on webhook-enabled models
|
||||
here, preserving the normal list-query optimization.
|
||||
"""
|
||||
from signal_webhooks.handlers import find_hook_handler
|
||||
from signal_webhooks.utils import reference_for_model
|
||||
|
||||
ref = reference_for_model(type(instance))
|
||||
deferred_fields = instance.get_deferred_fields()
|
||||
if not deferred_fields or find_hook_handler(ref, "DELETE") is None:
|
||||
return
|
||||
|
||||
instance.refresh_from_db(using=using, fields=deferred_fields)
|
||||
|
||||
|
||||
def register_delete_webhook_field_loader() -> None:
|
||||
pre_delete.connect(
|
||||
load_deferred_fields_for_delete_webhook,
|
||||
dispatch_uid="archivebox.load_deferred_fields_for_delete_webhook",
|
||||
weak=False,
|
||||
)
|
||||
|
||||
|
||||
def warning_error_handler(hook: Any, error: Exception | None) -> None:
|
||||
if error is not None:
|
||||
logger.warning("Outbound webhook %r failed: %s", hook.name, error)
|
||||
|
||||
@@ -155,13 +155,10 @@ def cli(ctx, help=False):
|
||||
wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
|
||||
if not wants_help and (subcommand in ArchiveBoxGroup.archive_commands or subcommand in ArchiveBoxGroup.model_commands):
|
||||
try:
|
||||
if subcommand == "server":
|
||||
run_in_debug = "--reload" in sys.argv or os.environ.get("DEBUG") in ("1", "true", "True", "TRUE", "yes")
|
||||
if run_in_debug:
|
||||
os.environ["ARCHIVEBOX_RUNSERVER"] = "1"
|
||||
if "--reload" in sys.argv:
|
||||
os.environ["ARCHIVEBOX_AUTORELOAD"] = "1"
|
||||
|
||||
# RUNSERVER_WORKER sets the web-worker markers on its own child.
|
||||
# Setting them on the server parent leaks them through supervisord
|
||||
# into runners waiting for takeover, which then register themselves
|
||||
# as web workers and trigger spurious runner_watch reloads.
|
||||
from archivebox.config.django import setup_django
|
||||
from archivebox.misc.checks import check_data_folder, check_migrations
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ __command__ = "archivebox add"
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
@@ -71,17 +71,7 @@ def add(
|
||||
created_by_id: int | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> tuple[Crawl, QuerySet[Snapshot]]:
|
||||
"""Add a new URL or list of URLs to your archive.
|
||||
|
||||
The flow is:
|
||||
1. Save URLs to sources file
|
||||
2. Create Crawl with URLs and max_depth
|
||||
3. Crawl runner creates Snapshots from Crawl URLs (depth=0)
|
||||
4. Crawl runner runs parser extractors on root snapshots
|
||||
5. Parser extractors output to urls.jsonl
|
||||
6. URLs are added to Crawl.urls and child Snapshots are created
|
||||
7. Repeat until max_depth is reached
|
||||
"""
|
||||
"""Add a URL list or imported URL document to a new Crawl."""
|
||||
|
||||
from rich import print
|
||||
|
||||
@@ -125,12 +115,12 @@ def add(
|
||||
created_by_id = created_by_id or get_or_create_system_user_pk()
|
||||
started_at = timezone.now()
|
||||
|
||||
use_internal_input_root = isinstance(urls, str)
|
||||
source_text = (
|
||||
urls
|
||||
if use_internal_input_root
|
||||
else "\n".join(json.dumps({"type": "CrawlSeed", "url": str(url), "depth": 0}, separators=(",", ":")) for url in urls)
|
||||
)
|
||||
if isinstance(urls, str):
|
||||
source_text = urls
|
||||
else:
|
||||
from archivebox.misc.util import validate_url
|
||||
|
||||
source_text = "\n".join(validate_url(str(url)) for url in urls)
|
||||
|
||||
# 2. Create a new Crawl with inline URLs
|
||||
# Foreground add must claim runner ownership before publishing runnable
|
||||
@@ -187,12 +177,7 @@ def add(
|
||||
try:
|
||||
crawl = Crawl.objects.create(
|
||||
urls=source_text,
|
||||
# Stdin/import text gets an extra hop because the synthetic
|
||||
# archivebox://internal root lives at depth 0 and parser-discovered
|
||||
# URLs land at depth 1; direct URL args become the depth=0 input
|
||||
# snapshots themselves so --depth=N matches the deepest hop the user
|
||||
# asked for.
|
||||
max_depth=depth + 1 if use_internal_input_root else depth,
|
||||
max_depth=depth,
|
||||
tags_str=tag,
|
||||
persona_id=persona_obj.id,
|
||||
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
|
||||
@@ -213,9 +198,8 @@ def add(
|
||||
print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]")
|
||||
print(f" [dim]First URL: {first_url}[/dim]")
|
||||
|
||||
# 3. The CrawlMachine will create Snapshots from all URLs when started
|
||||
# Parser extractors run on snapshots and discover more URLs
|
||||
# Discovered URLs become child Snapshots (depth+1)
|
||||
# The runner parses Crawl.urls after claiming the Crawl, then persists the
|
||||
# resulting depth-0 Snapshot facts. Recursive discoveries start at depth 1.
|
||||
|
||||
if index_only:
|
||||
print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
|
||||
@@ -244,6 +228,10 @@ def add(
|
||||
|
||||
assert command is not None
|
||||
exit_code = 0
|
||||
interactive_interrupts = sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
|
||||
|
||||
def interrupt_hook() -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def crawl_is_complete() -> bool:
|
||||
crawl.refresh_from_db(fields=["status"])
|
||||
@@ -251,7 +239,13 @@ def add(
|
||||
|
||||
try:
|
||||
try:
|
||||
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
first_signal_message=None,
|
||||
interrupt_handlers={signal.SIGINT: interrupt_hook},
|
||||
),
|
||||
foreground_parent_watchdog(),
|
||||
):
|
||||
while True:
|
||||
standby = standby_until_foreground_runner_needed(
|
||||
command,
|
||||
@@ -263,7 +257,7 @@ def add(
|
||||
exit_code = run_runner_worker(
|
||||
["--crawl-id", str(crawl.id)],
|
||||
name=f"worker_runner_add_{os.getpid()}",
|
||||
interactive_interrupts=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
config=get_config(crawl=crawl),
|
||||
)
|
||||
crawl.refresh_from_db(fields=["status", "retry_at"])
|
||||
@@ -328,9 +322,13 @@ def add(
|
||||
|
||||
admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id}/change/", config=config)
|
||||
|
||||
print("\n[bold]crawl output saved to:[/bold]")
|
||||
print(f" {rel_output_str}")
|
||||
print(f" {admin_url}")
|
||||
if snapshots_count:
|
||||
print("\n[bold]snapshot output saved to:[/bold]")
|
||||
for snapshot in crawl.snapshot_set.order_by("created_at").iterator():
|
||||
click.echo(f" {snapshot.output_dir}")
|
||||
print("\n[bold]crawl logs and setup:[/bold]")
|
||||
click.echo(f" {rel_output_str}")
|
||||
click.echo(f" {admin_url}")
|
||||
print(f"\n[bold]total urls snapshotted:[/bold] {snapshots_count}")
|
||||
print(f"[bold]total size:[/bold] {total_size}")
|
||||
print(f"[bold]total time:[/bold] {duration_str}")
|
||||
@@ -405,10 +403,6 @@ def main(**kwargs):
|
||||
raise click.BadParameter(str(err), param_hint="--snapshot-max-size") from err
|
||||
if kwargs.get("crawl_max_concurrent_snapshots") is not None and int(kwargs["crawl_max_concurrent_snapshots"]) < 1:
|
||||
raise click.BadParameter("crawl_max_concurrent_snapshots must be at least 1.", param_hint="--crawl-max-concurrent-snapshots")
|
||||
if not kwargs.get("index_only"):
|
||||
from archivebox.workers.supervisord_util import require_crawl_memory
|
||||
|
||||
require_crawl_memory()
|
||||
|
||||
# Translate --only-new/--no-only-new into a crawl config override.
|
||||
# add() takes config overrides as a dict; no per-flag kwargs.
|
||||
@@ -421,13 +415,13 @@ def main(**kwargs):
|
||||
if only_new is not None:
|
||||
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
|
||||
if extract:
|
||||
from abx_dl.models import discover_plugins, plugins_matching_output
|
||||
from archivebox.plugins.discovery import get_plugin_catalog
|
||||
|
||||
all_plugins = discover_plugins()
|
||||
catalog = get_plugin_catalog()
|
||||
tokens = [token.strip() for token in extract.split(",") if token.strip()]
|
||||
plugin_names = {name.lower(): name for name in all_plugins}
|
||||
plugin_names = {name.lower(): name for name in catalog}
|
||||
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
|
||||
selected += plugins_matching_output(all_plugins, tokens)
|
||||
selected += catalog.matching_output(tokens)
|
||||
if not selected:
|
||||
raise click.UsageError(f"No plugins found matching extract types: {extract}")
|
||||
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
|
||||
|
||||
@@ -6,13 +6,13 @@ archivebox archiveresult <action> [args...] [--filters]
|
||||
Manage ArchiveResult records (plugin extraction results).
|
||||
|
||||
Actions:
|
||||
create - Create ArchiveResults for Snapshots (queue extractions)
|
||||
create - Emit plugin extraction request records for Snapshots
|
||||
list - List ArchiveResults as JSONL (with optional filters)
|
||||
update - Update ArchiveResults from stdin JSONL
|
||||
delete - Delete ArchiveResults from stdin JSONL
|
||||
|
||||
Examples:
|
||||
# Create ArchiveResults for snapshots (queue for extraction)
|
||||
# Emit extraction requests; `archivebox run` schedules their parent snapshots
|
||||
archivebox snapshot list --status=queued | archivebox archiveresult create
|
||||
archivebox archiveresult create --plugin=screenshot --snapshot-id=<uuid>
|
||||
|
||||
@@ -20,9 +20,6 @@ Examples:
|
||||
archivebox archiveresult list --status=failed
|
||||
archivebox archiveresult list --plugin=screenshot --status=succeeded
|
||||
|
||||
# Update (reset failed extractions to queued)
|
||||
archivebox archiveresult list --status=failed | archivebox archiveresult update --status=queued
|
||||
|
||||
# Delete
|
||||
archivebox archiveresult list --plugin=singlefile | archivebox archiveresult delete --yes
|
||||
|
||||
@@ -67,7 +64,7 @@ def create_archiveresults(
|
||||
Reads Snapshot records from stdin and emits ArchiveResult request JSONL.
|
||||
Pass-through: Non-Snapshot/ArchiveResult records are output unchanged.
|
||||
If --plugin is specified, only emits requests for that plugin.
|
||||
Otherwise, emits one request for each enabled Snapshot plugin.
|
||||
Otherwise, emits requests for all enabled snapshot hooks.
|
||||
|
||||
Exit codes:
|
||||
0: Success
|
||||
@@ -144,19 +141,20 @@ def create_archiveresults(
|
||||
|
||||
created_count = 0
|
||||
for snapshot in snapshots:
|
||||
if plugin:
|
||||
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
|
||||
hooks = [
|
||||
hook
|
||||
for hook in discover_hooks("Snapshot", filter_disabled=not plugin, config=config)
|
||||
if not plugin or hook.parent.name == plugin
|
||||
]
|
||||
for hook_path in hooks:
|
||||
hook_name = hook_path.stem
|
||||
plugin_name = hook_path.parent.name
|
||||
if not is_tty:
|
||||
write_record(build_archiveresult_request(snapshot.id, plugin, status=status))
|
||||
write_record(build_archiveresult_request(snapshot.id, plugin_name, hook_name=hook_name, status=status))
|
||||
created_count += 1
|
||||
else:
|
||||
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
|
||||
hooks = discover_hooks("Snapshot", filter_disabled=True, config=config)
|
||||
for plugin_name in dict.fromkeys(hook_path.parent.name for hook_path in hooks):
|
||||
if not is_tty:
|
||||
write_record(build_archiveresult_request(snapshot.id, plugin_name, status=status))
|
||||
created_count += 1
|
||||
|
||||
rprint(f"[green]Created {created_count} archive result request records[/green]", file=sys.stderr)
|
||||
rprint(f"[green]Created {created_count} extraction request records[/green]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -254,11 +252,9 @@ def update_archiveresults(
|
||||
|
||||
# Apply updates from CLI flags
|
||||
if status:
|
||||
if status == ArchiveResult.StatusChoices.QUEUED:
|
||||
result.reset_for_retry()
|
||||
else:
|
||||
result.status = status
|
||||
result.save(update_fields=["status", "modified_at"])
|
||||
result.status = status
|
||||
|
||||
result.save()
|
||||
updated_count += 1
|
||||
|
||||
if not is_tty:
|
||||
@@ -342,7 +338,7 @@ def main():
|
||||
@click.option("--plugin", "-p", help="Plugin name (e.g., screenshot, singlefile)")
|
||||
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
|
||||
def create_cmd(snapshot_id: str | None, plugin: str | None, status: str):
|
||||
"""Create ArchiveResults for Snapshots from stdin JSONL."""
|
||||
"""Emit Snapshot plugin extraction requests as JSONL."""
|
||||
sys.exit(create_archiveresults(snapshot_id=snapshot_id, plugin=plugin, status=status))
|
||||
|
||||
|
||||
|
||||
@@ -1,95 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
archivebox extract [snapshot_ids...] [--plugins=NAMES]
|
||||
|
||||
Run plugins on Snapshots. Accepts snapshot IDs as arguments, from stdin, or via JSONL.
|
||||
|
||||
Input formats:
|
||||
- Snapshot UUIDs (one per line)
|
||||
- JSONL: {"type": "Snapshot", "id": "...", "url": "..."}
|
||||
- JSONL: {"type": "ArchiveResult", "snapshot_id": "...", "plugin": "..."}
|
||||
|
||||
Output (JSONL):
|
||||
{"type": "ArchiveResult", "id": "...", "snapshot_id": "...", "plugin": "...", "status": "..."}
|
||||
|
||||
Examples:
|
||||
# Extract specific snapshot
|
||||
archivebox extract 01234567-89ab-cdef-0123-456789abcdef
|
||||
|
||||
# Pipe from snapshot command
|
||||
archivebox snapshot https://example.com | archivebox extract
|
||||
|
||||
# Run specific plugins only
|
||||
archivebox extract --plugins=screenshot,singlefile 01234567-89ab-cdef-0123-456789abcdef
|
||||
|
||||
# Chain commands
|
||||
archivebox crawl https://example.com | archivebox snapshot | archivebox extract
|
||||
"""
|
||||
"""Run abx-dl snapshot hooks for existing ArchiveBox snapshots."""
|
||||
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox extract"
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from itertools import product
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
import rich_click as click
|
||||
|
||||
|
||||
def process_archiveresult_by_id(archiveresult_id: str) -> int:
|
||||
"""
|
||||
Re-run extraction for a single ArchiveResult by ID.
|
||||
def _resolve_requests(records: list[dict], plugins: str) -> tuple[dict[str, set[str]], set[str]]:
|
||||
"""Resolve CLI records to snapshot-level execution requests.
|
||||
|
||||
ArchiveResults are projected status rows, not queued work items. Re-running
|
||||
a single result means resetting that row and queueing its parent snapshot
|
||||
through the shared crawl runner with the corresponding plugin selected.
|
||||
ArchiveResult input is accepted as a convenient reference to its parent
|
||||
Snapshot and plugin. It is never reset or converted into queued work.
|
||||
"""
|
||||
from rich import print as rprint
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.api.v1_core import _uuid_ref_query
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.misc.jsonl import TYPE_ARCHIVERESULT
|
||||
|
||||
explicit_plugins = {name.strip() for name in plugins.split(",") if name.strip()}
|
||||
requested: dict[str, set[str]] = defaultdict(set)
|
||||
missing: set[str] = set()
|
||||
|
||||
for record in records:
|
||||
record_type = record.get("type")
|
||||
record_id = str(record.get("id") or "")
|
||||
|
||||
if record_type == TYPE_ARCHIVERESULT and record.get("snapshot_id"):
|
||||
requested[str(record["snapshot_id"])].update(explicit_plugins or {str(record.get("plugin") or "")})
|
||||
requested[str(record["snapshot_id"])].discard("")
|
||||
continue
|
||||
|
||||
if record_type == TYPE_ARCHIVERESULT:
|
||||
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
|
||||
if result is not None:
|
||||
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
|
||||
continue
|
||||
|
||||
snapshot_id = str(record.get("snapshot_id") or record_id or "")
|
||||
snapshot = Snapshot.objects.filter(id=snapshot_id).only("id").first() if snapshot_id else None
|
||||
if snapshot is None and record.get("url"):
|
||||
snapshot = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").only("id").first()
|
||||
# Bare UUID CLI arguments are parsed as Snapshot records because the
|
||||
# input layer cannot know which model owns them. Preserve the public
|
||||
# convenience of passing an ArchiveResult ID by trying that reference
|
||||
# only after the Snapshot lookup misses.
|
||||
if snapshot is None and record_id and not record.get("url"):
|
||||
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
|
||||
if result is not None:
|
||||
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
|
||||
continue
|
||||
if snapshot is None:
|
||||
missing.add(snapshot_id or str(record.get("url") or ""))
|
||||
continue
|
||||
requested[str(snapshot.id)].update(explicit_plugins)
|
||||
|
||||
return requested, missing
|
||||
|
||||
|
||||
def _run_snapshot_requests(requested: dict[str, set[str]], *, wait: bool, show_progress: bool) -> int:
|
||||
from django.utils import timezone
|
||||
from rich import print as rprint
|
||||
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.services.runner import run_crawl
|
||||
|
||||
try:
|
||||
archiveresult = ArchiveResult.objects.get(_uuid_ref_query("id", archiveresult_id))
|
||||
except ArchiveResult.DoesNotExist:
|
||||
rprint(f"[red]ArchiveResult {archiveresult_id} not found[/red]", file=sys.stderr)
|
||||
snapshots = {str(snapshot.id): snapshot for snapshot in Snapshot.objects.filter(id__in=requested).select_related("crawl")}
|
||||
if not snapshots:
|
||||
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
rprint(f"[blue]Extracting {archiveresult.plugin} for {archiveresult.snapshot.url}[/blue]", file=sys.stderr)
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
|
||||
try:
|
||||
archiveresult.reset_for_retry()
|
||||
snapshot = archiveresult.snapshot
|
||||
snapshot.queue_for_extraction()
|
||||
crawl = snapshot.crawl
|
||||
if not crawl.claim_processing_lock(lock_seconds=10):
|
||||
rprint(
|
||||
f"[yellow]Crawl {crawl.id} is already owned by another runner[/yellow]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
for snapshot_id, plugin_names in requested.items():
|
||||
snapshot = snapshots.get(snapshot_id)
|
||||
if snapshot is not None and not plugin_names:
|
||||
plugin_names.update(get_enabled_plugins(config=get_config(crawl=snapshot.crawl, snapshot=snapshot)))
|
||||
|
||||
run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
|
||||
archiveresult.refresh_from_db()
|
||||
if wait and any("search_backend_sonic" in plugin_names for plugin_names in requested.values()):
|
||||
from archivebox.core.takeover_util import ensure_daemon_stack
|
||||
|
||||
if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
print(f"[green]Extraction succeeded: {archiveresult.output_str}[/green]")
|
||||
return 0
|
||||
elif archiveresult.status == ArchiveResult.StatusChoices.NORESULTS:
|
||||
print(f"[dim]Extraction completed with no results: {archiveresult.output_str}[/dim]")
|
||||
return 0
|
||||
elif archiveresult.status == ArchiveResult.StatusChoices.FAILED:
|
||||
print(f"[red]Extraction failed: {archiveresult.output_str}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
# Still in progress or backoff - not a failure
|
||||
print(f"[yellow]Extraction status: {archiveresult.status}[/yellow]")
|
||||
return 0
|
||||
with redirect_stdout(sys.stderr):
|
||||
ensure_daemon_stack(reason="Sonic snapshot indexing")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[red]Extraction error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
# Explicit extraction resumes open/paused snapshots at the snapshot level.
|
||||
# Sealed snapshots stay sealed during targeted maintenance backfills.
|
||||
if wait:
|
||||
for snapshot in snapshots.values():
|
||||
if snapshot.status != Snapshot.StatusChoices.SEALED:
|
||||
snapshot.update_and_requeue(
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
|
||||
if not wait:
|
||||
for snapshot_id, plugin_names in requested.items():
|
||||
snapshot = snapshots.get(snapshot_id)
|
||||
if snapshot is None:
|
||||
continue
|
||||
if plugin_names:
|
||||
snapshot.schedule_plugin_run(plugin_names, when=timezone.now())
|
||||
elif snapshot.status == Snapshot.StatusChoices.SEALED:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
else:
|
||||
snapshot.update_and_requeue(
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=timezone.now(),
|
||||
)
|
||||
if show_progress:
|
||||
rprint(f"[blue]Queued {len(snapshots)} snapshots for extraction[/blue]", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
grouped: dict[tuple[str, tuple[str, ...]], list[str]] = defaultdict(list)
|
||||
for snapshot_id, plugin_names in requested.items():
|
||||
snapshot = snapshots.get(snapshot_id)
|
||||
if snapshot is not None:
|
||||
grouped[(str(snapshot.crawl_id), tuple(sorted(plugin_names)))].append(snapshot_id)
|
||||
|
||||
for (crawl_id, plugin_names), snapshot_ids in grouped.items():
|
||||
run_crawl(
|
||||
crawl_id,
|
||||
snapshot_ids=sorted(snapshot_ids),
|
||||
selected_plugins=list(plugin_names) or None,
|
||||
show_progress=show_progress,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def run_plugins(
|
||||
@@ -99,356 +139,64 @@ def run_plugins(
|
||||
wait: bool = True,
|
||||
emit_results: bool = True,
|
||||
show_progress: bool = True,
|
||||
preserve_queued: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Run plugins on Snapshots from input.
|
||||
|
||||
Reads Snapshot IDs or JSONL from args/stdin, runs plugins, outputs JSONL.
|
||||
|
||||
Exit codes:
|
||||
0: Success
|
||||
1: Failure
|
||||
"""
|
||||
"""Execute selected plugins through the snapshot-level runner."""
|
||||
from rich import print as rprint
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.misc.jsonl import (
|
||||
read_args_or_stdin,
|
||||
write_record,
|
||||
TYPE_SNAPSHOT,
|
||||
TYPE_ARCHIVERESULT,
|
||||
)
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.services.runner import run_crawl
|
||||
from archivebox.misc.jsonl import read_args_or_stdin, write_record
|
||||
|
||||
is_tty = sys.stdout.isatty()
|
||||
|
||||
# Parse comma-separated plugins list once (reused in creation and filtering)
|
||||
plugins_list = [p.strip() for p in plugins.split(",") if p.strip()] if plugins else []
|
||||
|
||||
# Parse stdin/args exactly once per CLI invocation.
|
||||
# `main()` may already have consumed stdin to distinguish Snapshot input from
|
||||
# ArchiveResult IDs; if so, it must pass the parsed records through here
|
||||
# instead of asking this helper to reread an already-drained pipe.
|
||||
if records is None:
|
||||
records = list(read_args_or_stdin(args))
|
||||
|
||||
if not records:
|
||||
rprint("[yellow]No snapshots provided. Pass snapshot IDs as arguments or via stdin.[/yellow]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Gather snapshot IDs and optional plugin constraints to process
|
||||
snapshot_ids = set()
|
||||
requested_plugins_by_snapshot: dict[str, set[str]] = defaultdict(set)
|
||||
for record in records:
|
||||
record_type = record.get("type")
|
||||
|
||||
if record_type == TYPE_SNAPSHOT:
|
||||
snapshot_id = record.get("id")
|
||||
if snapshot_id:
|
||||
snapshot_ids.add(str(snapshot_id))
|
||||
elif record.get("url"):
|
||||
# Look up by URL (get most recent if multiple exist)
|
||||
snap = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").first()
|
||||
if snap:
|
||||
snapshot_ids.add(str(snap.id))
|
||||
else:
|
||||
rprint(f"[yellow]Snapshot not found for URL: {record['url']}[/yellow]", file=sys.stderr)
|
||||
|
||||
elif record_type == TYPE_ARCHIVERESULT:
|
||||
snapshot_id = record.get("snapshot_id")
|
||||
if snapshot_id:
|
||||
snapshot_ids.add(str(snapshot_id))
|
||||
plugin_name = record.get("plugin")
|
||||
if plugin_name and not plugins_list:
|
||||
requested_plugins_by_snapshot[str(snapshot_id)].add(str(plugin_name))
|
||||
|
||||
elif "id" in record:
|
||||
# Assume it's a snapshot ID
|
||||
snapshot_ids.add(str(record["id"]))
|
||||
|
||||
if not snapshot_ids:
|
||||
rprint("[red]No valid snapshot IDs found in input[/red]", file=sys.stderr)
|
||||
requested, missing = _resolve_requests(records, plugins)
|
||||
for value in sorted(missing):
|
||||
rprint(f"[yellow]Snapshot or ArchiveResult not found: {value}[/yellow]", file=sys.stderr)
|
||||
if not requested:
|
||||
return 1
|
||||
|
||||
existing_snapshots = list(Snapshot.objects.filter(id__in=snapshot_ids).values_list("id", "crawl_id"))
|
||||
existing_snapshot_ids = {str(snapshot_id) for snapshot_id, _crawl_id in existing_snapshots}
|
||||
existing_crawl_ids = {str(crawl_id) for _snapshot_id, crawl_id in existing_snapshots}
|
||||
missing_snapshot_ids = sorted(str(snapshot_id) for snapshot_id in snapshot_ids - existing_snapshot_ids)
|
||||
for snapshot_id in missing_snapshot_ids:
|
||||
rprint(f"[yellow]Snapshot {snapshot_id} not found[/yellow]", file=sys.stderr)
|
||||
|
||||
# Queue only the target plugin rows. Bulk updates keep large reindex runs
|
||||
# from doing one SELECT+UPDATE per snapshot/plugin before hooks even start.
|
||||
requested_pairs: set[tuple[str, str]] = set()
|
||||
if plugins_list:
|
||||
requested_pairs.update((snapshot_id, plugin_name) for snapshot_id, plugin_name in product(existing_snapshot_ids, plugins_list))
|
||||
else:
|
||||
requested_pairs.update(
|
||||
(snapshot_id, plugin_name)
|
||||
for snapshot_id, plugin_names in requested_plugins_by_snapshot.items()
|
||||
if snapshot_id in existing_snapshot_ids
|
||||
for plugin_name in plugin_names
|
||||
)
|
||||
queued_rows: set[tuple[str, str]] = set()
|
||||
if preserve_queued and requested_pairs:
|
||||
queued_rows = {
|
||||
(str(snapshot_id), plugin_name)
|
||||
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=existing_snapshot_ids,
|
||||
plugin__in={plugin_name for _snapshot_id, plugin_name in requested_pairs},
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
).values_list("snapshot_id", "plugin")
|
||||
}
|
||||
rows_to_queue = requested_pairs - queued_rows
|
||||
|
||||
reset_fields = {
|
||||
"status": ArchiveResult.StatusChoices.QUEUED,
|
||||
"hook_name": "",
|
||||
"output_str": "",
|
||||
"output_json": None,
|
||||
"output_files": {},
|
||||
"output_size": 0,
|
||||
"output_mimetypes": "",
|
||||
"start_ts": None,
|
||||
"end_ts": None,
|
||||
"modified_at": timezone.now(),
|
||||
}
|
||||
if rows_to_queue and plugins_list:
|
||||
rows_to_reset_by_plugin: dict[str, set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_name in rows_to_queue:
|
||||
rows_to_reset_by_plugin[plugin_name].add(snapshot_id)
|
||||
for plugin_name, plugin_snapshot_ids in rows_to_reset_by_plugin.items():
|
||||
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
|
||||
**reset_fields,
|
||||
)
|
||||
elif rows_to_queue and requested_plugins_by_snapshot:
|
||||
snapshot_ids_by_plugin: dict[str, set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_name in rows_to_queue:
|
||||
snapshot_ids_by_plugin[plugin_name].add(snapshot_id)
|
||||
for plugin_name, plugin_snapshot_ids in snapshot_ids_by_plugin.items():
|
||||
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
|
||||
**reset_fields,
|
||||
)
|
||||
existing_rows = (
|
||||
{
|
||||
(str(snapshot_id), plugin_name)
|
||||
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=existing_snapshot_ids,
|
||||
plugin__in={plugin_name for _snapshot_id, plugin_name in rows_to_queue},
|
||||
).values_list("snapshot_id", "plugin")
|
||||
}
|
||||
if rows_to_queue
|
||||
else set()
|
||||
)
|
||||
missing_rows = rows_to_queue - existing_rows
|
||||
if missing_rows:
|
||||
ArchiveResult.objects.bulk_create(
|
||||
[
|
||||
ArchiveResult(
|
||||
snapshot_id=snapshot_id,
|
||||
plugin=plugin_name,
|
||||
hook_name="",
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
)
|
||||
for snapshot_id, plugin_name in sorted(missing_rows)
|
||||
],
|
||||
batch_size=500,
|
||||
)
|
||||
|
||||
processed_count = len(existing_snapshot_ids)
|
||||
queue_at = timezone.now()
|
||||
if existing_snapshot_ids:
|
||||
if requested_pairs:
|
||||
# Search indexing on a sealed Snapshot is the only targeted plugin
|
||||
# allowed to bypass the normal lifecycle. Every other requested
|
||||
# plugin requeues its Snapshot through the unified state machine.
|
||||
affected_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in rows_to_queue}
|
||||
if preserve_queued and queued_rows:
|
||||
queued_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in queued_rows}
|
||||
affected_snapshot_ids.update(
|
||||
str(snapshot_id)
|
||||
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids)
|
||||
.filter(retry_at__gt=queue_at)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
affected_snapshot_ids.update(
|
||||
str(snapshot_id)
|
||||
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids, retry_at__isnull=True).values_list(
|
||||
"id",
|
||||
flat=True,
|
||||
)
|
||||
)
|
||||
requested_plugins_by_id: dict[str, set[str]] = defaultdict(set)
|
||||
for snapshot_id, plugin_name in requested_pairs:
|
||||
requested_plugins_by_id[snapshot_id].add(plugin_name)
|
||||
for snapshot in Snapshot.objects.filter(id__in=affected_snapshot_ids).only("id", "status", "modified_at"):
|
||||
# Guard the read-time status so we never bump retry_at on a
|
||||
# row that's been re-queued / started by a concurrent runner.
|
||||
plugin_names = requested_plugins_by_id.get(str(snapshot.id), set())
|
||||
sealed_search_backfill = (
|
||||
snapshot.status == Snapshot.StatusChoices.SEALED
|
||||
and plugin_names
|
||||
and all(plugin_name.startswith("search_backend_") for plugin_name in plugin_names)
|
||||
)
|
||||
if sealed_search_backfill:
|
||||
snapshot.safe_update(
|
||||
{"retry_at": queue_at, "modified_at": queue_at},
|
||||
refresh=False,
|
||||
extra_filter={"status": snapshot.status},
|
||||
)
|
||||
else:
|
||||
snapshot.update_and_requeue(
|
||||
status=Snapshot.StatusChoices.QUEUED,
|
||||
retry_at=queue_at,
|
||||
current_step=0,
|
||||
)
|
||||
else:
|
||||
# No plugin rows were requested, so this is a full snapshot retry.
|
||||
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "retry_at", "modified_at"):
|
||||
snapshot.safe_update(
|
||||
{
|
||||
"status": Snapshot.StatusChoices.QUEUED,
|
||||
"retry_at": queue_at,
|
||||
"current_step": 0,
|
||||
"modified_at": queue_at,
|
||||
},
|
||||
refresh=False,
|
||||
extra_filter={"status": snapshot.status},
|
||||
)
|
||||
if existing_crawl_ids and not requested_pairs:
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
for crawl in Crawl.objects.filter(id__in=existing_crawl_ids).only("id", "status", "retry_at", "modified_at"):
|
||||
update_fields = {
|
||||
"retry_at": queue_at,
|
||||
"modified_at": queue_at,
|
||||
}
|
||||
if crawl.status != Crawl.StatusChoices.STARTED:
|
||||
update_fields["status"] = Crawl.StatusChoices.QUEUED
|
||||
crawl.safe_update(
|
||||
update_fields,
|
||||
refresh=False,
|
||||
extra_filter={"status": crawl.status},
|
||||
)
|
||||
|
||||
if processed_count == 0:
|
||||
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if show_progress:
|
||||
rprint(f"[blue]Queued {processed_count} snapshots for extraction[/blue]", file=sys.stderr)
|
||||
|
||||
# Run orchestrator if --wait (default)
|
||||
if wait:
|
||||
if show_progress:
|
||||
rprint("[blue]Running plugins...[/blue]", file=sys.stderr)
|
||||
snapshot_ids_by_crawl: dict[str, set[str]] = defaultdict(set)
|
||||
for snapshot_id, crawl_id in existing_snapshots:
|
||||
snapshot_ids_by_crawl[str(crawl_id)].add(str(snapshot_id))
|
||||
|
||||
for crawl_id, crawl_snapshot_ids in snapshot_ids_by_crawl.items():
|
||||
from archivebox.crawls.models import Crawl
|
||||
|
||||
crawl = Crawl.objects.get(id=crawl_id)
|
||||
if not crawl.claim_processing_lock(lock_seconds=10):
|
||||
rprint(
|
||||
f"[yellow]Crawl {crawl_id} is already owned by another runner[/yellow]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
selected_plugins = (
|
||||
plugins_list
|
||||
or sorted(
|
||||
{plugin for snapshot_id in crawl_snapshot_ids for plugin in requested_plugins_by_snapshot.get(str(snapshot_id), set())},
|
||||
)
|
||||
or None
|
||||
)
|
||||
run_crawl(
|
||||
crawl_id,
|
||||
snapshot_ids=sorted(crawl_snapshot_ids),
|
||||
selected_plugins=selected_plugins,
|
||||
show_progress=show_progress,
|
||||
)
|
||||
|
||||
if not emit_results:
|
||||
return 0
|
||||
|
||||
# Output results as JSONL (when piped) or human-readable (when TTY)
|
||||
for snapshot_id in snapshot_ids:
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(id=snapshot_id)
|
||||
results = snapshot.archiveresult_set.all()
|
||||
if plugins_list:
|
||||
results = results.filter(plugin__in=plugins_list)
|
||||
|
||||
for result in results:
|
||||
if is_tty:
|
||||
status_color = {
|
||||
"succeeded": "green",
|
||||
"failed": "red",
|
||||
"skipped": "yellow",
|
||||
}.get(result.status, "dim")
|
||||
rprint(
|
||||
f" [{status_color}]{result.status}[/{status_color}] {result.plugin} → {result.output_str or ''}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
write_record(result.to_json())
|
||||
except Snapshot.DoesNotExist:
|
||||
continue
|
||||
exit_code = _run_snapshot_requests(requested, wait=wait, show_progress=show_progress)
|
||||
if exit_code or not emit_results:
|
||||
return exit_code
|
||||
|
||||
is_tty = sys.stdout.isatty()
|
||||
for snapshot in Snapshot.objects.filter(id__in=requested):
|
||||
results = snapshot.archiveresult_set.all()
|
||||
requested_plugins = requested[str(snapshot.id)]
|
||||
if requested_plugins:
|
||||
results = results.filter(plugin__in=requested_plugins)
|
||||
for result in results:
|
||||
if is_tty:
|
||||
color = {"succeeded": "green", "failed": "red", "skipped": "yellow"}.get(result.status, "dim")
|
||||
rprint(f" [{color}]{result.status}[/{color}] {result.plugin} → {result.output_str or ''}", file=sys.stderr)
|
||||
else:
|
||||
write_record(result.to_json())
|
||||
return 0
|
||||
|
||||
|
||||
def is_archiveresult_id(value: str) -> bool:
|
||||
"""Check if value resolves to an ArchiveResult ID."""
|
||||
from archivebox.core.models import ArchiveResult
|
||||
from archivebox.api.v1_core import _uuid_ref_query
|
||||
|
||||
return ArchiveResult.objects.filter(_uuid_ref_query("id", value)).exists()
|
||||
def process_archiveresult_by_id(archiveresult_id: str) -> int:
|
||||
"""Re-run the parent Snapshot plugin referenced by an ArchiveResult."""
|
||||
return run_plugins((), records=[{"id": archiveresult_id}], wait=True)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run (e.g., screenshot,singlefile)")
|
||||
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run")
|
||||
@click.option("--wait/--no-wait", default=True, help="Wait for plugins to complete (default: wait)")
|
||||
@click.argument("args", nargs=-1)
|
||||
def main(plugins: str, wait: bool, args: tuple):
|
||||
"""Run plugins on Snapshots, or process existing ArchiveResults by ID"""
|
||||
"""Run plugins on Snapshots; ArchiveResult IDs select their parent plugin."""
|
||||
from archivebox.misc.jsonl import read_args_or_stdin
|
||||
|
||||
# Read all input
|
||||
records = list(read_args_or_stdin(args))
|
||||
|
||||
if not records:
|
||||
from rich import print as rprint
|
||||
|
||||
rprint("[yellow]No Snapshot IDs or ArchiveResult IDs provided. Pass as arguments or via stdin.[/yellow]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Check if input looks like existing ArchiveResult IDs to process
|
||||
all_are_archiveresult_ids = all(is_archiveresult_id(r.get("id") or r.get("url", "")) for r in records)
|
||||
|
||||
if all_are_archiveresult_ids:
|
||||
# Process existing ArchiveResults by ID
|
||||
from rich import print as rprint
|
||||
|
||||
exit_code = 0
|
||||
for record in records:
|
||||
archiveresult_id = record.get("id") or record.get("url")
|
||||
if not isinstance(archiveresult_id, str):
|
||||
rprint(f"[red]Invalid ArchiveResult input: {record}[/red]", file=sys.stderr)
|
||||
exit_code = 1
|
||||
continue
|
||||
result = process_archiveresult_by_id(archiveresult_id)
|
||||
if result != 0:
|
||||
exit_code = result
|
||||
sys.exit(exit_code)
|
||||
else:
|
||||
# Default behavior: run plugins on Snapshots from input
|
||||
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
|
||||
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -68,7 +68,7 @@ def help() -> None:
|
||||
[blue]docker compose run[/blue] [dark_green]archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
|
||||
|
||||
[grey53]# using Docker:[/grey53]
|
||||
[blue]docker run[/blue] -v [light_slate_blue]$PWD:/data[/light_slate_blue] [grey53]-p 8000:8000[/grey53] -it [dark_green]archivebox/archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
|
||||
[blue]docker run[/blue] -v [light_slate_blue]$PWD:/data[/light_slate_blue] [grey53]-p 5797:5797[/grey53] -it [dark_green]archivebox/archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
|
||||
"""
|
||||
if IN_DOCKER
|
||||
else ""
|
||||
@@ -109,7 +109,7 @@ def help() -> None:
|
||||
[dark_green]archivebox[/dark_green] [green]add[/green] --depth=1 "https://example.com/some/page"
|
||||
[dark_green]archivebox[/dark_green] [green]list[/green] --sort=timestamp --csv=timestamp,downloaded_at,url,title
|
||||
[dark_green]archivebox[/dark_green] [green]schedule[/green] --every=day --depth=1 "https://example.com/some/feed.rss"
|
||||
[dark_green]archivebox[/dark_green] [green]server[/green] [blue]0.0.0.0:8000[/blue] [grey53]# Start the Web UI / API server[/grey53]
|
||||
[dark_green]archivebox[/dark_green] [green]server[/green] [blue]0.0.0.0:5797[/blue] [grey53]# Start the Web UI / API server[/grey53]
|
||||
"""
|
||||
print(
|
||||
Panel(
|
||||
|
||||
@@ -134,7 +134,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
print(" > Skipping orphan snapshot import during init.")
|
||||
print()
|
||||
print(" [violet]Hint:[/violet] To import orphaned snapshot directories and reconcile filesystem state, run:")
|
||||
print(" archivebox update")
|
||||
print(" archivebox update --rescan --migrate-only")
|
||||
|
||||
print("\n[green]----------------------------------------------------------------------[/green]")
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@ def _resolve_install_targets(
|
||||
requested_names: tuple[str, ...],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Resolve plugin names and declared binary aliases, leaving unknown tokens raw."""
|
||||
from abx_dl.models import discover_plugins, filter_plugins
|
||||
from archivebox.plugins.discovery import get_plugin_catalog
|
||||
|
||||
plugins = discover_plugins(runtime="archivebox")
|
||||
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in plugins}
|
||||
catalog = get_plugin_catalog()
|
||||
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in catalog}
|
||||
plugin_names_by_binary_alias: dict[str, set[str]] = {}
|
||||
for plugin_name, plugin in plugins.items():
|
||||
for plugin_name, plugin in catalog.items():
|
||||
for required_binary in plugin.config.required_binaries:
|
||||
aliases = {required_binary.name.lower()}
|
||||
template_match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", required_binary.name)
|
||||
@@ -47,14 +47,14 @@ def _resolve_install_targets(
|
||||
else:
|
||||
raw_binary_names.append(name)
|
||||
|
||||
selected_plugins = filter_plugins(plugins, requested_plugins, include_providers=True) if requested_plugins else {}
|
||||
selected_plugins = catalog.select(requested_plugins) if requested_plugins else catalog.select([])
|
||||
selected_plugin_names = {name.lower() for name in selected_plugins}
|
||||
raw_binary_names = [name for name in raw_binary_names if name.lower() not in selected_plugin_names]
|
||||
return sorted(selected_plugins), sorted(set(raw_binary_names))
|
||||
|
||||
|
||||
def _install_raw_binary_names(binary_names: list[str], binproviders: str) -> None:
|
||||
"""Install user-requested standalone binaries through the Binary state machine."""
|
||||
"""Install user-requested standalone binaries through the Binary lifecycle."""
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.machine.models import Binary, Machine, _canonical_binary_name
|
||||
@@ -179,7 +179,7 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
|
||||
run_install(plugin_names=install_plugin_names or None)
|
||||
|
||||
if raw_binary_names:
|
||||
print(f"[+] Running direct binary installer via ArchiveBox binary state machine: {', '.join(raw_binary_names)}")
|
||||
print(f"[+] Running direct binary installer via ArchiveBox binary lifecycle: {', '.join(raw_binary_names)}")
|
||||
print()
|
||||
_install_raw_binary_names(raw_binary_names, binproviders)
|
||||
|
||||
|
||||
@@ -27,11 +27,10 @@ Examples:
|
||||
__package__ = "archivebox.cli"
|
||||
__command__ = "archivebox persona"
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
from collections.abc import Iterable
|
||||
|
||||
import rich_click as click
|
||||
@@ -41,116 +40,6 @@ from archivebox.cli.cli_util import apply_filters
|
||||
from archivebox.personas import importers as persona_importers
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Browser Profile Locations
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_chrome_user_data_dir() -> Path | None:
|
||||
"""Get the default Chrome user data directory for the current platform."""
|
||||
system = platform.system()
|
||||
home = Path.home()
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
candidates = [
|
||||
home / "Library" / "Application Support" / "Google" / "Chrome",
|
||||
home / "Library" / "Application Support" / "Chromium",
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
home / ".config" / "google-chrome",
|
||||
home / ".config" / "chromium",
|
||||
home / ".config" / "chrome",
|
||||
home / "snap" / "chromium" / "common" / "chromium",
|
||||
]
|
||||
elif system == "Windows":
|
||||
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
|
||||
candidates = [
|
||||
local_app_data / "Google" / "Chrome" / "User Data",
|
||||
local_app_data / "Chromium" / "User Data",
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and (candidate / "Default").exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_brave_user_data_dir() -> Path | None:
|
||||
"""Get the default Brave user data directory for the current platform."""
|
||||
system = platform.system()
|
||||
home = Path.home()
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
home / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser",
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
home / ".config" / "BraveSoftware" / "Brave-Browser",
|
||||
]
|
||||
elif system == "Windows":
|
||||
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
|
||||
candidates = [
|
||||
local_app_data / "BraveSoftware" / "Brave-Browser" / "User Data",
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and (candidate / "Default").exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_edge_user_data_dir() -> Path | None:
|
||||
"""Get the default Edge user data directory for the current platform."""
|
||||
system = platform.system()
|
||||
home = Path.home()
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
home / "Library" / "Application Support" / "Microsoft Edge",
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
home / ".config" / "microsoft-edge",
|
||||
home / ".config" / "microsoft-edge-beta",
|
||||
home / ".config" / "microsoft-edge-dev",
|
||||
]
|
||||
elif system == "Windows":
|
||||
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
|
||||
candidates = [
|
||||
local_app_data / "Microsoft" / "Edge" / "User Data",
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists() and (candidate / "Default").exists():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
BROWSER_PROFILE_FINDERS = {
|
||||
"chrome": get_chrome_user_data_dir,
|
||||
"chromium": get_chrome_user_data_dir, # Same locations
|
||||
"brave": get_brave_user_data_dir,
|
||||
"edge": get_edge_user_data_dir,
|
||||
}
|
||||
|
||||
CHROMIUM_BROWSERS = {"chrome", "chromium", "brave", "edge"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cookie Extraction via CDP
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Validation Helpers
|
||||
# =============================================================================
|
||||
@@ -218,6 +107,9 @@ def create_personas(
|
||||
names: Iterable[str],
|
||||
import_from: str | None = None,
|
||||
profile: str | None = None,
|
||||
source: str | None = None,
|
||||
browser_binary: str | None = None,
|
||||
permissions: str | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Create Personas from names.
|
||||
@@ -239,25 +131,26 @@ def create_personas(
|
||||
rprint("[yellow]No persona names provided. Pass names as arguments.[/yellow]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate import source if specified
|
||||
source_profile_dir = None
|
||||
import_source = None
|
||||
if source and not import_from:
|
||||
rprint("[red]--source requires --import (the source browser name).[/red]", file=sys.stderr)
|
||||
return 1
|
||||
if import_from:
|
||||
import_from = import_from.lower()
|
||||
if import_from not in BROWSER_PROFILE_FINDERS:
|
||||
rprint(f"[red]Unknown browser: {import_from}[/red]", file=sys.stderr)
|
||||
rprint(f"[dim]Supported browsers: {', '.join(BROWSER_PROFILE_FINDERS.keys())}[/dim]", file=sys.stderr)
|
||||
try:
|
||||
if source:
|
||||
import_source = persona_importers.resolve_custom_import_source(source, profile_dir=profile)
|
||||
if import_source.browser != "persona":
|
||||
import_source = replace(import_source, browser=import_from.lower(), browser_binary=browser_binary)
|
||||
elif import_from.startswith(("http://", "https://", "ws://", "wss://")) or Path(import_from).expanduser().is_absolute():
|
||||
import_source = persona_importers.resolve_custom_import_source(import_from)
|
||||
else:
|
||||
import_source = persona_importers.resolve_browser_import_source(import_from, profile_dir=profile)
|
||||
if browser_binary:
|
||||
import_source = replace(import_source, browser_binary=browser_binary)
|
||||
except ValueError as err:
|
||||
rprint(f"[red]{err}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
source_profile_dir = BROWSER_PROFILE_FINDERS[import_from]()
|
||||
if not source_profile_dir:
|
||||
rprint(f"[red]Could not find {import_from} profile directory[/red]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
rprint(f"[dim]Found {import_from} profile: {source_profile_dir}[/dim]", file=sys.stderr)
|
||||
|
||||
if profile is None and (source_profile_dir / "Default").exists():
|
||||
profile = "Default"
|
||||
|
||||
created_count = 0
|
||||
for name in name_list:
|
||||
name = name.strip()
|
||||
@@ -270,21 +163,24 @@ def create_personas(
|
||||
rprint(f'[red]Invalid persona name "{name}": {error_msg}[/red]', file=sys.stderr)
|
||||
continue
|
||||
|
||||
persona, created = Persona.objects.get_or_create(name=name)
|
||||
persona, created = Persona.objects.get_or_create(
|
||||
name=name,
|
||||
defaults={"config": {"PERMISSIONS": permissions}} if permissions else {},
|
||||
)
|
||||
|
||||
if created:
|
||||
persona.ensure_dirs()
|
||||
created_count += 1
|
||||
rprint(f"[green]Created persona: {name}[/green]", file=sys.stderr)
|
||||
|
||||
else:
|
||||
rprint(f"[dim]Persona already exists: {name}[/dim]", file=sys.stderr)
|
||||
|
||||
cookies_file = Path(persona.path) / "cookies.txt"
|
||||
|
||||
# Import browser profile if requested
|
||||
if import_from in CHROMIUM_BROWSERS and source_profile_dir is not None:
|
||||
if import_source is not None:
|
||||
try:
|
||||
import_source = persona_importers.resolve_browser_import_source(import_from, profile_dir=profile)
|
||||
rprint(f"[dim]Importing {import_source.display_label}; copying settings and exporting cookies...[/dim]", file=sys.stderr)
|
||||
import_result = persona_importers.import_persona_from_source(
|
||||
persona,
|
||||
import_source,
|
||||
@@ -292,20 +188,25 @@ def create_personas(
|
||||
import_cookies=True,
|
||||
capture_storage=False,
|
||||
)
|
||||
except Exception as e:
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
if created:
|
||||
shutil.rmtree(persona.path, ignore_errors=True)
|
||||
persona.delete()
|
||||
rprint(f"[red]Failed to import browser profile: {e}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if import_result.profile_copied:
|
||||
rprint("[green]Copied browser profile to persona[/green]", file=sys.stderr)
|
||||
if import_result.cookies_imported:
|
||||
rprint(f"[green]Extracted cookies to {cookies_file}[/green]", file=sys.stderr)
|
||||
rprint(f"[green]Extracted {import_result.cookie_count} cookies to {cookies_file}[/green]", file=sys.stderr)
|
||||
elif not import_result.profile_copied:
|
||||
rprint("[yellow]Could not import cookies automatically.[/yellow]", file=sys.stderr)
|
||||
|
||||
for warning in import_result.warnings:
|
||||
rprint(f"[yellow]{warning}[/yellow]", file=sys.stderr)
|
||||
|
||||
if created:
|
||||
rprint(f"[green]Created persona: {name}[/green]", file=sys.stderr)
|
||||
if not is_tty:
|
||||
write_record(
|
||||
{
|
||||
@@ -544,11 +445,42 @@ def main():
|
||||
|
||||
@main.command("create")
|
||||
@click.argument("names", nargs=-1)
|
||||
@click.option("--import", "import_from", help="Import profile from browser (chrome, chromium, brave, edge)")
|
||||
@click.option(
|
||||
"--import",
|
||||
"import_from",
|
||||
help="Import from chrome, chrome-beta, chromium, brave, edge, a persona directory, or a live CDP URL",
|
||||
)
|
||||
@click.option("--source", help="Source browser user-data directory or exact profile path (including Docker mounts)")
|
||||
@click.option(
|
||||
"--browser-binary",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Source Chromium browser executable; required for other Chromium-based browsers",
|
||||
)
|
||||
@click.option("--profile", help="Profile directory name under the user data dir (e.g. Default, Profile 1)")
|
||||
def create_cmd(names: tuple, import_from: str | None, profile: str | None):
|
||||
@click.option(
|
||||
"--permissions",
|
||||
type=click.Choice(["public", "unlisted", "private"]),
|
||||
help="Visibility for a newly created persona (existing personas are unchanged)",
|
||||
)
|
||||
def create_cmd(
|
||||
names: tuple,
|
||||
import_from: str | None,
|
||||
profile: str | None,
|
||||
source: str | None,
|
||||
browser_binary: str | None,
|
||||
permissions: str | None,
|
||||
):
|
||||
"""Create Personas, optionally importing from a browser profile."""
|
||||
sys.exit(create_personas(names, import_from=import_from, profile=profile))
|
||||
sys.exit(
|
||||
create_personas(
|
||||
names,
|
||||
import_from=import_from,
|
||||
profile=profile,
|
||||
source=source,
|
||||
browser_binary=browser_binary,
|
||||
permissions=permissions,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@main.command("list")
|
||||
@@ -560,6 +492,84 @@ def list_cmd(name: str | None, name__icontains: str | None, limit: int | None):
|
||||
sys.exit(list_personas(name=name, name__icontains=name__icontains, limit=limit))
|
||||
|
||||
|
||||
@main.command("open")
|
||||
@click.argument("name", default="Default")
|
||||
def open_cmd(name: str):
|
||||
"""Open a persona's browser, including its imported logins."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import psutil
|
||||
|
||||
from abx_plugins import get_plugins_dir
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
valid, reason = validate_persona_name(name)
|
||||
if not valid:
|
||||
raise click.ClickException(reason)
|
||||
persona = Persona.get_or_create_named(name)
|
||||
persona.ensure_dirs()
|
||||
config = get_config(persona=persona)
|
||||
if os.environ.get("DISPLAY"):
|
||||
from archivebox.config import CONSTANTS
|
||||
|
||||
for process in psutil.process_iter():
|
||||
try:
|
||||
args = process.cmdline()
|
||||
profile = next((arg.split("=", 1)[1] for arg in args if arg.startswith("--user-data-dir=")), None)
|
||||
if (
|
||||
not profile
|
||||
or any(arg.startswith(("--type=", "--headless")) for arg in args)
|
||||
or not Path(profile).resolve().is_relative_to(CONSTANTS.DATA_DIR.resolve())
|
||||
or process.environ().get("DISPLAY") != os.environ["DISPLAY"]
|
||||
):
|
||||
continue
|
||||
rprint("An ArchiveBox browser is already open on this display; waiting for it to close.")
|
||||
process.wait()
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
plugins_dir = Path(get_plugins_dir()).resolve()
|
||||
env = os.environ.copy()
|
||||
dependencies = subprocess.run(
|
||||
[
|
||||
str(Path(sys.executable).with_name("abxpkg")),
|
||||
"env",
|
||||
"--install",
|
||||
"--json",
|
||||
f"--lib={config.ABXPKG_LIB_DIR}",
|
||||
f"--deps-from={plugins_dir / 'chrome' / 'config.json'}:required_binaries",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if dependencies.returncode:
|
||||
raise click.ClickException(dependencies.stderr.strip())
|
||||
env.update({key: str(value) for key, value in json.loads(dependencies.stdout).items()})
|
||||
env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(plugins_dir)
|
||||
env["ACTIVE_PERSONA"] = persona.name
|
||||
env["PERSONAS_DIR"] = str(persona.path.parent)
|
||||
env["NODE_MODULES_DIR"] = str(config.ABXPKG_LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules")
|
||||
payload = config.model_dump(mode="json")
|
||||
payload.update(
|
||||
ACTIVE_PERSONA=persona.name,
|
||||
CHROME_HEADLESS=False,
|
||||
CHROME_USER_DATA_DIR=str(persona.CHROME_USER_DATA_DIR),
|
||||
AUTH_STORAGE_FILE=str(persona.AUTH_STORAGE_FILE or ""),
|
||||
COOKIES_FILE=str(persona.COOKIES_FILE or ""),
|
||||
)
|
||||
script = Path(__file__).parent.parent / "personas" / "open_browser.js"
|
||||
result = subprocess.run(
|
||||
[env.get("NODE_BINARY") or "node", str(script)],
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
@main.command("update")
|
||||
@click.option("--name", "-n", help="Set new name")
|
||||
def update_cmd(name: str | None):
|
||||
|
||||
@@ -48,7 +48,7 @@ def pluginmap(
|
||||
Show the current abx-dl event phases and their associated plugin hooks.
|
||||
|
||||
This command reflects the new bus-driven runtime, not the legacy ArchiveBox
|
||||
state-machine executor. Event names are normalized to hook prefixes by
|
||||
event runtime. Event names are normalized to hook prefixes by
|
||||
stripping a trailing `Event`, then ArchiveBox checks whether any matching
|
||||
`on_{EventFamily}__*` scripts actually exist.
|
||||
"""
|
||||
|
||||
@@ -54,12 +54,9 @@ RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
|
||||
|
||||
|
||||
def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
|
||||
# A supervised `archivebox run --daemon` is intentionally a disposable
|
||||
# child. If it receives SIGINT/SIGTERM directly, exit with the conventional
|
||||
# signal status so supervisord treats it as an unexpected worker death and
|
||||
# restarts only the runner. The parent `archivebox server` owns supervisord
|
||||
# shutdown and must not be pulled down by a killed daemon worker.
|
||||
os._exit(128 + int(sig))
|
||||
# A supervised daemon must exit non-zero so supervisord restarts it, while
|
||||
# still unwinding the active crawl's hooks and persisted process state.
|
||||
raise SystemExit(128 + sig.value)
|
||||
|
||||
|
||||
def process_stdin_records() -> int:
|
||||
@@ -72,8 +69,8 @@ def process_stdin_records() -> int:
|
||||
|
||||
Outputs JSONL of all processed records (for chaining).
|
||||
|
||||
Handles any record type: Crawl, Snapshot, ArchiveResult.
|
||||
Auto-cascades: Crawl → Snapshots → ArchiveResults.
|
||||
Handles Crawl and Snapshot work records. ArchiveResult records are accepted
|
||||
as references to their parent Snapshot and plugin.
|
||||
|
||||
Returns exit code (0 = success, 1 = error).
|
||||
"""
|
||||
@@ -90,6 +87,7 @@ def process_stdin_records() -> int:
|
||||
)
|
||||
from archivebox.base_models.models import get_or_create_system_user_pk
|
||||
from archivebox.core.models import Snapshot, ArchiveResult
|
||||
from archivebox.api.v1_core import _uuid_ref_query
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
from archivebox.machine.models import Binary
|
||||
@@ -156,33 +154,15 @@ def process_stdin_records() -> int:
|
||||
queued_count += 1
|
||||
|
||||
elif record_type == TYPE_ARCHIVERESULT:
|
||||
if record_id:
|
||||
# Existing archiveresult - re-queue
|
||||
try:
|
||||
archiveresult = ArchiveResult.objects.get(id=record_id)
|
||||
except ArchiveResult.DoesNotExist:
|
||||
archiveresult = None
|
||||
else:
|
||||
archiveresult = None
|
||||
|
||||
snapshot_id = record.get("snapshot_id")
|
||||
plugin_name = record.get("plugin")
|
||||
snapshot = None
|
||||
if archiveresult:
|
||||
if archiveresult.status in [
|
||||
ArchiveResult.StatusChoices.FAILED,
|
||||
ArchiveResult.StatusChoices.SKIPPED,
|
||||
ArchiveResult.StatusChoices.NORESULTS,
|
||||
ArchiveResult.StatusChoices.BACKOFF,
|
||||
]:
|
||||
archiveresult.reset_for_retry()
|
||||
snapshot = archiveresult.snapshot
|
||||
plugin_name = plugin_name or archiveresult.plugin
|
||||
elif snapshot_id:
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(id=snapshot_id)
|
||||
except Snapshot.DoesNotExist:
|
||||
snapshot = None
|
||||
snapshot_id = str(record.get("snapshot_id") or "")
|
||||
plugin_name = str(record.get("plugin") or "")
|
||||
archiveresult = None
|
||||
if not snapshot_id and record_id:
|
||||
archiveresult = ArchiveResult.objects.filter(_uuid_ref_query("id", str(record_id))).select_related("snapshot").first()
|
||||
if archiveresult:
|
||||
snapshot_id = str(archiveresult.snapshot_id)
|
||||
plugin_name = plugin_name or archiveresult.plugin
|
||||
snapshot = Snapshot.objects.filter(id=snapshot_id).first() if snapshot_id else None
|
||||
|
||||
if snapshot:
|
||||
snapshot.queue_for_extraction()
|
||||
@@ -190,7 +170,7 @@ def process_stdin_records() -> int:
|
||||
snapshot_ids_by_crawl[crawl_id].add(str(snapshot.id))
|
||||
if plugin_name:
|
||||
plugin_names_by_crawl[crawl_id].add(str(plugin_name))
|
||||
output_records.append(record if not archiveresult else archiveresult.to_json())
|
||||
output_records.append(archiveresult.to_json() if archiveresult else record)
|
||||
queued_count += 1
|
||||
|
||||
elif record_type in {TYPE_BINARYREQUEST, TYPE_BINARY}:
|
||||
@@ -248,7 +228,6 @@ def process_stdin_records() -> int:
|
||||
crawl_id,
|
||||
snapshot_ids=None if crawl_id in full_crawl_ids else sorted(snapshot_ids_by_crawl[crawl_id]),
|
||||
selected_plugins=None if crawl_id in run_all_plugins_for_crawl else sorted(plugin_names_by_crawl[crawl_id]),
|
||||
selected_plugins_are_explicit=False,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -257,7 +236,6 @@ def run_runner(
|
||||
daemon: bool = False,
|
||||
crawl_id: str | None = None,
|
||||
maintenance_only: bool = False,
|
||||
maintenance_batch_size: int | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Run the background runner loop.
|
||||
@@ -304,19 +282,26 @@ def run_runner(
|
||||
# future ownership lease cannot hide it from the unified scheduler.
|
||||
if crawl is not None:
|
||||
crawl.update_and_requeue(retry_at=now, refresh=False)
|
||||
# Only a foreground `archivebox add` gets the interactive "abort current
|
||||
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
|
||||
# orchestrators should shut down immediately and cleanly on the first signal.
|
||||
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
|
||||
# Interactivity follows terminal ownership, not the spelling of the command.
|
||||
# A supervised add worker has pipes and receives terminal intent/answers from
|
||||
# its parent; direct run owns the terminal itself. Daemons and redirected
|
||||
# commands must never wait for a person who cannot answer their prompt.
|
||||
# Trust the explicit terminal-owner flag, not Process.root: when add borrows
|
||||
# a server-owned supervisord, its worker's OS ancestry still leads to server.
|
||||
# Gating on root=ADD silently made the first Ctrl+C abort after takeover.
|
||||
interactive_interrupts = not daemon and (
|
||||
(sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())) or os.environ.get("ARCHIVEBOX_INTERACTIVE_INTERRUPTS") == "1"
|
||||
)
|
||||
if daemon:
|
||||
os.environ[RUNNER_DAEMON_ENV] = "1"
|
||||
|
||||
shutdown_state = None
|
||||
try:
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
on_signal=_exit_daemon_runner_on_signal if daemon else None,
|
||||
raise_on_first_signal=not daemon,
|
||||
),
|
||||
) as shutdown_state,
|
||||
foreground_parent_watchdog(enabled=not daemon),
|
||||
):
|
||||
run_pending_crawls(
|
||||
@@ -324,12 +309,17 @@ def run_runner(
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
**({"maintenance_batch_size": maintenance_batch_size} if maintenance_batch_size else {}),
|
||||
)
|
||||
if daemon and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
if daemon and shutdown_state and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
return 130
|
||||
except asyncio.CancelledError as e:
|
||||
if daemon and shutdown_state and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
if daemon:
|
||||
rprint(f"[red]Runner cancelled unexpectedly: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
@@ -349,13 +339,6 @@ def run_runner(
|
||||
@click.option("--snapshot-id", help="Run one snapshot through its crawl")
|
||||
@click.option("--binary-id", help="Run one queued binary install directly on the bus")
|
||||
@click.option("--maintenance-only", is_flag=True, help="Only process sealed Snapshot maintenance and search-index backfills")
|
||||
@click.option(
|
||||
"--maintenance-batch-size",
|
||||
type=int,
|
||||
default=None,
|
||||
hidden=True,
|
||||
help="Limit queued maintenance snapshots claimed per scheduler tick",
|
||||
)
|
||||
@click.option("--no-stdin", is_flag=True, hidden=True, help="Run the scheduler even when stdin is not a TTY")
|
||||
def main(
|
||||
daemon: bool,
|
||||
@@ -363,7 +346,6 @@ def main(
|
||||
snapshot_id: str,
|
||||
binary_id: str,
|
||||
maintenance_only: bool,
|
||||
maintenance_batch_size: int | None,
|
||||
no_stdin: bool,
|
||||
):
|
||||
"""
|
||||
@@ -388,13 +370,13 @@ def main(
|
||||
),
|
||||
foreground_parent_watchdog(enabled=False),
|
||||
):
|
||||
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
|
||||
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
sys.exit(130)
|
||||
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=not daemon):
|
||||
if snapshot_id:
|
||||
sys.exit(run_snapshot_worker(snapshot_id))
|
||||
sys.exit(run_snapshot_worker(snapshot_id, daemon=daemon))
|
||||
|
||||
if binary_id:
|
||||
try:
|
||||
@@ -414,23 +396,22 @@ def main(
|
||||
if crawl_id:
|
||||
sys.exit(
|
||||
run_runner(
|
||||
daemon=False,
|
||||
daemon=daemon,
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
maintenance_batch_size=maintenance_batch_size,
|
||||
),
|
||||
)
|
||||
|
||||
if maintenance_only:
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=True, maintenance_batch_size=maintenance_batch_size))
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=True))
|
||||
|
||||
if not no_stdin and not sys.stdin.isatty():
|
||||
sys.exit(process_stdin_records())
|
||||
else:
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
|
||||
|
||||
|
||||
def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
def run_snapshot_worker(snapshot_id: str, *, daemon: bool = False) -> int:
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.takeover_util import enter_single_runner_gate
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
@@ -445,6 +426,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
return 0
|
||||
|
||||
snapshot = None
|
||||
interactive_interrupts = not daemon and sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
for _ in range(10):
|
||||
@@ -453,7 +435,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
elif snapshot.retry_at > timezone.now():
|
||||
break
|
||||
if not run_due_snapshot(snapshot, lock_seconds=60):
|
||||
if not run_due_snapshot(snapshot, lock_seconds=60, interactive_interrupts=interactive_interrupts):
|
||||
break
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
@@ -466,7 +448,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
return 130
|
||||
except Exception as e:
|
||||
rprint(f"[red]Runner error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
import traceback
|
||||
|
||||
@@ -37,6 +37,7 @@ def schedule(
|
||||
"created_schedule_ids": [],
|
||||
"disabled_count": 0,
|
||||
"run_all_enqueued": 0,
|
||||
"run_all_maintained": 0,
|
||||
"active_schedule_ids": [],
|
||||
}
|
||||
|
||||
@@ -57,12 +58,12 @@ def schedule(
|
||||
|
||||
created_by_id = get_or_create_system_user_pk()
|
||||
is_update_schedule = not import_path
|
||||
template_urls = import_path or "archivebox://update"
|
||||
template_urls = import_path or ""
|
||||
template_label = (f"Scheduled import: {template_urls}" if import_path else "Scheduled ArchiveBox update")[:64]
|
||||
template_notes = (
|
||||
f"Created by archivebox schedule for {template_urls}"
|
||||
if import_path
|
||||
else "Created by archivebox schedule to queue recurring archivebox://update maintenance crawls."
|
||||
else "Created by archivebox schedule to run recurring ArchiveBox maintenance."
|
||||
)
|
||||
|
||||
template = Crawl.objects.create(
|
||||
@@ -76,7 +77,6 @@ def schedule(
|
||||
retry_at=None,
|
||||
config={
|
||||
"DEPTH": 0 if is_update_schedule else depth,
|
||||
"SCHEDULE_KIND": "update" if is_update_schedule else "crawl",
|
||||
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) win over the
|
||||
# template defaults. Anything left unset falls through to the
|
||||
# standard config stack at crawl-resolution time.
|
||||
@@ -86,6 +86,7 @@ def schedule(
|
||||
crawl_schedule = CrawlSchedule.objects.create(
|
||||
template=template,
|
||||
schedule=schedule_str,
|
||||
config={**template.config, "SCHEDULE_KIND": "update" if is_update_schedule else "crawl"},
|
||||
is_enabled=True,
|
||||
label=template_label,
|
||||
notes=template_notes,
|
||||
@@ -119,12 +120,19 @@ def schedule(
|
||||
|
||||
if run_all:
|
||||
enqueued = 0
|
||||
maintained = 0
|
||||
now = timezone.now()
|
||||
for scheduled_crawl in schedules:
|
||||
scheduled_crawl.enqueue(queued_at=now)
|
||||
enqueued += 1
|
||||
if scheduled_crawl.dispatch(queued_at=now) is None:
|
||||
maintained += 1
|
||||
else:
|
||||
enqueued += 1
|
||||
result["run_all_enqueued"] = enqueued
|
||||
result["run_all_maintained"] = maintained
|
||||
print(f"[green]\\[*] Enqueued {enqueued} scheduled crawl(s) immediately.[/green]")
|
||||
if maintained:
|
||||
run_pending_crawls(maintenance_only=True)
|
||||
print(f"[green]\\[*] Ran {maintained} scheduled maintenance update(s).[/green]")
|
||||
if enqueued:
|
||||
print(
|
||||
"[yellow]\\[*] Start `archivebox server`, `archivebox run --daemon`, or `archivebox schedule --foreground` to process the queued crawls.[/yellow]",
|
||||
@@ -171,7 +179,7 @@ def schedule(
|
||||
@click.option("--clear", is_flag=True, help="Disable all currently enabled schedules")
|
||||
@click.option("--show", is_flag=True, help="Print all currently enabled schedules")
|
||||
@click.option("--foreground", "-f", is_flag=True, help="Run the global crawl runner in the foreground (no crontab required)")
|
||||
@click.option("--run-all", is_flag=True, help="Enqueue all enabled schedules immediately and process them once")
|
||||
@click.option("--run-all", is_flag=True, help="Dispatch all enabled schedules immediately")
|
||||
@click.argument("import_path", required=False)
|
||||
@docstring(schedule.__doc__)
|
||||
def main(**kwargs):
|
||||
|
||||
@@ -46,13 +46,13 @@ def _split_bind_spec(spec: str) -> tuple[str, str]:
|
||||
"""Split a ``host:port`` / ``host`` / ``port`` spec into ``(host, port)``.
|
||||
|
||||
The empty strings stand in for "not provided"; the caller fills in
|
||||
defaults. Bracketed IPv6 literals like ``[::1]:8000`` are handled.
|
||||
defaults. Bracketed IPv6 literals like ``[::1]:5797`` are handled.
|
||||
"""
|
||||
spec = (spec or "").strip()
|
||||
if not spec:
|
||||
return "", ""
|
||||
if spec.startswith("["):
|
||||
# Bracketed IPv6: ``[::1]`` or ``[::1]:8000``
|
||||
# Bracketed IPv6: ``[::1]`` or ``[::1]:5797``
|
||||
end = spec.find("]")
|
||||
if end == -1:
|
||||
return spec, "" # malformed; let validator reject it
|
||||
@@ -77,11 +77,11 @@ def _parse_and_validate_bind_spec(spec: str) -> tuple[str, str]:
|
||||
(normalized to ``127.0.0.1``). Bare hostnames are rejected because the
|
||||
bind address feeds Daphne, which has to listen on a numeric address;
|
||||
public hostnames belong in ``BASE_URL`` instead. Empty values fall back
|
||||
to ``127.0.0.1`` / ``8000``.
|
||||
to ``127.0.0.1`` / ``5797``.
|
||||
"""
|
||||
raw_host, raw_port = _split_bind_spec(spec)
|
||||
host = raw_host.strip()
|
||||
port = (raw_port or "").strip() or "8000"
|
||||
port = (raw_port or "").strip() or "5797"
|
||||
|
||||
if host == "" or host.lower() == "localhost":
|
||||
host = "127.0.0.1"
|
||||
@@ -198,7 +198,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None:
|
||||
"[yellow] routing cannot work against an IP address. Set BASE_URL explicitly, e.g.[/yellow]",
|
||||
)
|
||||
print(
|
||||
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:8000[/yellow]",
|
||||
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:5797[/yellow]",
|
||||
)
|
||||
if config.USES_SUBDOMAIN_ROUTING:
|
||||
print(
|
||||
@@ -221,7 +221,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None:
|
||||
"[yellow] a reverse proxy / ingress / public hostname, e.g.[/yellow]",
|
||||
)
|
||||
print(
|
||||
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:8000[/yellow]",
|
||||
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:5797[/yellow]",
|
||||
)
|
||||
print()
|
||||
|
||||
@@ -254,7 +254,7 @@ def server(
|
||||
print()
|
||||
|
||||
# First non-empty positional arg is the bind spec; otherwise inherit from
|
||||
# config (which defaults to "127.0.0.1:8000"). _parse_and_validate_bind_spec
|
||||
# config (which defaults to "127.0.0.1:5797"). _parse_and_validate_bind_spec
|
||||
# hard-errors on hostnames so the rest of the server can assume a numeric
|
||||
# bind host.
|
||||
bind_spec = next((arg for arg in runserver_args if arg), "")
|
||||
@@ -363,12 +363,10 @@ def server(
|
||||
command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
|
||||
|
||||
def still_owns_runtime_stack() -> bool:
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
finally:
|
||||
connections.close_all()
|
||||
# This is polled at the log-tail cadence. Keep the autocommit
|
||||
# connection open so SQLite can reuse its page cache between polls;
|
||||
# reconnecting here repeatedly rereads the process table while idle.
|
||||
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
|
||||
|
||||
shutdown_state = None
|
||||
try:
|
||||
@@ -416,8 +414,13 @@ def server(
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if not shutdown_state or not shutdown_state.signal_name:
|
||||
command.mark_exited()
|
||||
from django.db import connections
|
||||
|
||||
try:
|
||||
if not shutdown_state or not shutdown_state.signal_name:
|
||||
command.mark_exited()
|
||||
finally:
|
||||
connections.close_all()
|
||||
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ SNAPSHOT_LIST_CHUNK_SIZE = 5000
|
||||
|
||||
def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.config.common import redact_sensitive_config
|
||||
from archivebox.core.models import SnapshotTag
|
||||
|
||||
fields = (
|
||||
@@ -53,6 +54,9 @@ def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
|
||||
"title",
|
||||
"bookmarked_at",
|
||||
"created_at",
|
||||
"modified_at",
|
||||
"config",
|
||||
"notes",
|
||||
"timestamp",
|
||||
"depth",
|
||||
"status",
|
||||
@@ -80,6 +84,9 @@ def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
|
||||
"tags": ",".join(sorted(tags_by_snapshot[row["id"]])),
|
||||
"bookmarked_at": row["bookmarked_at"].isoformat() if row["bookmarked_at"] else None,
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
|
||||
"config": redact_sensitive_config(row["config"]),
|
||||
"notes": row["notes"],
|
||||
"timestamp": row["timestamp"],
|
||||
"depth": row["depth"],
|
||||
"status": row["status"],
|
||||
|
||||
+202
-394
@@ -7,6 +7,7 @@ import os
|
||||
import asyncio
|
||||
import shlex
|
||||
import time
|
||||
from itertools import islice
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Iterable
|
||||
@@ -52,17 +53,20 @@ def _get_search_indexing_plugins() -> list[str]:
|
||||
def _build_filtered_snapshots_queryset(
|
||||
**kwargs,
|
||||
):
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
|
||||
|
||||
limit = kwargs.pop("limit", None)
|
||||
reverse = kwargs.pop("reverse", False)
|
||||
snapshots = build_snapshot_queryset(**kwargs)
|
||||
snapshots = snapshots.order_by(kwargs.get("sort") or "-created_at", "-id")
|
||||
if reverse:
|
||||
snapshots = snapshots.reverse()
|
||||
if kwargs.get("resume"):
|
||||
snapshots = snapshots.filter(timestamp__lte=kwargs["resume"])
|
||||
snapshots = snapshots.filter(**{"timestamp__gte" if reverse else "timestamp__lte": kwargs["resume"]})
|
||||
snapshots = snapshots.select_related("crawl")
|
||||
if limit is not None and limit > 0:
|
||||
snapshot_ids = list(snapshots.values_list("id", flat=True)[:limit])
|
||||
snapshots = Snapshot.objects.filter(id__in=snapshot_ids).select_related("crawl")
|
||||
snapshots = snapshots.filter(id__in=snapshot_ids)
|
||||
|
||||
return snapshots
|
||||
|
||||
@@ -73,111 +77,75 @@ def reindex_snapshots(
|
||||
search_plugins: list[str],
|
||||
batch_size: int,
|
||||
collect_ids: bool = False,
|
||||
reverse: bool = False,
|
||||
wait_for_turn=None,
|
||||
) -> dict[str, Any]:
|
||||
from archivebox.cli.archivebox_extract import run_plugins
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from abx_dl.models import discover_plugins
|
||||
from django.db.models import Exists, OuterRef
|
||||
|
||||
# Search backfill is the one maintenance hook allowed to execute without
|
||||
# reopening a Snapshot. Restrict that exception to already-sealed rows;
|
||||
# every open lifecycle state remains owned by the normal state machine.
|
||||
# every open lifecycle state remains owned by the normal runner lifecycle.
|
||||
snapshots = snapshots.filter(status=Snapshot.StatusChoices.SEALED)
|
||||
|
||||
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "skipped_queued": 0, "reindexed": 0, "snapshot_ids": []}
|
||||
records: list[dict[str, str]] = []
|
||||
plugins_by_name = discover_plugins(runtime="archivebox")
|
||||
required_hooks_by_plugin = {
|
||||
plugin_name: frozenset(hook.name for hook in plugins_by_name[plugin_name].filter_hooks("Snapshot"))
|
||||
for plugin_name in search_plugins
|
||||
if plugin_name in plugins_by_name
|
||||
}
|
||||
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
|
||||
print(f"[*] Reindexing missing search indexes with: {', '.join(search_plugins)}")
|
||||
|
||||
total = snapshots.count()
|
||||
print(f"[*] Reindexing {total} snapshots with search plugins: {', '.join(search_plugins)}")
|
||||
|
||||
def run_batch() -> None:
|
||||
if not records:
|
||||
return
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
batch_records = list(records)
|
||||
snapshot_ids = {record["snapshot_id"] for record in batch_records}
|
||||
plugin_names = {record["plugin"] for record in batch_records}
|
||||
queued_rows = {
|
||||
(str(snapshot_id), plugin_name, hook_name)
|
||||
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
|
||||
snapshot_id__in=snapshot_ids,
|
||||
plugin__in=plugin_names,
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
).values_list("snapshot_id", "plugin", "hook_name")
|
||||
}
|
||||
records_to_queue = []
|
||||
for record in batch_records:
|
||||
snapshot_id = record["snapshot_id"]
|
||||
plugin_name = record["plugin"]
|
||||
required_hooks = required_hooks_by_plugin.get(plugin_name, frozenset())
|
||||
if required_hooks and all((snapshot_id, plugin_name, hook_name) in queued_rows for hook_name in required_hooks):
|
||||
stats["skipped_queued"] += 1
|
||||
continue
|
||||
records_to_queue.append(record)
|
||||
if not records_to_queue:
|
||||
print(
|
||||
f" [{stats['processed']}/{total}] Already queued {len(batch_records)} index jobs",
|
||||
)
|
||||
records.clear()
|
||||
return
|
||||
# `archivebox update --index-only` intentionally breaks the usual
|
||||
# "runner discovers work" rule by inserting synthetic queued
|
||||
# ArchiveResult rows for search backends. run_plugins() keeps this as
|
||||
# statement-sized UPDATE/bulk_create work, then bumps Snapshot.retry_at
|
||||
# so the orchestrator owns actual hook execution. Paused snapshots stay
|
||||
# PAUSED; run_due_snapshot restores retry_at=MAX after targeted rows
|
||||
# finish.
|
||||
exit_code = run_plugins(
|
||||
args=(),
|
||||
records=records_to_queue,
|
||||
wait=False,
|
||||
emit_results=False,
|
||||
show_progress=False,
|
||||
preserve_queued=True,
|
||||
completed_statuses = [ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS]
|
||||
for plugin_name in search_plugins:
|
||||
completed_result = ArchiveResult.objects.filter(
|
||||
snapshot_id=OuterRef("pk"),
|
||||
plugin=plugin_name,
|
||||
status__in=completed_statuses,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise SystemExit(exit_code)
|
||||
stats["queued"] += len(records_to_queue)
|
||||
print(
|
||||
f" [{stats['processed']}/{total}] Queued {len(records_to_queue)} index jobs for orchestrator",
|
||||
)
|
||||
records.clear()
|
||||
|
||||
for snapshot in snapshots.select_related("crawl").paged_iterator(chunk_size=batch_size):
|
||||
try:
|
||||
stats["processed"] += 1
|
||||
|
||||
if _get_snapshot_crawl(snapshot) is None:
|
||||
continue
|
||||
|
||||
candidates = snapshots.annotate(has_completed_index=Exists(completed_result)).filter(has_completed_index=False)
|
||||
candidates = candidates.order_by("created_at", "id") if reverse else candidates.order_by("-created_at", "-id")
|
||||
remaining = candidates.select_related(None).only("id", "timestamp", "created_at").paged_iterator(chunk_size=batch_size)
|
||||
while True:
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
batch = list(islice(remaining, batch_size))
|
||||
if not batch:
|
||||
break
|
||||
records = [{"type": "Snapshot", "id": str(snapshot.id)} for snapshot in batch]
|
||||
stats["processed"] += len(batch)
|
||||
stats["requested"] += len(batch)
|
||||
if collect_ids:
|
||||
stats["snapshot_ids"].append(str(snapshot.id))
|
||||
for plugin_name in search_plugins:
|
||||
records.append(
|
||||
{
|
||||
"type": "ArchiveResult",
|
||||
"snapshot_id": str(snapshot.id),
|
||||
"plugin": plugin_name,
|
||||
},
|
||||
)
|
||||
stats["requested"] += 1
|
||||
if len(records) >= batch_size:
|
||||
run_batch()
|
||||
except KeyboardInterrupt as err:
|
||||
err.archivebox_resume = snapshot.timestamp
|
||||
raise
|
||||
|
||||
run_batch()
|
||||
stats["snapshot_ids"].extend(str(snapshot.id) for snapshot in batch)
|
||||
exit_code = run_plugins(
|
||||
args=(),
|
||||
records=records,
|
||||
plugins=plugin_name,
|
||||
wait=True,
|
||||
emit_results=False,
|
||||
show_progress=False,
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise SystemExit(exit_code)
|
||||
stats["reindexed"] += len(batch)
|
||||
print(f" [{plugin_name}] indexed {stats['reindexed']} missing snapshots")
|
||||
return stats
|
||||
|
||||
|
||||
def run_scheduled_maintenance(*, batch_size: int = 500) -> dict[str, Any]:
|
||||
"""Queue stale filesystem rows and backfill missing search facts."""
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
filesystem_stats = process_all_db_snapshots(batch_size=batch_size)
|
||||
search_plugins = _get_search_indexing_plugins()
|
||||
search_stats = (
|
||||
reindex_snapshots(
|
||||
Snapshot.objects.filter(fs_version=Snapshot._fs_current_version()),
|
||||
search_plugins=search_plugins,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
if search_plugins
|
||||
else {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
|
||||
)
|
||||
return {"filesystem": filesystem_stats, "search": search_stats}
|
||||
|
||||
|
||||
@enforce_types
|
||||
def update(
|
||||
filter_patterns: Iterable[str] = (),
|
||||
@@ -197,18 +165,18 @@ def update(
|
||||
continuous: bool = False,
|
||||
index_only: bool = False,
|
||||
migrate_only: bool = False,
|
||||
rescan: bool = False,
|
||||
reverse: bool = False,
|
||||
stop_daemon_stack: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Update snapshots: migrate old dirs, reconcile DB, and re-queue for archiving.
|
||||
Update the database without forcing a filesystem scan by default.
|
||||
|
||||
Three-phase operation (without filters):
|
||||
- Phase 1: Drain old archive/ dirs by moving to new fs location (0.8.x → 0.9.x)
|
||||
- Phase 2: O(n) scan over entire DB from most recent to least recent
|
||||
- No orphan scans needed (trust 1:1 mapping between DB and filesystem after phase 1)
|
||||
|
||||
With filters: Only phase 2 (DB query), no filesystem operations.
|
||||
Without filters: All phases (full update).
|
||||
--migrate-only: eagerly finish pending filesystem migrations for known rows.
|
||||
--index-only: reconcile known snapshot assets/metadata and backfill search.
|
||||
--rescan: discover filesystem orphans first, then repair known snapshots.
|
||||
Explicit scans default to newest first; --reverse selects oldest first.
|
||||
Filters select existing database snapshots for targeted updates.
|
||||
"""
|
||||
|
||||
from rich import print
|
||||
@@ -232,7 +200,6 @@ def update(
|
||||
from archivebox.core.takeover_util import (
|
||||
command_owns_foreground_runner,
|
||||
current_command,
|
||||
foreground_runner_owner,
|
||||
standby_until_foreground_runner_needed,
|
||||
)
|
||||
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
|
||||
@@ -285,17 +252,28 @@ def update(
|
||||
exit_code = 0
|
||||
|
||||
try:
|
||||
if rescan and (is_filtered_update or resume or continuous):
|
||||
raise click.UsageError("--rescan cannot be combined with filters, --resume, or --continuous; rerun it to resume automatically.")
|
||||
if rescan:
|
||||
migrate_only = True # Discovery must not schedule new captures.
|
||||
wait_for_turn()
|
||||
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
while True:
|
||||
do_migrate = migrate_only or not index_only
|
||||
do_index = index_only or not migrate_only
|
||||
do_run_until_idle = do_migrate or do_index
|
||||
ran_post_migrate_runner = False
|
||||
full_update_empty = False
|
||||
maintenance_work_queued = False
|
||||
runner_work_queued = False
|
||||
if rescan:
|
||||
from archivebox.cli.rescan import rescan_snapshots
|
||||
|
||||
rescan_snapshots(
|
||||
wait_for_turn=wait_for_turn,
|
||||
reverse=reverse,
|
||||
scan_legacy=lambda: drain_old_archive_dirs(batch_size=batch_size, reverse=reverse),
|
||||
)
|
||||
do_migrate = migrate_only or is_filtered_update and not index_only
|
||||
do_index = index_only
|
||||
if not do_migrate and not do_index:
|
||||
print(
|
||||
"[*] Collection ready. Filesystem migrations remain lazy; use --migrate-only, --index-only, or --rescan for explicit scans.",
|
||||
)
|
||||
|
||||
if do_migrate:
|
||||
if (
|
||||
@@ -327,178 +305,86 @@ def update(
|
||||
after=after,
|
||||
resume=resume,
|
||||
batch_size=batch_size,
|
||||
queue_for_archiving=do_run_until_idle,
|
||||
queue_for_archiving=not migrate_only,
|
||||
reverse=reverse,
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
maintenance_work_queued = stats.get("queued", 0) > 0
|
||||
runner_work_queued = runner_work_queued or maintenance_work_queued
|
||||
else:
|
||||
stats_combined = {"phase1": {}, "phase2": {}}
|
||||
|
||||
print("[*] Phase 1: Draining old archive/ directories (0.8.x → 0.9.x migration)...")
|
||||
stats_combined["phase1"] = drain_old_archive_dirs(
|
||||
resume_from=resume,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
print("[*] Phase 2: Processing all database snapshots (most recent first)...")
|
||||
print("[*] Phase 2: Selecting database snapshots with stale filesystem versions...")
|
||||
stats_combined["phase2"] = process_all_db_snapshots(
|
||||
batch_size=batch_size,
|
||||
resume=resume,
|
||||
wait_for_turn=wait_for_turn,
|
||||
reverse=reverse,
|
||||
migrate=True,
|
||||
)
|
||||
print_combined_stats(stats_combined)
|
||||
full_update_empty = (
|
||||
stats_combined["phase1"].get("processed", 0) == 0 and stats_combined["phase2"].get("snapshots", 0) == 0
|
||||
)
|
||||
maintenance_work_queued = any(
|
||||
(
|
||||
stats_combined["phase1"].get("queued", 0),
|
||||
stats_combined["phase2"].get("queued", 0),
|
||||
stats_combined["phase2"].get("crawls_sealed", 0),
|
||||
),
|
||||
)
|
||||
runner_work_queued = runner_work_queued or maintenance_work_queued
|
||||
|
||||
if do_run_until_idle:
|
||||
# Filesystem migration is maintenance on existing
|
||||
# Snapshot rows: Snapshot.save() moves archive/<ts> to
|
||||
# the current output_dir and preserves the lifecycle
|
||||
# status. Drain those retry_at ticks before queuing
|
||||
# search backfill below. Otherwise the sealed search
|
||||
# runner branch correctly sees queued ArchiveResult
|
||||
# rows first, runs the targeted plugins, and may leave
|
||||
# the fs_version maintenance tick hidden behind that
|
||||
# plugin work until another update pass.
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots or legacy archive directories found; skipping filesystem maintenance runner.")
|
||||
elif not maintenance_work_queued:
|
||||
print("[*] No filesystem maintenance work queued; skipping filesystem maintenance runner.")
|
||||
else:
|
||||
print("[*] Phase 3: Running filesystem maintenance until idle...")
|
||||
if full_update_empty:
|
||||
pass
|
||||
elif not maintenance_work_queued:
|
||||
pass
|
||||
elif is_filtered_update:
|
||||
if not touched_snapshot_ids:
|
||||
print("[*] No matching snapshots queued work for the runner.")
|
||||
for snapshot_id in sorted(touched_snapshot_ids):
|
||||
run_scoped_runner("--snapshot-id", snapshot_id)
|
||||
else:
|
||||
run_scoped_runner("--maintenance-only", "--maintenance-batch-size", str(batch_size))
|
||||
ran_post_migrate_runner = True
|
||||
# The due selectors are indexed and cheap when empty, so
|
||||
# always drain them instead of preceding the runner with
|
||||
# whole-table counts merely to decide whether to call it.
|
||||
print("[*] Phase 3: Running filesystem maintenance until idle...")
|
||||
if is_filtered_update:
|
||||
for snapshot_id in sorted(touched_snapshot_ids, reverse=not reverse):
|
||||
run_scoped_runner("--snapshot-id", snapshot_id)
|
||||
elif migrate_only:
|
||||
run_scoped_runner("--maintenance-only")
|
||||
else:
|
||||
run_scoped_runner()
|
||||
|
||||
if do_index:
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots found; skipping search indexing backfill.")
|
||||
else:
|
||||
search_plugins = _get_search_indexing_plugins()
|
||||
if not search_plugins:
|
||||
print("[*] No search indexing plugins are available, nothing to backfill.")
|
||||
else:
|
||||
snapshots = _build_filtered_snapshots_queryset(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=filter_type,
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
search=search,
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
)
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.cli.rescan import reconcile_known_snapshots
|
||||
|
||||
scoped_snapshot_ids = snapshots.order_by().values("id") if is_filtered_update else None
|
||||
queued_index_results = ArchiveResult.objects.filter(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
plugin__in=search_plugins,
|
||||
)
|
||||
if scoped_snapshot_ids is not None:
|
||||
queued_index_results = queued_index_results.filter(snapshot_id__in=scoped_snapshot_ids)
|
||||
|
||||
if queued_index_results.exists():
|
||||
runner_work_queued = True
|
||||
now = timezone.now()
|
||||
queued_result_for_snapshot = queued_index_results.filter(snapshot_id=OuterRef("pk"))
|
||||
snapshots_to_wake = (
|
||||
Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
|
||||
.annotate(
|
||||
has_queued_index_result=Exists(queued_result_for_snapshot),
|
||||
)
|
||||
.filter(
|
||||
has_queued_index_result=True,
|
||||
)
|
||||
.filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
)
|
||||
)
|
||||
if scoped_snapshot_ids is not None:
|
||||
snapshots_to_wake = snapshots_to_wake.filter(id__in=scoped_snapshot_ids)
|
||||
woken_count = snapshots_to_wake.update(
|
||||
retry_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
print(
|
||||
"[*] Existing queued search index jobs found; "
|
||||
f"skipping backfill scan and waking {woken_count} snapshot(s) for the runner.",
|
||||
)
|
||||
else:
|
||||
collect_index_ids = (
|
||||
is_filtered_update
|
||||
or foreground_runner_owner(
|
||||
data_dir=CONSTANTS.DATA_DIR,
|
||||
exclude_id=command.id,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
stats = reindex_snapshots(
|
||||
snapshots,
|
||||
search_plugins=search_plugins,
|
||||
batch_size=batch_size,
|
||||
collect_ids=collect_index_ids,
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_index_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
runner_work_queued = runner_work_queued or stats["queued"] > 0
|
||||
|
||||
if do_run_until_idle and (do_index or not ran_post_migrate_runner):
|
||||
# Search/index backfill intentionally queues targeted
|
||||
# ArchiveResult rows without reopening sealed snapshots.
|
||||
# This second runner pass drains those plugin
|
||||
# rows after filesystem maintenance has had its own turn.
|
||||
# For a normal unfiltered `archivebox update`, keep the
|
||||
# historical final pass broad enough to resume genuinely
|
||||
# queued/interrupted crawl work after maintenance is done.
|
||||
if full_update_empty:
|
||||
print("[*] No snapshots found; skipping queued/interrupted crawl runner.")
|
||||
elif not runner_work_queued:
|
||||
print("[*] No queued/interrupted crawl work found; skipping queued/interrupted crawl runner.")
|
||||
snapshots = _build_filtered_snapshots_queryset(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=filter_type,
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
search=search,
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
reverse=reverse,
|
||||
)
|
||||
reconcile_known_snapshots(snapshots, wait_for_turn=wait_for_turn)
|
||||
search_plugins = _get_search_indexing_plugins()
|
||||
if not search_plugins:
|
||||
print("[*] No search indexing plugins are available, nothing to backfill.")
|
||||
else:
|
||||
print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
|
||||
if full_update_empty:
|
||||
pass
|
||||
elif not runner_work_queued:
|
||||
pass
|
||||
elif touched_snapshot_ids and is_filtered_update:
|
||||
if not touched_snapshot_ids:
|
||||
print("[*] No matching snapshots queued work for the runner.")
|
||||
for snapshot_id in sorted(touched_snapshot_ids):
|
||||
run_scoped_runner("--snapshot-id", snapshot_id)
|
||||
else:
|
||||
run_scoped_runner(
|
||||
*(["--maintenance-only", "--maintenance-batch-size", str(batch_size)] if index_only or migrate_only else []),
|
||||
snapshots = _build_filtered_snapshots_queryset(
|
||||
filter_patterns=filter_patterns,
|
||||
filter_type=filter_type,
|
||||
status=status,
|
||||
url__icontains=url__icontains,
|
||||
url__istartswith=url__istartswith,
|
||||
tag=tag,
|
||||
crawl_id=crawl_id,
|
||||
limit=limit,
|
||||
sort=sort,
|
||||
search=search,
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
reverse=reverse,
|
||||
)
|
||||
stats = reindex_snapshots(
|
||||
snapshots,
|
||||
search_plugins=search_plugins,
|
||||
batch_size=batch_size,
|
||||
collect_ids=is_filtered_update,
|
||||
reverse=reverse,
|
||||
wait_for_turn=wait_for_turn,
|
||||
)
|
||||
print_index_stats(stats)
|
||||
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
|
||||
|
||||
if not continuous:
|
||||
break
|
||||
@@ -510,6 +396,10 @@ def update(
|
||||
exit_code = 130
|
||||
exact_resume = err.__dict__.get("archivebox_resume")
|
||||
resume_cmd = ["archivebox", "update"]
|
||||
if rescan:
|
||||
resume_cmd.append("--rescan")
|
||||
if reverse:
|
||||
resume_cmd.append("--reverse")
|
||||
if migrate_only:
|
||||
resume_cmd.append("--migrate-only")
|
||||
if index_only:
|
||||
@@ -557,7 +447,7 @@ def update(
|
||||
stop_own_supervisord_process()
|
||||
|
||||
|
||||
def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500) -> dict[str, int]:
|
||||
def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500, reverse: bool = False) -> dict[str, int]:
|
||||
"""
|
||||
Drain old archive/ directories (0.8.x → 0.9.x migration).
|
||||
|
||||
@@ -623,18 +513,18 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
|
||||
|
||||
# Scan real legacy directories only; these still contain data to migrate.
|
||||
entries = [
|
||||
(e.stat().st_mtime, e.path)
|
||||
(float(e.name), e.path)
|
||||
for e in all_entries
|
||||
if e.is_dir(follow_symlinks=False) and Snapshot.is_legacy_archive_dir(Path(e.path)) # Skip symlinks and 0.9.x roots
|
||||
]
|
||||
entries.sort(reverse=True) # Newest first
|
||||
entries.sort(reverse=not reverse) # Newest first
|
||||
print(f"[*] Found {len(entries)} old directories to drain")
|
||||
|
||||
for mtime, entry_path in entries:
|
||||
entry_path = Path(entry_path)
|
||||
|
||||
# Resume from timestamp if specified
|
||||
if resume_from and entry_path.name > resume_from:
|
||||
if resume_from and (entry_path.name < resume_from if reverse else entry_path.name > resume_from):
|
||||
continue
|
||||
|
||||
stats["processed"] += 1
|
||||
@@ -736,19 +626,15 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
|
||||
return stats
|
||||
|
||||
|
||||
def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, wait_for_turn=None) -> dict[str, int]:
|
||||
"""
|
||||
O(n) scan over entire DB from most recent to least recent.
|
||||
|
||||
For each snapshot:
|
||||
1. Reconcile index.json with DB (merge titles, tags, archive results)
|
||||
2. Mark migrated snapshots sealed unless explicitly re-queued elsewhere
|
||||
|
||||
No orphan detection needed - we trust 1:1 mapping between DB and filesystem
|
||||
after Phase 1 has drained all old archive/ directories.
|
||||
"""
|
||||
def process_all_db_snapshots(
|
||||
batch_size: int = 500,
|
||||
resume: str | None = None,
|
||||
wait_for_turn=None,
|
||||
reverse: bool = False,
|
||||
migrate: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Queue only snapshots whose indexed filesystem version is stale."""
|
||||
from archivebox.core.models import Snapshot
|
||||
from archivebox.crawls.models import Crawl
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -761,128 +647,41 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
|
||||
"sealed": 0,
|
||||
"crawls_sealed": 0,
|
||||
}
|
||||
current_fs_version = Snapshot._fs_current_version()
|
||||
|
||||
queryset = Snapshot.objects.all()
|
||||
queryset = Snapshot.objects.filter(fs_version__in=Snapshot._FS_VERSION_MIGRATION_PATHS)
|
||||
if resume:
|
||||
queryset = queryset.filter(timestamp__lte=resume)
|
||||
total = queryset.count()
|
||||
stats["snapshots"] = total
|
||||
print(f"[*] Processing {total} snapshots from database (most recent first)...")
|
||||
|
||||
def update_in_batches(rows, *, label: str, **updates) -> int:
|
||||
updated = 0
|
||||
checked = 0
|
||||
while True:
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
batch = list(rows.only("id", "modified_at").order_by("-timestamp")[:batch_size])
|
||||
if not batch:
|
||||
if updated:
|
||||
print(f" [{label}] complete: {updated} rows updated")
|
||||
return updated
|
||||
checked += len(batch)
|
||||
print(f" [{label}] updating next {len(batch)} rows (seen {checked})...")
|
||||
for snapshot in batch:
|
||||
# This maintenance scan intentionally bypasses save(); it is
|
||||
# only normalizing scheduler fields, and Snapshot.save() may
|
||||
# do filesystem migration work that belongs in the runner.
|
||||
# Guard each single-row UPDATE with modified_at so stale scan
|
||||
# pages cannot overwrite newer runner/admin writes.
|
||||
updated += int(
|
||||
snapshot.safe_update(
|
||||
updates,
|
||||
refresh=False,
|
||||
extra_filter={"modified_at": snapshot.modified_at},
|
||||
),
|
||||
)
|
||||
print(f" [{label}] updated {updated} rows so far")
|
||||
|
||||
now = timezone.now()
|
||||
updated_rows = update_in_batches(
|
||||
queryset.exclude(
|
||||
status__in=[
|
||||
Snapshot.StatusChoices.QUEUED,
|
||||
Snapshot.StatusChoices.STARTED,
|
||||
Snapshot.StatusChoices.PAUSED,
|
||||
Snapshot.StatusChoices.SEALED,
|
||||
],
|
||||
),
|
||||
label="snapshot status normalization",
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
retry_at=None,
|
||||
modified_at=now,
|
||||
)
|
||||
stats["sealed"] += updated_rows
|
||||
stats["updated_db"] += updated_rows
|
||||
|
||||
fs_version_rows = queryset.exclude(fs_version=current_fs_version).filter(Q(retry_at__isnull=True) | Q(retry_at__gt=now))
|
||||
stale_batch = []
|
||||
|
||||
def queue_stale_fs_batch() -> None:
|
||||
if not stale_batch:
|
||||
return
|
||||
queryset = queryset.filter(**{"timestamp__gte" if reverse else "timestamp__lte": resume})
|
||||
initial_now = timezone.now()
|
||||
rows_to_wake = queryset if migrate else queryset.filter(Q(retry_at__isnull=True) | Q(retry_at__gt=initial_now))
|
||||
ordered = rows_to_wake.order_by("created_at", "id") if reverse else rows_to_wake.order_by("-created_at", "-id")
|
||||
remaining = ordered.select_related("crawl__created_by").paged_iterator(chunk_size=batch_size)
|
||||
while True:
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
batch = list(islice(remaining, batch_size))
|
||||
if not batch:
|
||||
break
|
||||
now = timezone.now()
|
||||
# Do not bump fs_version here. The orchestrator calls Snapshot.save(),
|
||||
# which performs the idempotent filesystem migration and commits the new
|
||||
# fs_version in the same serialized worker path as normal crawls.
|
||||
updated = 0
|
||||
for snapshot in stale_batch:
|
||||
# Each row gets its own short autocommit UPDATE because this scan
|
||||
# can touch millions of snapshots while a server is also alive.
|
||||
# The modified_at predicate is the CAS guard: if the runner or
|
||||
# admin changed the snapshot after paged_iterator read it, skip it
|
||||
# and let the newer state decide whether migration is still due.
|
||||
for snapshot in batch:
|
||||
if migrate:
|
||||
from archivebox.services.runner import run_snapshot_maintenance
|
||||
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
run_snapshot_maintenance(str(snapshot.id))
|
||||
continue
|
||||
updated += int(
|
||||
snapshot.safe_update(
|
||||
{
|
||||
"retry_at": now,
|
||||
"modified_at": now,
|
||||
},
|
||||
{"retry_at": now, "modified_at": now},
|
||||
refresh=False,
|
||||
extra_filter={"fs_version": snapshot.fs_version},
|
||||
),
|
||||
)
|
||||
stats["processed"] += len(stale_batch)
|
||||
stats["processed"] += len(batch)
|
||||
stats["updated_db"] += updated
|
||||
stats["queued"] += updated
|
||||
print(f" [{stats['processed']}/{total}] Queued {updated} filesystem migrations for orchestrator...")
|
||||
stale_batch.clear()
|
||||
|
||||
for snapshot in (
|
||||
fs_version_rows.only("id", "crawl_id", "timestamp", "fs_version", "modified_at")
|
||||
.order_by("-timestamp")
|
||||
.paged_iterator(chunk_size=batch_size)
|
||||
):
|
||||
try:
|
||||
stale_batch.append(snapshot)
|
||||
if len(stale_batch) >= batch_size:
|
||||
queue_stale_fs_batch()
|
||||
except KeyboardInterrupt as err:
|
||||
err.archivebox_resume = snapshot.timestamp
|
||||
raise
|
||||
queue_stale_fs_batch()
|
||||
|
||||
now = timezone.now()
|
||||
# Crawls with no open child snapshots are already finished. Seal them here
|
||||
# instead of waking the foreground runner; otherwise migration/update can
|
||||
# accidentally re-enter full crawl execution for historical rows.
|
||||
stats["crawls_sealed"] = (
|
||||
Crawl.objects.filter(
|
||||
status__in=Crawl.RUNNABLE_STATES,
|
||||
)
|
||||
.exclude(
|
||||
snapshot_set__status__in=Snapshot.OPEN_STATES,
|
||||
)
|
||||
.update(
|
||||
status=Crawl.StatusChoices.SEALED,
|
||||
retry_at=None,
|
||||
modified_at=now,
|
||||
)
|
||||
)
|
||||
stats["updated_db"] += stats["crawls_sealed"]
|
||||
print(f" Queued {stats['queued']} stale filesystem snapshots so far...")
|
||||
stats["snapshots"] = stats["processed"]
|
||||
return stats
|
||||
|
||||
|
||||
@@ -902,6 +701,7 @@ def process_filtered_snapshots(
|
||||
resume: str | None,
|
||||
batch_size: int,
|
||||
queue_for_archiving: bool = True,
|
||||
reverse: bool = False,
|
||||
wait_for_turn=None,
|
||||
) -> dict[str, Any]:
|
||||
"""Process snapshots matching filters (DB query only)."""
|
||||
@@ -924,6 +724,7 @@ def process_filtered_snapshots(
|
||||
before=before,
|
||||
after=after,
|
||||
resume=resume,
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
total = snapshots.count()
|
||||
@@ -942,8 +743,10 @@ def process_filtered_snapshots(
|
||||
stats["snapshot_ids"].append(str(snapshot.id))
|
||||
update_values = {}
|
||||
updated = 0
|
||||
if not isinstance(snapshot.current_step, int):
|
||||
update_values["current_step"] = 0
|
||||
if not queue_for_archiving and snapshot.fs_migration_needed:
|
||||
from archivebox.services.runner import run_snapshot_maintenance
|
||||
|
||||
run_snapshot_maintenance(str(snapshot.id))
|
||||
if queue_for_archiving:
|
||||
update_values.update(
|
||||
{
|
||||
@@ -1029,10 +832,9 @@ def print_index_stats(stats: dict[str, Any]) -> None:
|
||||
|
||||
print(f"""
|
||||
[green]Search Reindex Complete[/green]
|
||||
Scanned rows: {stats["processed"]}
|
||||
Requested jobs: {stats.get("requested", stats["queued"])}
|
||||
Queued index jobs: {stats["queued"]}
|
||||
Already queued: {stats.get("skipped_queued", 0)}
|
||||
Missing index runs: {stats["processed"]}
|
||||
Requested runs: {stats.get("requested", 0)}
|
||||
Indexed snapshots: {stats.get("reindexed", 0)}
|
||||
""")
|
||||
|
||||
|
||||
@@ -1042,6 +844,12 @@ def print_index_stats(stats: dict[str, Any]) -> None:
|
||||
@click.option("--continuous", is_flag=True, help="Run continuously as background worker")
|
||||
@click.option("--index-only", is_flag=True, help="Backfill available search indexes from existing archived content")
|
||||
@click.option("--migrate-only", is_flag=True, help="Only migrate filesystem and update database/index state")
|
||||
@click.option("--reverse", is_flag=True, help="Process oldest first instead of newest first for explicit scans")
|
||||
@click.option(
|
||||
"--rescan",
|
||||
is_flag=True,
|
||||
help="Scan all snapshot directories for orphans and metadata repairs (slow, opt-in, safe to restart)",
|
||||
)
|
||||
@snapshot_filter_options(default_filter_type="exact")
|
||||
@docstring(update.__doc__)
|
||||
def main(**kwargs):
|
||||
|
||||
@@ -80,20 +80,6 @@ def _render_binary_abspath(abspath: str):
|
||||
return Text(abspath, style="green")
|
||||
|
||||
|
||||
def _binary_record_matches_runtime(installed, lib_dir: Path) -> bool:
|
||||
if not installed or not installed.is_valid or not installed.version:
|
||||
return False
|
||||
try:
|
||||
abspath = Path(installed.abspath).expanduser().resolve(strict=False)
|
||||
if not abspath.exists():
|
||||
return False
|
||||
if installed.binprovider not in {"", "env", "apt", "brew"}:
|
||||
abspath.relative_to(lib_dir)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _binary_row_dedupe_key(
|
||||
*,
|
||||
display_name: str,
|
||||
@@ -127,7 +113,6 @@ def version(
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from abx_dl.tables import binary_dependency_status, binary_dependency_table
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
@@ -263,47 +248,37 @@ def version(
|
||||
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
|
||||
failures = []
|
||||
seen_failures: set[str] = set()
|
||||
seen_rows: set[tuple[str, str, str, str]] = set()
|
||||
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
from archivebox.plugins.discovery import get_enabled_plugins, get_plugin_catalog
|
||||
from abx_dl.config import get_required_binary_requests
|
||||
from abx_dl.dependencies import resolve_binary_requests
|
||||
from abx_dl.models import discover_plugins, filter_plugins
|
||||
from abx_dl.orchestrator import create_bus
|
||||
from abxpkg.binary_service import BinaryEvent, BinaryService
|
||||
|
||||
plugins = discover_plugins(runtime="archivebox")
|
||||
enabled_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
|
||||
enabled_plugin_names = set(enabled_plugins)
|
||||
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
|
||||
plugins = get_plugin_catalog()
|
||||
enabled_plugin_names = set(get_enabled_plugins(config=config))
|
||||
runtime_config = normalize_runtime_config(dict(config.items()), json_safe=False)
|
||||
derived_config: dict[str, object] = {}
|
||||
db_binaries = {}
|
||||
db_available = False
|
||||
if in_data_dir:
|
||||
try:
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
|
||||
from archivebox.machine.models import Machine, Binary
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
machine = Machine.current()
|
||||
derived_config = normalize_runtime_config(machine.config, json_safe=False)
|
||||
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
|
||||
if _binary_record_matches_runtime(binary, config.ABXPKG_LIB_DIR):
|
||||
db_binaries.setdefault(binary.name, binary)
|
||||
db_available = True
|
||||
|
||||
except Exception as e:
|
||||
prnt()
|
||||
prnt("", f"[yellow]Warning: Could not query collection binary records; resolving through abxpkg: {e}[/yellow]")
|
||||
prnt("", f"[yellow]Warning: Could not query collection machine config; resolving through abxpkg: {e}[/yellow]")
|
||||
|
||||
declared_binary_specs: dict[str, dict[str, object]] = {}
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = bool(requested_names)
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
@@ -321,10 +296,9 @@ def version(
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
if not plugin_enabled and not requested_names:
|
||||
continue
|
||||
if _binary_record_matches_runtime(db_binaries.get(logical_name), config.ABXPKG_LIB_DIR):
|
||||
continue
|
||||
# This is a diagnostic: recheck the provider instead of trusting an
|
||||
# installation record or a cached version, including disabled plugins.
|
||||
binary_record["no_cache"] = True
|
||||
signature = json.dumps(binary_record, sort_keys=True, default=str)
|
||||
declared_binary_specs.setdefault(signature, binary_record)
|
||||
|
||||
@@ -342,126 +316,47 @@ def version(
|
||||
|
||||
loaded_binaries = asyncio.run(resolve_declared_binaries())
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
rows_by_binary: dict[str, dict] = {}
|
||||
any_rows = False
|
||||
any_available = False
|
||||
compact_paths = console.is_terminal
|
||||
live_enabled = console.is_terminal
|
||||
live_cm = Live(binary_dependency_table(rows), console=console, refresh_per_second=8) if live_enabled else None
|
||||
|
||||
def emit_row(row: dict[str, object]) -> None:
|
||||
rows.append(row)
|
||||
if live_cm is not None:
|
||||
live_cm.update(binary_dependency_table(rows), refresh=True)
|
||||
|
||||
if live_cm is not None:
|
||||
live_cm.start()
|
||||
try:
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = bool(requested_names)
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
disabled_by = ", ".join(
|
||||
f"{dependency.enabled_key}=False"
|
||||
for dependency in plugins.select([plugin_name]).values()
|
||||
if not config.get(dependency.enabled_key, True)
|
||||
)
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
)
|
||||
for binary_record in binary_records:
|
||||
binary_record["no_cache"] = True
|
||||
actual_name = str(binary_record["name"])
|
||||
logical_name = (
|
||||
Path(actual_name).expanduser().name
|
||||
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
|
||||
else actual_name
|
||||
)
|
||||
for binary_record in binary_records:
|
||||
actual_name = str(binary_record["name"])
|
||||
logical_name = (
|
||||
Path(actual_name).expanduser().name
|
||||
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
|
||||
else actual_name
|
||||
)
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
|
||||
installed = db_binaries.get(logical_name) if db_available else None
|
||||
if _binary_record_matches_runtime(installed, config.ABXPKG_LIB_DIR):
|
||||
abspath = installed.abspath
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
valid = True
|
||||
elif not plugin_enabled and not requested_names:
|
||||
# `archivebox version` is expected to verify the active runtime, not
|
||||
# cold-load every optional plugin provider. Migration and status
|
||||
# checks often run with PLUGINS narrowed to a tiny set; resolving
|
||||
# disabled plugin binaries there can spend most of the command on
|
||||
# providers the current collection will never execute.
|
||||
continue
|
||||
else:
|
||||
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
|
||||
abspath = loaded.abspath if loaded is not None else ""
|
||||
version_str = str(loaded.version or "unknown")[:15] if loaded is not None else "unknown"
|
||||
provider = str(loaded.binprovider or "env")[:8] if loaded is not None else "env"
|
||||
valid = loaded is not None
|
||||
|
||||
any_rows = True
|
||||
if valid:
|
||||
display_path = (
|
||||
_format_binary_abspath(
|
||||
abspath,
|
||||
pwd=Path.cwd(),
|
||||
lib_dir=config.ABXPKG_LIB_DIR,
|
||||
personas_dir=CONSTANTS.PERSONAS_DIR,
|
||||
home=Path.home(),
|
||||
)
|
||||
if compact_paths
|
||||
else abspath
|
||||
)
|
||||
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
|
||||
any_available = True
|
||||
else:
|
||||
rendered_path = "[grey53]not installed[/grey53]"
|
||||
if plugin_enabled and display_name not in seen_failures:
|
||||
failures.append(display_name)
|
||||
seen_failures.add(display_name)
|
||||
|
||||
row_key = _binary_row_dedupe_key(
|
||||
display_name=display_name,
|
||||
valid=valid,
|
||||
version=version_str if valid else "-",
|
||||
provider=provider if valid else "-",
|
||||
abspath=abspath,
|
||||
)
|
||||
if row_key in seen_rows:
|
||||
continue
|
||||
seen_rows.add(row_key)
|
||||
|
||||
emit_row(
|
||||
{
|
||||
"plugin": plugin_name,
|
||||
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
|
||||
"binary": display_name,
|
||||
"version": version_str if valid else "-",
|
||||
"provider": provider if valid else "-",
|
||||
"path": rendered_path,
|
||||
"style": "" if plugin_enabled else "dim",
|
||||
},
|
||||
)
|
||||
|
||||
if db_available:
|
||||
for binary_name, installed in db_binaries.items():
|
||||
if requested_names and binary_name not in requested_names:
|
||||
continue
|
||||
abspath = installed.abspath
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
row_key = _binary_row_dedupe_key(
|
||||
display_name=binary_name,
|
||||
valid=True,
|
||||
version=version_str,
|
||||
provider=provider,
|
||||
abspath=abspath,
|
||||
)
|
||||
if row_key in seen_rows:
|
||||
continue
|
||||
seen_rows.add(row_key)
|
||||
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
|
||||
abspath = loaded.abspath if loaded is not None else ""
|
||||
version_str = str(loaded.version or "unknown") if loaded is not None else "unknown"
|
||||
provider = str(loaded.binprovider or "env") if loaded is not None else "env"
|
||||
valid = loaded is not None and Path(abspath).is_file()
|
||||
|
||||
any_rows = True
|
||||
if valid:
|
||||
display_path = (
|
||||
_format_binary_abspath(
|
||||
abspath,
|
||||
@@ -473,24 +368,67 @@ def version(
|
||||
if compact_paths
|
||||
else abspath
|
||||
)
|
||||
emit_row(
|
||||
{
|
||||
"plugin": "(database)",
|
||||
"status": binary_dependency_status(enabled=True, valid=True),
|
||||
"binary": binary_name,
|
||||
"version": version_str,
|
||||
"provider": provider,
|
||||
"path": _render_binary_abspath(display_path) if compact_paths else display_path,
|
||||
"style": "dim",
|
||||
},
|
||||
)
|
||||
any_rows = True
|
||||
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
|
||||
any_available = True
|
||||
finally:
|
||||
if live_cm is not None:
|
||||
live_cm.stop()
|
||||
else:
|
||||
prnt(binary_dependency_table(rows))
|
||||
else:
|
||||
rendered_path = "[grey53]not installed[/grey53]"
|
||||
if plugin_enabled and display_name not in seen_failures:
|
||||
failures.append(display_name)
|
||||
seen_failures.add(display_name)
|
||||
|
||||
if not plugin_enabled:
|
||||
rendered_path = f"[grey53]disabled by {disabled_by or 'PLUGINS configuration'}[/grey53]"
|
||||
|
||||
row = {
|
||||
"plugin": plugin_name,
|
||||
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
|
||||
"binary": display_name,
|
||||
"version": version_str if valid else "-",
|
||||
"provider": provider if valid else "-",
|
||||
"path": rendered_path,
|
||||
"style": "" if plugin_enabled else "dim",
|
||||
"enabled": plugin_enabled,
|
||||
"valid": valid,
|
||||
"plugins": [plugin_name],
|
||||
"disabled_by": [disabled_by or "PLUGINS configuration"] if not plugin_enabled else [],
|
||||
}
|
||||
existing = rows_by_binary.get(display_name)
|
||||
if existing is None:
|
||||
rows_by_binary[display_name] = row
|
||||
else:
|
||||
consumers = list(dict.fromkeys([*existing["plugins"], plugin_name]))
|
||||
disabled_reasons = list(dict.fromkeys([*existing["disabled_by"], *row["disabled_by"]]))
|
||||
# Disabled consumers cannot mask an active requirement. If any
|
||||
# active request fails (e.g. a version floor), keep that failure.
|
||||
if plugin_enabled and (not existing["enabled"] or not valid):
|
||||
existing.update(row)
|
||||
existing["plugins"] = consumers
|
||||
existing["disabled_by"] = disabled_reasons
|
||||
existing["plugin"] = ", ".join(consumers)
|
||||
if not existing["enabled"]:
|
||||
existing["path"] = f"[grey53]disabled by {', '.join(disabled_reasons)}[/grey53]"
|
||||
|
||||
rows = list(rows_by_binary.values())
|
||||
|
||||
if console.is_terminal and console.width < 120:
|
||||
from rich.text import Text
|
||||
|
||||
for row in rows:
|
||||
heading = Text.from_markup(str(row["status"]))
|
||||
heading.append(f" {row['binary']} {row['version']} ({row['provider']}) · {row['plugin']}")
|
||||
prnt(heading, style=str(row["style"]))
|
||||
path = row["path"]
|
||||
prnt(Text.assemble(" ", path if isinstance(path, Text) else Text.from_markup(str(path))), style=str(row["style"]))
|
||||
else:
|
||||
table = binary_dependency_table(rows)
|
||||
table.expand = False
|
||||
table.title = None
|
||||
table.box = None
|
||||
for column in table.columns:
|
||||
column.width = None
|
||||
column.max_width = None
|
||||
column.ratio = None
|
||||
prnt(table)
|
||||
|
||||
if not any_rows:
|
||||
prnt("", "[grey53]No required binaries declared for discovered plugins.[/grey53]")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Explicit discovery of portable snapshots; no checkpoint or default tree walk."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from itertools import groupby
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from rich import print
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.misc.util import parse_date
|
||||
|
||||
|
||||
def _directories(parent: Path):
|
||||
# Names encode dates / UUIDv7 creation time. Newest first within each
|
||||
# subtree, with bounded memory and no traversal into plugin payloads.
|
||||
with os.scandir(parent) as entries:
|
||||
paths = [Path(entry.path) for entry in entries if entry.is_dir(follow_symlinks=False)]
|
||||
yield from sorted(paths, key=lambda path: path.name, reverse=True)
|
||||
|
||||
|
||||
def _snapshot_directories(reverse=False):
|
||||
dates = []
|
||||
for user in _directories(CONSTANTS.USERS_DIR):
|
||||
root = user / CONSTANTS.SNAPSHOTS_DIR_NAME
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
continue
|
||||
dates.extend(_directories(root))
|
||||
dates.sort(key=lambda path: path.name, reverse=not reverse)
|
||||
for _, date_group in groupby(dates, key=lambda path: path.name):
|
||||
directories = [directory for date in date_group for domain in _directories(date) for directory in _directories(domain)]
|
||||
yield from sorted(directories, key=lambda path: path.name, reverse=not reverse)
|
||||
|
||||
|
||||
def _fields(record, names):
|
||||
return {name: record[name] for name in names.split() if name in record}
|
||||
|
||||
|
||||
def _dates(record):
|
||||
return {name: parse_date(record[name]) for name in ("created_at", "modified_at") if record.get(name)}
|
||||
|
||||
|
||||
def _restore_dates(obj, record):
|
||||
dates = _dates(record)
|
||||
if dates:
|
||||
type(obj).objects.filter(pk=obj.pk).update(**dates)
|
||||
for name, value in dates.items():
|
||||
setattr(obj, name, value)
|
||||
|
||||
|
||||
def _reconcile_directory(directory: Path) -> str:
|
||||
snapshot_id = uuid.UUID(directory.name)
|
||||
snapshot = Snapshot.objects.select_related("crawl__created_by").filter(pk=snapshot_id).first()
|
||||
index = directory / CONSTANTS.JSONL_INDEX_FILENAME
|
||||
if index.is_symlink():
|
||||
raise ValueError("metadata must not be a symlink")
|
||||
if not index.exists():
|
||||
if snapshot is None or Path(snapshot.output_dir) != directory or snapshot.status != "sealed":
|
||||
raise ValueError("missing index.jsonl; cannot reconstruct an unknown or active snapshot")
|
||||
snapshot.write_index_jsonl(output_dir=directory)
|
||||
snapshot.reconcile_filesystem_links()
|
||||
return "Repaired"
|
||||
|
||||
records = [json.loads(line) for line in index.read_text().splitlines() if line.strip()]
|
||||
if any(not isinstance(record, dict) for record in records):
|
||||
raise ValueError("metadata records must be JSON objects")
|
||||
snapshots = [record for record in records if record.get("type") == "Snapshot"]
|
||||
if len(snapshots) != 1:
|
||||
raise ValueError("expected exactly one Snapshot record")
|
||||
record = snapshots[0]
|
||||
if uuid.UUID(record["id"]) != snapshot_id:
|
||||
raise ValueError("snapshot ID conflicts with directory name")
|
||||
if record.get("status") != "sealed":
|
||||
raise ValueError("snapshot is not sealed; finish the source capture first")
|
||||
if record.get("fs_version") not in {Snapshot._fs_current_version(), *Snapshot._FS_VERSION_MIGRATION_PATHS}:
|
||||
raise ValueError("unsupported filesystem version")
|
||||
crawl_id = uuid.UUID(record["crawl_id"])
|
||||
owner = directory.parents[3].name
|
||||
crawl_records = [item for item in records if item.get("type") == "Crawl" and uuid.UUID(item["id"]) == crawl_id]
|
||||
crawl_record = crawl_records[0] if crawl_records else {}
|
||||
if crawl_record.get("created_by", owner) != owner:
|
||||
raise ValueError("crawl owner conflicts with directory path")
|
||||
crawl = Crawl.objects.select_related("created_by").filter(pk=crawl_id).first()
|
||||
imported_crawl = crawl is None
|
||||
if crawl and crawl.created_by.username != owner:
|
||||
raise ValueError("existing crawl ID belongs to a different owner")
|
||||
if snapshot and (snapshot.url != record["url"] or snapshot.crawl_id != crawl_id or snapshot.timestamp != record["timestamp"]):
|
||||
raise ValueError("existing snapshot ID has conflicting identity; refusing to overwrite")
|
||||
if snapshot and Path(snapshot.output_dir) != directory:
|
||||
raise ValueError("existing snapshot has a different storage path")
|
||||
|
||||
# Validate identities before inserting anything. Never turn an ID collision
|
||||
# into a new ID, and never overwrite a newer destination row.
|
||||
# write_index_jsonl retains historical hook records before appending the
|
||||
# canonical DB rows. The final record for each hook is authoritative.
|
||||
results = {(item.get("plugin"), item.get("hook_name", "")): item for item in records if item.get("type") == "ArchiveResult"}.values()
|
||||
pending = []
|
||||
for result in results:
|
||||
result_id = uuid.UUID(result["id"])
|
||||
if uuid.UUID(result["snapshot_id"]) != snapshot_id:
|
||||
raise ValueError("ArchiveResult belongs to another snapshot")
|
||||
existing = ArchiveResult.objects.filter(pk=result_id).first()
|
||||
identity = (snapshot_id, result["plugin"], result.get("hook_name", ""))
|
||||
if existing:
|
||||
if (existing.snapshot_id, existing.plugin, existing.hook_name) != identity:
|
||||
raise ValueError("existing ArchiveResult ID has conflicting identity")
|
||||
elif ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin=identity[1], hook_name=identity[2]).exists():
|
||||
raise ValueError("existing hook has a different ArchiveResult ID")
|
||||
else:
|
||||
pending.append(result)
|
||||
|
||||
imported = snapshot is None
|
||||
if imported:
|
||||
if Snapshot.objects.filter(timestamp=record["timestamp"]).exists():
|
||||
raise ValueError("timestamp belongs to another snapshot")
|
||||
if Snapshot.objects.filter(crawl_id=crawl_id, url=record["url"]).exists():
|
||||
raise ValueError("crawl URL belongs to another snapshot")
|
||||
user, _ = get_user_model().objects.get_or_create(
|
||||
username=owner,
|
||||
defaults={"is_active": False, "password": "!"},
|
||||
)
|
||||
if crawl is None:
|
||||
# Old exports contain only crawl_id. Keep that identity but report
|
||||
# that absent crawl metadata cannot be reconstructed exactly.
|
||||
if not crawl_record:
|
||||
print(f"[yellow] {directory}: no Crawl record; preserving ID with recovered metadata[/yellow]")
|
||||
crawl = Crawl(
|
||||
id=crawl_id,
|
||||
created_by=user,
|
||||
status="sealed",
|
||||
retry_at=None,
|
||||
**_fields(crawl_record, "urls max_depth config tags_str label"),
|
||||
)
|
||||
crawl.save(force_insert=True)
|
||||
_restore_dates(crawl, crawl_record)
|
||||
snapshot = Snapshot.create_from_directory(directory, crawl=crawl, preserve_identity=True)
|
||||
if snapshot is None:
|
||||
raise ValueError("invalid snapshot metadata")
|
||||
snapshot.status = "sealed"
|
||||
snapshot.retry_at = None
|
||||
if "config" not in record:
|
||||
snapshot.config = {"PERMISSIONS": "private"}
|
||||
if Path(snapshot.get_storage_path_for_version(Snapshot._fs_current_version())) != directory:
|
||||
raise ValueError("metadata does not match its owner/date/domain directory")
|
||||
snapshot.save(force_insert=True)
|
||||
_restore_dates(snapshot, record)
|
||||
|
||||
# Each insert is durable. An interruption after the Snapshot insert is not
|
||||
# completion: the next pass still imports missing result IDs and tags.
|
||||
for result in pending:
|
||||
snapshot._create_archive_result_if_missing(result, {}, preserve_identity=True)
|
||||
tags_before = set(snapshot.tags.values_list("name", flat=True))
|
||||
snapshot._merge_tags_from_index(record)
|
||||
tags_changed = tags_before != set(snapshot.tags.values_list("name", flat=True))
|
||||
link = Path(snapshot.crawl.output_dir) / "snapshots" / snapshot.extract_domain_from_url(snapshot.url) / str(snapshot.id)
|
||||
links_ok = link.is_symlink() and link.resolve() == directory.resolve()
|
||||
if not links_ok:
|
||||
snapshot.reconcile_filesystem_links()
|
||||
if snapshot.fs_migration_needed:
|
||||
snapshot.migrate_filesystem_to_current_version(source_dir=directory)
|
||||
if imported:
|
||||
_restore_dates(snapshot, record)
|
||||
if imported_crawl:
|
||||
_restore_dates(crawl, crawl_record)
|
||||
# Leave source metadata untouched, including process/binary records that
|
||||
# are historical evidence rather than runnable destination process state.
|
||||
return "Imported" if imported else "Repaired" if pending or tags_changed or not links_ok else "Unchanged"
|
||||
|
||||
|
||||
def reconcile_known_snapshots(snapshots, wait_for_turn=None):
|
||||
"""Explicit metadata / asset repair; normal updates never call this scan."""
|
||||
for snapshot in snapshots.filter(status="sealed").paged_iterator():
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
if snapshot.fs_migration_needed:
|
||||
snapshot.migrate_filesystem_to_current_version()
|
||||
directory = Path(snapshot.output_dir)
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"Missing snapshot directory: {directory}")
|
||||
_reconcile_directory(directory)
|
||||
for result in snapshot.archiveresult_set.all():
|
||||
result.update_output_metadata_from_filesystem(snapshot_dir=directory, full_scan=True)
|
||||
|
||||
|
||||
def rescan_snapshots(wait_for_turn=None, reverse=False, scan_legacy=None):
|
||||
stats = dict.fromkeys(("Imported", "Repaired", "Unchanged", "Errors"), 0)
|
||||
print("[*] Rescanning snapshot directories (O(N), orphans first; completed records are reused)...")
|
||||
known = {str(pk) for pk in Snapshot.objects.values_list("pk", flat=True)}
|
||||
# Two streaming passes avoid enumerating the entire archive before the
|
||||
# first import. Rows imported before interruption move to the second pass
|
||||
# next time; only one day's directory names are held in memory at a time.
|
||||
for existing in (False, True):
|
||||
if existing and scan_legacy:
|
||||
scan_legacy()
|
||||
for directory in _snapshot_directories(reverse=reverse):
|
||||
if (directory.name.replace("-", "") in known) != existing:
|
||||
continue
|
||||
if wait_for_turn:
|
||||
wait_for_turn()
|
||||
try:
|
||||
outcome = _reconcile_directory(directory)
|
||||
if directory.name.replace("-", "") in known:
|
||||
for result in ArchiveResult.objects.filter(snapshot_id=uuid.UUID(directory.name)):
|
||||
result.update_output_metadata_from_filesystem(snapshot_dir=directory, full_scan=True)
|
||||
stats[outcome] += 1
|
||||
if outcome != "Unchanged":
|
||||
print(f" {outcome}: {directory}")
|
||||
except (OSError, ValueError, KeyError, TypeError, ValidationError, IntegrityError) as err:
|
||||
stats["Errors"] += 1
|
||||
print(f"[yellow] Unresolved: {directory}: {err}[/yellow]")
|
||||
if sum(stats.values()) % 100 == 0:
|
||||
print(" " + ", ".join(f"{key}: {value}" for key, value in stats.items()))
|
||||
print(" " + ", ".join(f"{key}: {value}" for key, value in stats.items()))
|
||||
if stats["Errors"]:
|
||||
raise SystemExit(1)
|
||||
return stats
|
||||
@@ -17,7 +17,7 @@ CONFIG_FILE_HEADER = (
|
||||
"# You can add options here manually in INI format, or automatically by running:\n"
|
||||
"# archivebox config --set KEY=VALUE\n"
|
||||
"#\n"
|
||||
"# This file is kept in sync 1:1 with Machine.config in the index DB —\n"
|
||||
"# This file is kept in sync 1:1 with the current Machine.config in the DB —\n"
|
||||
"# editing either side propagates to the other. ``archivebox init`` reads\n"
|
||||
"# this file on startup; the admin Machine.config editor writes both.\n"
|
||||
"#\n"
|
||||
@@ -160,7 +160,7 @@ def _coerce_from_str_dict(file_config: dict[str, str]) -> dict[str, Any]:
|
||||
so they round-trip through INI's string-only storage. When reading the
|
||||
file back into ``Machine.config`` (a JSONField that holds native types)
|
||||
those strings have to be decoded — otherwise downstream consumers like
|
||||
``_emit_machine_config`` → ``MachineEvent`` → abx-dl see a JSON string
|
||||
``MachineEvent`` → abx-dl see a JSON string
|
||||
where they expect a dict and raise ``TypeError``.
|
||||
Declared fields go through pydantic-settings' own ``field_is_complex`` /
|
||||
``prepare_field_value`` so they're decoded per annotation. Undeclared
|
||||
|
||||
+27
-21
@@ -16,9 +16,9 @@ from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs
|
||||
from abx_plugins.plugins.base.utils import build_config_model
|
||||
from django.db import DatabaseError
|
||||
from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter, create_model, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from rich.console import Console
|
||||
|
||||
@@ -83,12 +83,16 @@ def base_url_from_legacy_server_config(raw_config: Mapping[str, object]) -> str:
|
||||
return candidate
|
||||
|
||||
listen_host = str(raw_config.get("LISTEN_HOST") or "").strip()
|
||||
if listen_host and listen_host.lower() != "archivebox.localhost:8000":
|
||||
if listen_host and listen_host.lower() not in {"archivebox.localhost:8000", "archivebox.localhost:5797"}:
|
||||
return normalize(listen_host)
|
||||
|
||||
csrf_origins = [normalize(origin) for origin in str(raw_config.get("CSRF_TRUSTED_ORIGINS") or "").split(",")]
|
||||
csrf_origins = [origin for origin in csrf_origins if origin]
|
||||
nondefault_csrf_origins = [origin for origin in csrf_origins if origin != "http://admin.archivebox.localhost:8000"]
|
||||
nondefault_csrf_origins = [
|
||||
origin
|
||||
for origin in csrf_origins
|
||||
if origin not in {"http://admin.archivebox.localhost:8000", "http://admin.archivebox.localhost:5797"}
|
||||
]
|
||||
if len(nondefault_csrf_origins) == 1:
|
||||
return nondefault_csrf_origins[0]
|
||||
|
||||
@@ -314,7 +318,7 @@ class ServerConfig(BaseConfigSet):
|
||||
)
|
||||
|
||||
SECRET_KEY: str = Field(default_factory=lambda: "".join(secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789_") for _ in range(50)))
|
||||
BIND_ADDR: str = Field(default="127.0.0.1:8000")
|
||||
BIND_ADDR: str = Field(default="127.0.0.1:5797")
|
||||
BASE_URL: str = Field(default="")
|
||||
ALLOWED_HOSTS: str = Field(default="*")
|
||||
CSRF_TRUSTED_ORIGINS: str = Field(default="")
|
||||
@@ -441,6 +445,7 @@ class ArchivingConfig(BaseConfigSet):
|
||||
default="",
|
||||
description="Comma-separated plugin selection for this run. Empty means use enabled plugin defaults.",
|
||||
)
|
||||
PARSER: str = Field(default="auto", description="Parser used to read the submitted Crawl input document.")
|
||||
|
||||
ONLY_NEW: bool = Field(default=True)
|
||||
INDEX_ONLY: bool = Field(default=False)
|
||||
@@ -460,9 +465,16 @@ class ArchivingConfig(BaseConfigSet):
|
||||
USER_AGENT: str = Field(
|
||||
default=f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
|
||||
)
|
||||
COOKIES_FILE: Path | None = Field(default=None)
|
||||
COOKIES_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
AUTH_STORAGE_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
URL_DENYLIST: str = Field(default=r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$", alias="URL_BLACKLIST")
|
||||
URL_DENYLIST: str = Field(
|
||||
default=(
|
||||
r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$"
|
||||
r"|^https?://news\.ycombinator\.com/(?:vote|hide)/?(?:[?#]|$)"
|
||||
),
|
||||
alias="URL_BLACKLIST",
|
||||
)
|
||||
URL_ALLOWLIST: str | None = Field(default=None, alias="URL_WHITELIST")
|
||||
|
||||
DEFAULT_PERSONA: str = Field(default="Default", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
@@ -601,13 +613,9 @@ def _explicit_plugin_enabled_keys(config: Mapping[str, object]) -> set[str]:
|
||||
|
||||
|
||||
def _discover_plugin_config_schemas() -> PluginSchemaDocuments:
|
||||
from archivebox.plugins.discovery import discover_plugin_configs
|
||||
from archivebox.plugins.discovery import get_plugin_config_resolver
|
||||
|
||||
schemas: PluginSchemaDocuments = {}
|
||||
if BASE_CONFIG_PATH.exists():
|
||||
schemas["base"] = json.loads(BASE_CONFIG_PATH.read_text())
|
||||
schemas.update(discover_plugin_configs())
|
||||
return schemas
|
||||
return get_plugin_config_resolver().schemas
|
||||
|
||||
|
||||
def _plugin_config_properties(plugin_schemas: PluginSchemaDocuments) -> dict[str, dict[str, Any]]:
|
||||
@@ -856,13 +864,11 @@ class ArchiveBoxBaseConfig(
|
||||
disabled_plugins = [plugin_name for plugin_name, enabled_key in enabled_config_keys.items() if not getattr(self, enabled_key)]
|
||||
selected_plugin_roots = plugin_names
|
||||
if selected_plugin_roots:
|
||||
from abx_dl.models import discover_plugins, filter_plugins
|
||||
from archivebox.plugins.discovery import get_plugin_catalog
|
||||
|
||||
selected_plugins = set(
|
||||
filter_plugins(
|
||||
discover_plugins(runtime="archivebox"),
|
||||
get_plugin_catalog().select(
|
||||
sorted(selected_plugin_roots),
|
||||
include_providers=True,
|
||||
disabled_names=disabled_plugins,
|
||||
),
|
||||
)
|
||||
@@ -1195,7 +1201,6 @@ def get_config(
|
||||
|
||||
explicit_plugin_enabled_keys: set[str] = set()
|
||||
if resolve_plugins:
|
||||
plugin_schemas = {plugin_name: schema for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)}
|
||||
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
|
||||
crawl_selected_plugins = crawl_config_base and bool(_normalize_plugins_config_value(dict(crawl.config or {}).get("PLUGINS")))
|
||||
# A frozen crawl-level PLUGINS selector is the exact extractor set for
|
||||
@@ -1219,8 +1224,9 @@ def get_config(
|
||||
**_plugin_user_config(_plugin_input_config(file_config)),
|
||||
**plugin_user_config,
|
||||
}
|
||||
plugin_sections = resolve_plugin_configs(
|
||||
plugin_schemas,
|
||||
from archivebox.plugins.discovery import get_plugin_config_resolver
|
||||
|
||||
plugin_sections = get_plugin_config_resolver().resolve(
|
||||
global_config=plugin_global_config,
|
||||
user_config=plugin_user_config,
|
||||
environ={},
|
||||
@@ -1258,7 +1264,7 @@ def get_config(
|
||||
config = config.model_copy(update={"SERVER_SECURITY_MODE": "safe-subdomains-fullreplay"})
|
||||
for key in explicit_plugin_enabled_keys:
|
||||
if key in config_data:
|
||||
setattr(config, key, config_data[key])
|
||||
setattr(config, key, TypeAdapter(bool).validate_python(config_data[key]))
|
||||
if config.PLUGINS:
|
||||
config._derive_plugin_enabled_config(respect_current_enabled=True)
|
||||
if redact_sensitive:
|
||||
|
||||
+21
-13
@@ -85,23 +85,31 @@ def get_collection_id(DATA_DIR=DATA_DIR) -> str:
|
||||
|
||||
|
||||
@cache
|
||||
def get_machine_id() -> str:
|
||||
"""Get a short, stable, unique ID for the current machine (e.g. abc45678)"""
|
||||
def get_machine_id(length: int = 8) -> str:
|
||||
"""Get a machine ID, using the network identity when no OS ID is available."""
|
||||
import machineid
|
||||
|
||||
MACHINE_ID = "unknown"
|
||||
try:
|
||||
import machineid
|
||||
return machineid.hashed_id("archivebox")[:length]
|
||||
except machineid.MachineIdNotFound:
|
||||
import uuid
|
||||
|
||||
MACHINE_ID = machineid.hashed_id("archivebox")[:8]
|
||||
except Exception:
|
||||
try:
|
||||
import uuid
|
||||
import hashlib
|
||||
import psutil
|
||||
|
||||
MACHINE_ID = hashlib.sha256(str(uuid.getnode()).encode()).hexdigest()[:8]
|
||||
except Exception:
|
||||
pass
|
||||
return MACHINE_ID
|
||||
node = uuid.getnode()
|
||||
identity = str(node)
|
||||
if node & (1 << 40):
|
||||
# uuid.getnode() silently generates a per-process random value when
|
||||
# tools such as ip/ifconfig are absent. Read interfaces directly so
|
||||
# separate CLI processes agree even in a minimal container.
|
||||
addresses = sorted(
|
||||
address.address
|
||||
for interface in psutil.net_if_addrs().values()
|
||||
for address in interface
|
||||
if address.family == psutil.AF_LINK and address.address not in ("", "00:00:00:00:00:00")
|
||||
)
|
||||
identity = str(int(addresses[0].replace(":", "").replace("-", ""), 16)) if addresses else f"hostname:{socket.gethostname()}"
|
||||
return hashlib.sha256(identity.encode()).hexdigest()[:length]
|
||||
|
||||
|
||||
@cache
|
||||
|
||||
@@ -2,11 +2,9 @@ __package__ = "archivebox.core"
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from functools import reduce
|
||||
from operator import and_
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import admin
|
||||
@@ -24,7 +22,13 @@ from django.utils.text import smart_split
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.core.routes_util import build_snapshot_url
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_snapshot_output_anchor,
|
||||
)
|
||||
from archivebox.core.widgets import InlineTagEditorWidget
|
||||
from archivebox.machine.env_util import env_to_shell_exports
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
@@ -186,7 +190,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None, can_del
|
||||
if embed_path and result.status == "succeeded":
|
||||
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
|
||||
else:
|
||||
output_link = build_snapshot_url(snapshot_id, "", config=config)
|
||||
output_link = build_snapshot_detail_url(result.snapshot.archive_path_from_db, config=config)
|
||||
output_link_attr = html.escape(output_link, quote=True)
|
||||
|
||||
# Get version - try cmd_version field
|
||||
@@ -555,8 +559,8 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
try:
|
||||
queryset = self.get_queryset(request).filter(pk__in=selected)
|
||||
if not queryset.exists():
|
||||
snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
|
||||
return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
|
||||
snapshot = Snapshot.objects.filter(pk=request.GET.get("snapshot")).first()
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request) if snapshot else request.path)
|
||||
except (ValidationError, ValueError):
|
||||
return redirect(request.path)
|
||||
return delete_selected(self, request, queryset)
|
||||
@@ -568,7 +572,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
request.GET.clear()
|
||||
request.META["QUERY_STRING"] = ""
|
||||
try:
|
||||
handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
|
||||
handoff_snapshot = Snapshot.objects.filter(pk=handoff_snapshot).first()
|
||||
except (ValidationError, ValueError):
|
||||
handoff_snapshot = None
|
||||
saved_list_per_page = self.list_per_page
|
||||
@@ -582,7 +586,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
|
||||
).exists()
|
||||
):
|
||||
return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
|
||||
return redirect(build_snapshot_detail_url(handoff_snapshot.archive_path_from_db, request=request))
|
||||
return response
|
||||
finally:
|
||||
self.list_per_page = saved_list_per_page
|
||||
@@ -656,22 +660,40 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
|
||||
def get_snapshot_view_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
return build_snapshot_url(str(result.snapshot_id), request=request, config=request.archivebox_config)
|
||||
return build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
def get_output_view_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
output_path = result.embed_path()
|
||||
if not output_path:
|
||||
output_path = result.plugin or ""
|
||||
return build_snapshot_url(str(result.snapshot_id), output_path, request=request, config=config)
|
||||
output_path = get_snapshot_output_anchor(result.plugin, result.embed_path() or "")
|
||||
return build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
output_path=output_path,
|
||||
request=request,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def get_output_files_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=request.archivebox_config)}/?files=1"
|
||||
return build_snapshot_files_url(
|
||||
str(result.snapshot_id),
|
||||
result.plugin,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
def get_output_zip_url(self, result: ArchiveResult) -> str:
|
||||
return f"{self.get_output_files_url(result)}&download=zip"
|
||||
request = self.request
|
||||
return build_snapshot_zip_url(
|
||||
str(result.snapshot_id),
|
||||
result.plugin,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
@admin.display(description="Details", ordering="id")
|
||||
def details_link(self, result):
|
||||
@@ -729,7 +751,11 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
request = self.request
|
||||
return format_html(
|
||||
'<a href="{}"><b><code>[{}]</code></b> {} {}</a><br/>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
),
|
||||
snapshot_id[:8],
|
||||
result.snapshot.bookmarked_at.strftime("%Y-%m-%d %H:%M"),
|
||||
result.snapshot.url[:128],
|
||||
@@ -827,11 +853,14 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
config = request.archivebox_config
|
||||
# Determine output link path - use embed_path() which checks output_files
|
||||
embed_path = result.embed_path()
|
||||
output_path = embed_path if (result.status == "succeeded" and embed_path) else "index.html"
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
output_url = (
|
||||
build_snapshot_url(str(result.snapshot_id), embed_path, request=request, config=config)
|
||||
if result.status == "succeeded" and embed_path
|
||||
else build_snapshot_detail_url(result.snapshot.archive_path_from_db, request=request, config=config)
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}" class="output-link">↗️</a><pre>{}</pre>',
|
||||
build_snapshot_url(snapshot_id, output_path, request=request, config=config),
|
||||
output_url,
|
||||
result.output_str_for_display(),
|
||||
)
|
||||
|
||||
@@ -858,46 +887,16 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
)
|
||||
|
||||
def output_summary(self, result):
|
||||
snapshot_dir = Path(result.snapshot.output_dir)
|
||||
output_html = format_html(
|
||||
'<pre style="display: inline-block">{}</pre><br/>',
|
||||
result.output_str_for_display(),
|
||||
)
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
request = self.request
|
||||
output_html += format_html(
|
||||
'<a href="{}#all">See result files ...</a><br/><pre><code>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
)
|
||||
embed_path = result.embed_path() or ""
|
||||
path_from_embed = snapshot_dir / (embed_path or "")
|
||||
output_html += format_html(
|
||||
'<i style="padding: 1px">{}</i><b style="padding-right: 20px">/</b><i>{}</i><br/><hr/>',
|
||||
str(snapshot_dir),
|
||||
str(embed_path),
|
||||
)
|
||||
if os.access(path_from_embed, os.R_OK):
|
||||
root_dir = str(path_from_embed)
|
||||
else:
|
||||
root_dir = str(snapshot_dir)
|
||||
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
depth = root.replace(root_dir, "").count(os.sep) + 1
|
||||
if depth > 2:
|
||||
continue
|
||||
indent = " " * 4 * (depth)
|
||||
output_html += format_html('<b style="padding: 1px">{}{}/</b><br/>', indent, os.path.basename(root))
|
||||
indentation_str = " " * 4 * (depth + 1)
|
||||
for filename in sorted(files):
|
||||
is_hidden = filename.startswith(".")
|
||||
output_html += format_html(
|
||||
'<span style="opacity: {}.2">{}{}</span><br/>',
|
||||
int(not is_hidden),
|
||||
indentation_str,
|
||||
filename.strip(),
|
||||
)
|
||||
|
||||
return output_html + mark_safe("</code></pre>")
|
||||
output_html = format_html('<pre style="display: inline-block">{}</pre>', result.output_str_for_display())
|
||||
file_map = result.output_file_map()
|
||||
paths = sorted({path for key in file_map if (path := result.output_file_path(key, output_file_map=file_map))})
|
||||
if paths:
|
||||
output_html += format_html(
|
||||
'<br/><a href="{}">See result files ...</a><pre><code>{}</code></pre>',
|
||||
self.get_output_files_url(result),
|
||||
"\n".join(paths),
|
||||
)
|
||||
return output_html
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
|
||||
+260
-189
@@ -1,38 +1,56 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from types import SimpleNamespace
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed, Http404
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Q, Count, Exists, F, OuterRef, Prefetch
|
||||
from django import forms
|
||||
from django.template import Template, RequestContext
|
||||
from django.template import Context, Template, RequestContext
|
||||
from django.contrib.admin.helpers import ActionForm
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.util import htmldecode, urldecode
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.misc.serve_static import serve_static_with_byterange_support
|
||||
from archivebox.search.admin import SearchResultsAdminMixin, SearchResultsChangeList
|
||||
from archivebox.search.views import admin_snapshot_search_stream_view
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_role_output_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_snapshot_output_anchor,
|
||||
)
|
||||
from archivebox.core.tag_util import get_or_create_tag
|
||||
from archivebox.core.templatetags.core_tags import snapshot_thumbnail, snapshot_url_text
|
||||
from archivebox.plugins.hooks import discover_hooks
|
||||
from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_plugins
|
||||
from archivebox.plugins.discovery import get_plugin_icon, get_snapshot_role_names
|
||||
from archivebox.plugins.output_groups import (
|
||||
OUTPUT_GROUPS,
|
||||
display_plugin_name,
|
||||
output_group_for_plugin,
|
||||
order_output_plugins,
|
||||
plugin_icon_is_hidden,
|
||||
plugin_output_sizes,
|
||||
)
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
|
||||
|
||||
from archivebox.core.models import Tag, Snapshot, ArchiveResult
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.admin_archiveresults import render_archiveresults_list
|
||||
from archivebox.core.preview_util import EXTENSION_SCREENSHOT_PLUGIN
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_CHOICES,
|
||||
@@ -48,9 +66,12 @@ GLOBAL_CONTEXT = {}
|
||||
SNAPSHOT_PERMISSION_META = PERMISSIONS_META
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _plugin_sort_order() -> dict[str, int]:
|
||||
return {get_plugin_name(plugin): idx for idx, plugin in enumerate(get_plugins())}
|
||||
def _format_size_column(num_bytes: int) -> str:
|
||||
size = num_bytes
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit == "GB" else f"{ceil(size)} {unit}"
|
||||
size /= 1024
|
||||
|
||||
|
||||
class SnapshotActionForm(ActionForm):
|
||||
@@ -262,15 +283,28 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
rows = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
|
||||
.order_by("snapshot_id", "plugin")
|
||||
.values_list("snapshot_id", "plugin", "status", "output_size", "output_files")
|
||||
.only("snapshot_id", "plugin", "status", "output_str", "output_size", "output_files")
|
||||
)
|
||||
for snapshot_id, plugin, status, output_size, output_files in rows.iterator(chunk_size=1000):
|
||||
if plugin in seen_plugins[snapshot_id]:
|
||||
for result in rows.iterator(chunk_size=1000):
|
||||
if result.plugin in seen_plugins[result.snapshot_id]:
|
||||
continue
|
||||
seen_plugins[snapshot_id].add(plugin)
|
||||
output_results_by_snapshot[snapshot_id].append(
|
||||
SimpleNamespace(plugin=plugin, status=status, output_size=output_size, output_files=output_files),
|
||||
seen_plugins[result.snapshot_id].add(result.plugin)
|
||||
output_results_by_snapshot[result.snapshot_id].append(result)
|
||||
|
||||
card_results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
|
||||
for result in (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids)
|
||||
.exclude(plugin="")
|
||||
.only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_size",
|
||||
"output_files",
|
||||
)
|
||||
):
|
||||
card_results_by_snapshot[result.snapshot_id].append(result)
|
||||
|
||||
for obj in self.result_list:
|
||||
counts = status_counts_by_snapshot.get(obj.pk, {})
|
||||
@@ -294,6 +328,7 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
"is_sealed": obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED, obj.StatusChoices.PAUSED),
|
||||
}
|
||||
obj.__dict__["_admin_output_results"] = output_results_by_snapshot[obj.pk]
|
||||
obj.__dict__["_snapshot_card_results"] = card_results_by_snapshot[obj.pk]
|
||||
|
||||
def get_results(self, request):
|
||||
super().get_results(request)
|
||||
@@ -306,6 +341,7 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
class SnapshotAdminForm(forms.ModelForm):
|
||||
"""Custom form for Snapshot admin with tag editor widget."""
|
||||
|
||||
url = forms.CharField(widget=forms.TextInput)
|
||||
tags_editor = forms.CharField(
|
||||
label="Tags",
|
||||
required=False,
|
||||
@@ -346,19 +382,8 @@ class SnapshotAdminForm(forms.ModelForm):
|
||||
|
||||
# Parse and save tags from tags_editor
|
||||
tags_str = self.cleaned_data.get("tags_editor", "")
|
||||
if tags_str:
|
||||
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
|
||||
tags = []
|
||||
for name in tag_names:
|
||||
tag, _ = Tag.objects.get_or_create(
|
||||
name__iexact=name,
|
||||
defaults={"name": name},
|
||||
)
|
||||
tag = Tag.objects.filter(name__iexact=name).first() or tag
|
||||
tags.append(tag)
|
||||
instance.tags.set(tags)
|
||||
else:
|
||||
instance.tags.clear()
|
||||
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
|
||||
instance.save_tags(tag_names)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -367,9 +392,19 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
form = SnapshotAdminForm
|
||||
raw_id_fields = ("crawl", "parent_snapshot")
|
||||
list_select_related = ()
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request, form, formsets, change)
|
||||
tags_str = form.cleaned_data.get("tags_editor", "")
|
||||
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
|
||||
form.instance.save_tags(
|
||||
tag_names,
|
||||
created_by=request.user if request.user.is_authenticated else None,
|
||||
)
|
||||
|
||||
list_display = (
|
||||
"permissions_badge",
|
||||
"created_at",
|
||||
"created_at_display",
|
||||
"preview_icon",
|
||||
"title_str",
|
||||
"tags_inline",
|
||||
@@ -377,7 +412,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"files",
|
||||
"size_with_stats",
|
||||
)
|
||||
list_display_links = ("created_at",)
|
||||
list_display_links = ("created_at_display",)
|
||||
sort_fields = ("title_str", "created_at", "status", "crawl")
|
||||
readonly_fields = (
|
||||
"admin_actions",
|
||||
@@ -491,6 +526,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
inlines = [] # Removed TagInline, using TagEditorWidget instead
|
||||
list_per_page = 50
|
||||
|
||||
@admin.display(description="Date", ordering="created_at")
|
||||
def created_at_display(self, obj):
|
||||
created_at = timezone.localtime(obj.created_at)
|
||||
return format_html(
|
||||
'<span class="snapshot-created-at"><span class="snapshot-created-at__date">{}</span><span class="snapshot-created-at__time">{}</span></span>',
|
||||
date_format(created_at, "Y-m-d"),
|
||||
date_format(created_at, "g:i A"),
|
||||
)
|
||||
|
||||
action_form = SnapshotActionForm
|
||||
paginator = AcceleratedPaginator
|
||||
|
||||
@@ -556,20 +600,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
def get_snapshot_view_url(self, obj: Snapshot) -> str:
|
||||
request = self.request
|
||||
return build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
return build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=request.archivebox_config)
|
||||
|
||||
def get_snapshot_files_url(self, obj: Snapshot) -> str:
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)}/?files=1"
|
||||
return build_snapshot_files_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_snapshot_zip_url(self, obj: Snapshot) -> str:
|
||||
return f"{self.get_snapshot_files_url(obj)}&download=zip"
|
||||
request = self.request
|
||||
return build_snapshot_zip_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path("grid/", self.admin_site.admin_view(self.grid_view), name="grid"),
|
||||
path("search-stream/", self.admin_site.admin_view(self.search_stream_view), name="core_snapshot_search_stream"),
|
||||
path(
|
||||
"<path:object_id>/preview/<str:plugin>/<path:filename>",
|
||||
self.admin_site.admin_view(self.preview_view, cacheable=True),
|
||||
name="core_snapshot_preview",
|
||||
),
|
||||
path("<path:object_id>/redo-failed/", self.admin_site.admin_view(self.redo_failed_view), name="core_snapshot_redo_failed"),
|
||||
path(
|
||||
"<path:object_id>/set-permissions/",
|
||||
@@ -579,6 +629,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def preview_view(self, request, object_id, plugin, filename):
|
||||
"""Serve a saved image output from the authenticated admin origin."""
|
||||
snapshot = get_object_or_404(Snapshot, pk=object_id)
|
||||
if not self.has_view_or_change_permission(request, snapshot):
|
||||
raise PermissionDenied
|
||||
result = snapshot.archiveresult_set.filter(plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED).first()
|
||||
try:
|
||||
output_path = result.output_file_path(filename) if result else None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
output_path = None
|
||||
if not output_path:
|
||||
raise Http404("Snapshot image does not exist")
|
||||
request.archivebox_cache_policy = "private"
|
||||
response = serve_static_with_byterange_support(request, output_path, document_root=snapshot.output_dir)
|
||||
if response.status_code != 304 and not response.headers.get("Content-Type", "").lower().startswith("image/"):
|
||||
raise Http404("Snapshot preview is not an image")
|
||||
response.headers["Vary"] = "Cookie"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
def search_stream_view(self, request):
|
||||
return admin_snapshot_search_stream_view(self, request)
|
||||
|
||||
@@ -710,7 +780,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
return qs
|
||||
|
||||
@admin.display(description="👁", ordering="permissions")
|
||||
@admin.display(description="🔒", ordering="permissions")
|
||||
def permissions_badge(self, obj):
|
||||
permissions = obj.__dict__.get("snapshot_permissions")
|
||||
if permissions is None:
|
||||
@@ -856,7 +926,13 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
def status_info(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
favicon_url = build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)
|
||||
favicon_url = build_snapshot_role_output_url(
|
||||
obj,
|
||||
"list_icon",
|
||||
request=request,
|
||||
config=config,
|
||||
fallback_to_default=True,
|
||||
)
|
||||
return format_html(
|
||||
"""
|
||||
Archived: {} ({} files {})
|
||||
@@ -894,7 +970,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
show_title = bool(title_raw) and title_normalized != "pending..." and title_normalized != url_normalized
|
||||
css_class = "fetched" if show_title else "pending"
|
||||
|
||||
detail_url = build_web_url(f"/{obj.archive_path_from_db}/index.html", request=request, config=config)
|
||||
detail_url = build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config)
|
||||
title_html = ""
|
||||
if show_title:
|
||||
title_html = format_html(
|
||||
@@ -904,14 +980,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
urldecode(htmldecode(title_raw))[:128],
|
||||
)
|
||||
|
||||
favicon = self._get_favicon_data(obj) or {}
|
||||
favicon_html = (
|
||||
format_html(
|
||||
'<img class="snapshot-url-favicon" src="{}" alt="" loading="lazy" decoding="async" onerror="this.hidden=true">',
|
||||
favicon["url"],
|
||||
)
|
||||
if favicon.get("url")
|
||||
else ""
|
||||
)
|
||||
return format_html(
|
||||
'<a class="snapshot-title-detail-hitbox" href="{}" aria-label="Open snapshot details"></a>'
|
||||
"{}"
|
||||
'<div style="font-size: 11px; color: #64748b; margin-top: 2px;">'
|
||||
'<a href="{}"><code style="user-select: all;">{}</code></a>'
|
||||
'<div class="snapshot-title-url">'
|
||||
'<a class="snapshot-original-url" href="{}">{}<code style="user-select: all;">{}</code></a>'
|
||||
"</div>",
|
||||
detail_url,
|
||||
title_html,
|
||||
url_raw or obj.url,
|
||||
(url_raw or obj.url)[:128],
|
||||
favicon_html,
|
||||
snapshot_url_text(url_raw or obj.url),
|
||||
)
|
||||
|
||||
@admin.display(description="Tags", ordering="tag_count")
|
||||
@@ -938,90 +1026,47 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
return mark_safe(f'<span class="tags-inline-editor">{tags_html}</span>')
|
||||
|
||||
def _get_preview_data(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
def _get_favicon_data(self, obj):
|
||||
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is not None:
|
||||
results = [r for r in results if r.plugin in ("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")]
|
||||
else:
|
||||
results = list(
|
||||
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_files",
|
||||
"output_str",
|
||||
),
|
||||
if results is None:
|
||||
results = obj.archiveresult_set.filter(
|
||||
plugin__in=list_icon_plugins,
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
)
|
||||
urls = []
|
||||
for result in results:
|
||||
if result.plugin not in list_icon_plugins or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
try:
|
||||
output_path = result.embed_path()
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if output_path:
|
||||
urls.append(
|
||||
build_snapshot_url(str(obj.id), output_path, request=self.request, config=self.request.archivebox_config),
|
||||
)
|
||||
return {"url": urls[0], "fallbacks": ",".join(urls[1:])} if urls else None
|
||||
|
||||
def result_output_path(result, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
def result_urls(plugin: str, filenames: tuple[str, ...]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
for filename in filenames:
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
urls.append(build_snapshot_url(str(obj.id), output_path, request=request, config=config))
|
||||
return urls
|
||||
|
||||
screenshot_urls = result_urls("screenshot", ("screenshot.png",))
|
||||
extension_screenshot_urls = result_urls(EXTENSION_SCREENSHOT_PLUGIN, ("screenshot-1.png", "screenshot.png"))
|
||||
favicon_urls = result_urls("favicon", ("favicon.ico",))
|
||||
|
||||
if not screenshot_urls and not extension_screenshot_urls and not favicon_urls:
|
||||
return None
|
||||
|
||||
all_screenshot_urls = [*screenshot_urls, *extension_screenshot_urls]
|
||||
if all_screenshot_urls:
|
||||
img_url = all_screenshot_urls[0]
|
||||
fallbacks = [*all_screenshot_urls[1:], *favicon_urls]
|
||||
img_alt = "Screenshot"
|
||||
preview_class = "screenshot"
|
||||
else:
|
||||
img_url = favicon_urls[0]
|
||||
fallbacks = favicon_urls[1:]
|
||||
img_alt = "Favicon"
|
||||
preview_class = "favicon"
|
||||
|
||||
fallback_list = ",".join(fallbacks)
|
||||
onerror_js = (
|
||||
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
|
||||
"(this.src=this.dataset.fallbacks.split(',').shift(), "
|
||||
"this.dataset.fallbacks=this.dataset.fallbacks.split(',').slice(1).join(',')) : "
|
||||
"this.remove()"
|
||||
def _render_thumbnail(self, obj, width="100px", height="100px"):
|
||||
return snapshot_thumbnail(
|
||||
Context({"request": self.request, "CONFIG": self.request.archivebox_config}),
|
||||
obj,
|
||||
admin=True,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
return {
|
||||
"img_url": img_url,
|
||||
"img_alt": img_alt,
|
||||
"preview_class": preview_class,
|
||||
"onerror_js": onerror_js,
|
||||
"fallback_list": fallback_list,
|
||||
"favicon_url": favicon_urls[0] if favicon_urls else "",
|
||||
"favicon_fallback_list": ",".join(favicon_urls[1:]),
|
||||
}
|
||||
|
||||
@admin.display(description="", empty_value="")
|
||||
def url_favicon(self, obj):
|
||||
preview = self._get_preview_data(obj)
|
||||
if not preview:
|
||||
favicon = self._get_favicon_data(obj)
|
||||
if not favicon:
|
||||
return ""
|
||||
|
||||
favicon_url = preview.get("favicon_url") or ""
|
||||
favicon_url = favicon.get("url") or ""
|
||||
if not favicon_url:
|
||||
return ""
|
||||
fallback_list = preview.get("favicon_fallback_list") or ""
|
||||
fallback_list = favicon.get("fallbacks") or ""
|
||||
onerror_js = (
|
||||
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
|
||||
"(this.src=this.dataset.fallbacks.split(',').shift(), "
|
||||
@@ -1042,42 +1087,24 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Preview", empty_value="")
|
||||
def preview_icon(self, obj):
|
||||
preview = self._get_preview_data(obj)
|
||||
if not preview:
|
||||
return None
|
||||
|
||||
return format_html(
|
||||
'<img src="{}" alt="{}" class="snapshot-preview {}" decoding="async" loading="lazy" onerror="{}" data-fallbacks="{}">',
|
||||
preview["img_url"],
|
||||
preview["img_alt"],
|
||||
preview["preview_class"],
|
||||
preview["onerror_js"],
|
||||
preview["fallback_list"],
|
||||
'<a href="{}" title="Open snapshot details">{}</a>',
|
||||
build_snapshot_detail_url(obj.archive_path_from_db, request=self.request, config=self.request.archivebox_config),
|
||||
self._render_thumbnail(obj, height="38px"),
|
||||
)
|
||||
|
||||
@admin.display(description=" ", empty_value="")
|
||||
def snapshot_summary(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
preview = self._get_preview_data(obj)
|
||||
stats = self._get_progress_stats(obj)
|
||||
archive_size = stats["output_size"] or 0
|
||||
size_txt = printable_filesize(archive_size) if archive_size else "pending"
|
||||
screenshot_html = ""
|
||||
|
||||
if preview:
|
||||
screenshot_html = format_html(
|
||||
'<a href="{href}" title="Open snapshot live view" style="display:block; flex:0 0 220px; width:220px;">'
|
||||
'<img src="{src}" alt="{alt}" decoding="async" loading="lazy" onerror="{onerror}" data-fallbacks="{fallbacks}" '
|
||||
'style="display:block; width:100%; max-width:220px; aspect-ratio: 16 / 10; object-fit: cover; object-position: top; '
|
||||
'border-radius: 10px; border: 1px solid #e2e8f0; background: #f8fafc;">'
|
||||
"</a>",
|
||||
href=build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
src=preview["img_url"],
|
||||
alt=preview["img_alt"],
|
||||
onerror=preview["onerror_js"],
|
||||
fallbacks=preview["fallback_list"],
|
||||
)
|
||||
screenshot_html = format_html(
|
||||
'<a href="{}" title="Open snapshot live view" style="display:block;flex:0 0 220px;">{}</a>',
|
||||
build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config),
|
||||
self._render_thumbnail(obj, width="220px", height="138px"),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<div style="display:flex; gap:16px; align-items:flex-start;">'
|
||||
@@ -1092,18 +1119,19 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"</div>",
|
||||
screenshot_html,
|
||||
size_txt,
|
||||
build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
obj.archive_path,
|
||||
self.get_snapshot_files_url(obj),
|
||||
obj.archive_path_from_db,
|
||||
)
|
||||
|
||||
@admin.display(
|
||||
description="Files Saved",
|
||||
ordering="ar_succeeded_count",
|
||||
empty_value="",
|
||||
)
|
||||
def files(self, obj):
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is None:
|
||||
results = obj.archiveresult_set.only("plugin", "status", "output_size")
|
||||
results = obj.archiveresult_set.only("plugin", "status", "output_size", "output_files")
|
||||
|
||||
plugins_with_output: dict[str, ArchiveResult] = {}
|
||||
for result in results:
|
||||
@@ -1114,56 +1142,111 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
plugins_with_output.setdefault(result.plugin, result)
|
||||
|
||||
if not plugins_with_output:
|
||||
return mark_safe('<span style="opacity: 0.35;">...</span>')
|
||||
return ""
|
||||
|
||||
sorted_results = sorted(
|
||||
plugins_with_output.values(),
|
||||
key=lambda result: (_plugin_sort_order().get(result.plugin, 9999), result.plugin),
|
||||
)
|
||||
visible_results = sorted_results[:14]
|
||||
output = []
|
||||
sorted_results = [plugins_with_output[plugin] for plugin in order_output_plugins(plugins_with_output, plugin_output_sizes(results))]
|
||||
icons_by_group: dict[str, list[dict[str, Any]]] = {group_id: [] for group_id, _, _ in OUTPUT_GROUPS}
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
for result in visible_results:
|
||||
for result in sorted_results:
|
||||
if plugin_icon_is_hidden(result.plugin):
|
||||
continue
|
||||
icon = mark_safe(get_plugin_icon(result.plugin))
|
||||
if not icon.strip():
|
||||
continue
|
||||
output.append(
|
||||
format_html(
|
||||
'<a href="{}" class="exists-True" title="{}">{}</a>',
|
||||
build_web_url(f"/{obj.archive_path_from_db}/{result.plugin}/", request=request, config=config),
|
||||
result.plugin,
|
||||
icon,
|
||||
),
|
||||
)
|
||||
if len(sorted_results) > len(visible_results):
|
||||
output.append(
|
||||
format_html(
|
||||
'<span title="{} more outputs">+{}</span>',
|
||||
len(sorted_results) - len(visible_results),
|
||||
len(sorted_results) - len(visible_results),
|
||||
),
|
||||
embed_path = result.embed_path()
|
||||
if not embed_path:
|
||||
continue
|
||||
group_id = output_group_for_plugin(result.plugin)
|
||||
anchor_path = get_snapshot_output_anchor(result.plugin, embed_path)
|
||||
icons_by_group[group_id].append(
|
||||
{
|
||||
"plugin": result.plugin,
|
||||
"icon": icon,
|
||||
"href": build_snapshot_detail_url(
|
||||
obj.archive_path_from_db,
|
||||
output_path=anchor_path,
|
||||
request=request,
|
||||
config=config,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
output = []
|
||||
for group_id, label, _ in OUTPUT_GROUPS:
|
||||
icons = icons_by_group[group_id]
|
||||
if icons:
|
||||
top = icons[0]
|
||||
front_index = 0
|
||||
pile_column = len(output)
|
||||
top_html = format_html(
|
||||
'<a href="{}" class="exists-True files-icon-pile-top files-icon-pile-cover files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-front-index:{};--files-icon-front-offset:{}px">{}</a>',
|
||||
top["href"],
|
||||
top["plugin"],
|
||||
label,
|
||||
label,
|
||||
front_index,
|
||||
front_index * 2,
|
||||
top["icon"],
|
||||
)
|
||||
members = []
|
||||
for icon_index, item in enumerate(icons):
|
||||
row = icon_index // 5
|
||||
row_count = min(5, len(icons) - row * 5)
|
||||
member_top = 8 + row * 18
|
||||
row_offset = (5 - row_count) * 11 + 3
|
||||
member_left = 3 + row_offset + (icon_index % 5) * 22
|
||||
members.append(
|
||||
format_html(
|
||||
'<a href="{}" class="exists-True files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-index:{};--files-icon-row:{};--files-icon-column:{};--files-icon-row-offset:{}px;--files-icon-member-left:{}px;--files-icon-member-top:{}px">{}</a>',
|
||||
item["href"],
|
||||
item["plugin"],
|
||||
display_plugin_name(item["plugin"]),
|
||||
item["plugin"],
|
||||
icon_index,
|
||||
row,
|
||||
icon_index % 5,
|
||||
row_offset,
|
||||
member_left,
|
||||
member_top,
|
||||
item["icon"],
|
||||
),
|
||||
)
|
||||
popup = format_html(
|
||||
'<span class="files-icon-pile-popup" popover="manual">{}<span class="files-icon-pile-label">{}</span></span>',
|
||||
mark_safe("".join(str(icon) for icon in members)),
|
||||
label,
|
||||
)
|
||||
pile_contents = format_html("{}{}", top_html, popup)
|
||||
output.append(
|
||||
format_html(
|
||||
'<span class="files-icon-pile files-icon-pile--{}" role="group" aria-label="{} saved outputs" style="--files-icon-popup-left:{}px;--files-icon-rows:{};--files-icon-card-width:{}px">{}</span>',
|
||||
group_id,
|
||||
label,
|
||||
pile_column * -22 - front_index * 2,
|
||||
(len(icons) + 4) // 5,
|
||||
116,
|
||||
pile_contents,
|
||||
),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<span class="files-icons files-icons--compact" style="font-size: 1em; opacity: 0.8;">{}</span>',
|
||||
'<span class="files-icons files-icons--compact" style="font-size: 1em;">{}</span>',
|
||||
mark_safe("".join(output)),
|
||||
)
|
||||
|
||||
@admin.display()
|
||||
@admin.display(empty_value="")
|
||||
def size(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
archive_size = self._get_progress_stats(obj)["output_size"] or 0
|
||||
if archive_size:
|
||||
size_txt = printable_filesize(archive_size)
|
||||
size_txt = _format_size_column(archive_size)
|
||||
if archive_size > 52428800:
|
||||
size_txt = mark_safe(f"<b>{size_txt}</b>")
|
||||
else:
|
||||
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
|
||||
return ""
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files">{}</a>',
|
||||
build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
self.get_snapshot_files_url(obj),
|
||||
size_txt,
|
||||
)
|
||||
|
||||
@@ -1233,6 +1316,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
@admin.display(
|
||||
description="Size",
|
||||
ordering="output_size",
|
||||
empty_value="",
|
||||
)
|
||||
def size_with_stats(self, obj):
|
||||
"""Show archive size with output size from archive results."""
|
||||
@@ -1241,24 +1325,11 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
size_bytes = output_size or 0
|
||||
|
||||
if size_bytes:
|
||||
size_txt = printable_filesize(size_bytes)
|
||||
size_txt = _format_size_column(size_bytes)
|
||||
if size_bytes > 52428800: # 50MB
|
||||
size_txt = mark_safe(f"<b>{size_txt}</b>")
|
||||
else:
|
||||
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
|
||||
|
||||
# Show hook statistics
|
||||
if stats["total"] > 0:
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files" style="white-space: nowrap;">'
|
||||
"{}</a>"
|
||||
'<div style="font-size: 10px; color: #94a3b8; margin-top: 2px;">'
|
||||
"{}/{} hooks</div>",
|
||||
self.get_snapshot_files_url(obj),
|
||||
size_txt,
|
||||
stats["succeeded"],
|
||||
stats["total"],
|
||||
)
|
||||
return ""
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files">{}</a>',
|
||||
@@ -1386,9 +1457,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
def url_str(self, obj):
|
||||
return format_html(
|
||||
'<a href="{}"><code style="user-select: all;">{}</code></a>',
|
||||
'<a class="snapshot-original-url" href="{}"><code style="user-select: all;">{}</code></a>',
|
||||
obj.url,
|
||||
obj.url[:128],
|
||||
snapshot_url_text(obj.url),
|
||||
)
|
||||
|
||||
@admin.display(description="Health", ordering="health")
|
||||
|
||||
@@ -7,12 +7,13 @@ from django.contrib import admin, messages
|
||||
from django.contrib.admin.options import IS_POPUP_VAR
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.db.models import Prefetch
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin
|
||||
from archivebox.core.models import SnapshotTag, Tag
|
||||
from archivebox.core.models import ArchiveResult, SnapshotTag, Tag
|
||||
from archivebox.core.tag_util import (
|
||||
TAG_HAS_SNAPSHOTS_CHOICES,
|
||||
TAG_SORT_CHOICES,
|
||||
@@ -24,7 +25,8 @@ from archivebox.core.tag_util import (
|
||||
normalize_has_snapshots_filter,
|
||||
normalize_tag_sort,
|
||||
)
|
||||
from archivebox.core.routes_util import build_snapshot_url
|
||||
from archivebox.core.routes_util import build_snapshot_role_output_url
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
|
||||
class TagInline(admin.TabularInline):
|
||||
@@ -191,7 +193,26 @@ class TagAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Snapshots")
|
||||
def snapshots(self, tag: Tag):
|
||||
snapshots = tag.snapshot_set.select_related("crawl__created_by").order_by("-downloaded_at", "-created_at", "-pk")[:10]
|
||||
snapshots = (
|
||||
tag.snapshot_set.select_related("crawl__created_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
.order_by("-downloaded_at", "-created_at", "-pk")[:10]
|
||||
)
|
||||
total_count = tag.snapshot_set.count()
|
||||
if not snapshots:
|
||||
return mark_safe(
|
||||
@@ -214,7 +235,12 @@ class TagAdmin(BaseModelAdmin):
|
||||
</a>
|
||||
""",
|
||||
reverse("admin:core_snapshot_change", args=[snapshot.pk]),
|
||||
build_snapshot_url(str(snapshot.pk), "favicon.ico"),
|
||||
build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
),
|
||||
title[:120],
|
||||
snapshot.url[:120],
|
||||
),
|
||||
|
||||
@@ -10,7 +10,6 @@ class CoreConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
"""Register the archivebox.core.admin_site as the main django admin site"""
|
||||
import sys
|
||||
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
|
||||
|
||||
from archivebox.core.admin_site import register_admin_site
|
||||
@@ -28,11 +27,6 @@ class CoreConfig(AppConfig):
|
||||
|
||||
pre_save.connect(truncate_overlong_charfields, dispatch_uid="archivebox_truncate_overlong_charfields")
|
||||
|
||||
# Import models to register state machines with the registry
|
||||
# Skip during makemigrations to avoid premature state machine access
|
||||
if "makemigrations" not in sys.argv:
|
||||
from archivebox.core import models # noqa: F401
|
||||
|
||||
def _should_prepare_runtime() -> bool:
|
||||
if os.environ.get("ARCHIVEBOX_RUNSERVER") == "1":
|
||||
if os.environ.get("ARCHIVEBOX_AUTORELOAD") == "1":
|
||||
|
||||
+19
-58
@@ -7,70 +7,31 @@ For more information on this file, see
|
||||
https://docs.djangoproject.com/en/stable/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from django.core.handlers.asgi import ASGIHandler
|
||||
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django(check_db=True)
|
||||
|
||||
|
||||
def _patch_thread_sensitive_context_shutdown() -> None:
|
||||
"""Stop ``ThreadSensitiveContext.__aexit__`` from blocking the daphne loop.
|
||||
|
||||
Django 6.0's ASGIHandler wraps every request in ``async with
|
||||
ThreadSensitiveContext():`` (django/core/handlers/asgi.py:169). On exit
|
||||
asgiref calls ``executor.shutdown()`` with the default ``wait=True``
|
||||
(asgiref/sync.py:148), which is a *synchronous* ``Thread.join()`` inside
|
||||
an async function — so it blocks the daphne event loop until the
|
||||
executor's worker thread exits.
|
||||
|
||||
That's normally fine because the request handler has already awaited
|
||||
every ``sync_to_async`` it submitted, so the worker is idle and dies as
|
||||
soon as the shutdown sentinel reaches it. The blocking turns into a
|
||||
problem when a client disconnects mid-request:
|
||||
|
||||
* ``SyncToAsync.__call__`` shields the executor work with
|
||||
``await asyncio.shield(exec_coro)`` (asgiref/sync.py:506) so that the
|
||||
sync DB call doesn't get torn down halfway through.
|
||||
* On cancellation it calls ``exec_coro.cancel()`` (line 522) which only
|
||||
flips the asyncio ``Future`` to cancelled — the underlying thread
|
||||
keeps running the SQL query.
|
||||
* Control unwinds to ``__aexit__`` while the orphaned thread is still
|
||||
mid-query. ``shutdown(wait=True)`` then blocks the event loop until
|
||||
that orphan finishes.
|
||||
|
||||
Under heavy SQLite contention (the load-test scenario that surfaced
|
||||
this on cabbage) those orphan threads can take 30 seconds each waiting
|
||||
for write locks, and the daphne loop is single-threaded — so every
|
||||
such orphan stalls every other in-flight request, healthchecks time
|
||||
out, and the container goes ``unhealthy``.
|
||||
|
||||
Switching to ``shutdown(wait=False)`` queues the sentinel and returns
|
||||
immediately; the worker thread still exits cleanly once its current
|
||||
task finishes, and asgiref's ``WeakKeyDictionary`` releases the
|
||||
executor as soon as the request's context is GC'd. No per-request
|
||||
teardown guarantee is lost — there was no caller relying on it.
|
||||
"""
|
||||
from asgiref import sync as _asgiref_sync
|
||||
|
||||
original_aexit = _asgiref_sync.ThreadSensitiveContext.__aexit__
|
||||
|
||||
async def __aexit__(self, exc, value, tb): # type: ignore[no-redef]
|
||||
if not self.token:
|
||||
return
|
||||
executor = _asgiref_sync.SyncToAsync.context_to_thread_executor.pop(self, None)
|
||||
if executor is not None:
|
||||
executor.shutdown(wait=False)
|
||||
_asgiref_sync.SyncToAsync.thread_sensitive_context.reset(self.token)
|
||||
|
||||
# Idempotent: only patch once even if asgi.py is reloaded.
|
||||
if getattr(original_aexit, "_archivebox_patched", False):
|
||||
return
|
||||
__aexit__._archivebox_patched = True # type: ignore[attr-defined]
|
||||
_asgiref_sync.ThreadSensitiveContext.__aexit__ = __aexit__
|
||||
class ArchiveBoxASGIHandler(ASGIHandler):
|
||||
def create_request(self, scope, body_file):
|
||||
# ASGI already spools the complete body. Django's multipart parser
|
||||
# otherwise treats chunked uploads without Content-Length as empty.
|
||||
if not any(name == b"content-length" for name, _value in scope.get("headers", [])):
|
||||
body_file.seek(0, 2)
|
||||
length = body_file.tell()
|
||||
body_file.seek(0)
|
||||
scope = {**scope, "headers": [*scope.get("headers", []), (b"content-length", str(length).encode("ascii"))]}
|
||||
return super().create_request(scope, body_file)
|
||||
|
||||
|
||||
_patch_thread_sensitive_context_shutdown()
|
||||
django_application = ArchiveBoxASGIHandler()
|
||||
|
||||
# Standard Django ASGI application (no websockets/channels needed)
|
||||
application = get_asgi_application()
|
||||
|
||||
async def application(scope, receive, send):
|
||||
if scope["type"] == "websocket":
|
||||
from archivebox.opencode.views import websocket_view
|
||||
|
||||
return await websocket_view(scope, receive, send)
|
||||
return await django_application(scope, receive, send)
|
||||
@@ -12,9 +12,9 @@ from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
|
||||
from archivebox.crawls.schedule_util import validate_schedule
|
||||
from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes
|
||||
from archivebox.personas.models import Persona
|
||||
from archivebox.plugins.discovery import get_plugins
|
||||
from archivebox.plugins.discovery import get_plugin_catalog
|
||||
from archivebox.plugins.forms import (
|
||||
PLUGIN_GROUP_DEFINITIONS,
|
||||
PLUGIN_GROUPS,
|
||||
TIMEOUT_INPUT_PATTERN,
|
||||
PluginConfigFormMixin,
|
||||
get_choice_field,
|
||||
@@ -333,10 +333,9 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
|
||||
if self.can_override_crawl_config:
|
||||
self.build_plugin_groups(get_config(persona=selected_persona) if selected_persona else get_config())
|
||||
else:
|
||||
all_plugins = get_plugins()
|
||||
for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS:
|
||||
get_choice_field(self, field_name).choices = [(p, p) for p in all_plugins if p in plugin_names]
|
||||
get_choice_field(self, "other_plugins").choices = [(p, p) for p in all_plugins]
|
||||
grouped_plugins = get_plugin_catalog().groups()
|
||||
for category, field_name, _title in PLUGIN_GROUPS:
|
||||
get_choice_field(self, field_name).choices = [(plugin.name, plugin.name) for plugin in grouped_plugins.get(category, [])]
|
||||
self.plugin_groups = []
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.http import HttpResponseForbidden, HttpResponseNotModified
|
||||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.http import http_date
|
||||
from django.utils.cache import patch_cache_control, patch_vary_headers
|
||||
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.config.common import get_config, get_request_config
|
||||
@@ -32,6 +33,7 @@ from archivebox.core.routes_util import (
|
||||
split_host_port,
|
||||
)
|
||||
from archivebox.core.views import OriginalDomainHostView, SnapshotHostView
|
||||
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL
|
||||
|
||||
ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
|
||||
|
||||
@@ -89,8 +91,9 @@ def AdminCookieIsolationMiddleware(get_response):
|
||||
response = get_response(request)
|
||||
|
||||
if request.path == "/admin" or request.path.startswith("/admin/"):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
||||
if not getattr(response, "xframe_options_exempt", False):
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
|
||||
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None or config.SERVER_SECURITY_MODE == "auto":
|
||||
@@ -122,13 +125,12 @@ def AdminCookieIsolationMiddleware(get_response):
|
||||
|
||||
|
||||
def CacheControlMiddleware(get_response):
|
||||
snapshot_path_re = re.compile(r"^/[^/]+/\\d{8}/[^/]+/[0-9a-fA-F-]{8,36}/")
|
||||
static_cache_key = (get_COMMIT_HASH() or VERSION or "dev").strip()
|
||||
|
||||
def middleware(request):
|
||||
response = get_response(request)
|
||||
|
||||
if request.path.startswith("/static/"):
|
||||
if request.path.startswith("/static/") and response.status_code in (200, 304):
|
||||
rel_path = request.path[len("/static/") :]
|
||||
static_path = finders.find(rel_path)
|
||||
if static_path:
|
||||
@@ -153,15 +155,30 @@ def CacheControlMiddleware(get_response):
|
||||
response.headers["Last-Modified"] = http_date(mtime)
|
||||
return response
|
||||
|
||||
if ("/archive/" in request.path or "/static/" in request.path or snapshot_path_re.match(request.path)) and not response.get(
|
||||
"Cache-Control",
|
||||
):
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
request.archivebox_config = config
|
||||
policy = "private" if config.PERMISSIONS == "private" else "public"
|
||||
response["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300"
|
||||
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
|
||||
# The same raw favicon bytes are shared across authenticated viewers.
|
||||
# Session middleware may have added Cookie to Vary during authorization.
|
||||
vary = [
|
||||
value.strip()
|
||||
for value in response.get("Vary", "").split(",")
|
||||
if value.strip().lower() not in {"", "cookie", "authorization"}
|
||||
]
|
||||
if vary:
|
||||
response["Vary"] = ", ".join(vary)
|
||||
else:
|
||||
response.headers.pop("Vary", None)
|
||||
return response
|
||||
|
||||
# Other private/unlisted artifacts remain private; auth errors stay unstored.
|
||||
snapshot_policy = getattr(request, "archivebox_cache_policy", None)
|
||||
if snapshot_policy and response.status_code in (200, 206, 304):
|
||||
if not response.get("Cache-Control"):
|
||||
response["Cache-Control"] = f"{snapshot_policy}, max-age=60, stale-while-revalidate=300"
|
||||
if snapshot_policy == "private":
|
||||
patch_cache_control(response, private=True)
|
||||
elif snapshot_policy or not response.get("Cache-Control"):
|
||||
response["Cache-Control"] = "private, no-store"
|
||||
patch_vary_headers(response, ("Cookie", "Authorization"))
|
||||
return response
|
||||
|
||||
return middleware
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Sum
|
||||
|
||||
|
||||
def consolidate_archiveresults_per_plugin(apps, schema_editor):
|
||||
ArchiveResult = apps.get_model("core", "ArchiveResult")
|
||||
Snapshot = apps.get_model("core", "Snapshot")
|
||||
duplicate_groups = ArchiveResult.objects.values("snapshot_id", "plugin").annotate(count=models.Count("id")).filter(count__gt=1)
|
||||
|
||||
affected_snapshot_ids = set()
|
||||
for group in duplicate_groups.iterator(chunk_size=200):
|
||||
rows = list(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id=group["snapshot_id"],
|
||||
plugin=group["plugin"],
|
||||
).order_by("created_at", "id"),
|
||||
)
|
||||
winner = max(
|
||||
rows,
|
||||
key=lambda row: (
|
||||
bool(row.output_files),
|
||||
int(row.output_size or 0),
|
||||
row.modified_at,
|
||||
str(row.id),
|
||||
),
|
||||
)
|
||||
output_files = {}
|
||||
mimetypes = set()
|
||||
for row in rows:
|
||||
output_files.update(row.output_files or {})
|
||||
mimetypes.update(part.strip() for part in (row.output_mimetypes or "").split(",") if part.strip())
|
||||
|
||||
winner.output_files = output_files
|
||||
winner.output_size = max(
|
||||
sum(int(metadata.get("size") or 0) for metadata in output_files.values() if isinstance(metadata, dict)),
|
||||
*(int(row.output_size or 0) for row in rows),
|
||||
)
|
||||
winner.output_mimetypes = ",".join(sorted(mimetypes))
|
||||
winner.start_ts = min((row.start_ts for row in rows if row.start_ts), default=None)
|
||||
winner.end_ts = max((row.end_ts for row in rows if row.end_ts), default=None)
|
||||
winner.save(update_fields=["output_files", "output_size", "output_mimetypes", "start_ts", "end_ts"])
|
||||
ArchiveResult.objects.filter(id__in=[row.id for row in rows if row.id != winner.id]).delete()
|
||||
affected_snapshot_ids.add(group["snapshot_id"])
|
||||
|
||||
for snapshot_id in affected_snapshot_ids:
|
||||
total = ArchiveResult.objects.filter(snapshot_id=snapshot_id).aggregate(total=Sum("output_size"))["total"] or 0
|
||||
Snapshot.objects.filter(id=snapshot_id).update(output_size=total)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0051_postgres_url_pattern_ops_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="archiveresult",
|
||||
name="unique_archiveresult_per_snapshot_hook",
|
||||
),
|
||||
migrations.RunPython(
|
||||
consolidate_archiveresults_per_plugin,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="archiveresult",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("snapshot", "plugin"),
|
||||
name="unique_archiveresult_per_snapshot_plugin",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0052_unique_archiveresult_per_snapshot_plugin"),
|
||||
("core", "0051_postgres_url_pattern_ops_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 6.1 on 2026-09-02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0053_alter_archiveresult_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="snapshot",
|
||||
name="current_step",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="snapshot",
|
||||
name="fs_version",
|
||||
field=models.CharField(
|
||||
db_index=True,
|
||||
default="0.9.0",
|
||||
help_text='Filesystem version of this snapshot (e.g., "0.7.0", "0.8.0", "0.9.4").',
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
+814
-1028
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
|
||||
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
|
||||
@@ -10,10 +10,15 @@ def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
|
||||
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
|
||||
|
||||
|
||||
def _canonical_hook_name(hook_name: str) -> str:
|
||||
hook_name = Path(hook_name).name
|
||||
return Path(hook_name).stem if Path(hook_name).suffix in {".py", ".js", ".sh"} else hook_name
|
||||
|
||||
|
||||
def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str | None = None) -> dict[str, int]:
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.services.archive_result_service import _collect_output_metadata
|
||||
from abx_dl.output_files import OutputManifest
|
||||
from archivebox.machine.models import Process
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Exists, OuterRef, Q, Subquery, Value
|
||||
@@ -31,9 +36,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
"crawls_queued_without_retry_at": 0,
|
||||
"snapshots_queued_without_retry_at": 0,
|
||||
"snapshots_sealed_with_extension_uploads_only": 0,
|
||||
"archiveresults_backoff": 0,
|
||||
"snapshots_queued_plugin_rows_waiting_on_stale_lease": 0,
|
||||
"archiveresults_started_without_running_process": 0,
|
||||
"archiveresults_interrupted_without_running_process": 0,
|
||||
"archiveresults_missing_for_orphaned_hook_processes": 0,
|
||||
"snapshots_started_without_running_results": 0,
|
||||
"crawls_started_with_due_snapshots": 0,
|
||||
@@ -41,10 +44,10 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
"crawls_started_without_active_snapshots": 0,
|
||||
}
|
||||
|
||||
running_archiveresults = ArchiveResult.objects.filter(
|
||||
snapshot_id=OuterRef("pk"),
|
||||
status=ArchiveResult.StatusChoices.STARTED,
|
||||
process__status=Process.StatusChoices.RUNNING,
|
||||
running_hook_processes = Process.objects.filter(
|
||||
archiveresult__snapshot_id=OuterRef("pk"),
|
||||
process_type=Process.TypeChoices.HOOK,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
)
|
||||
active_child_snapshots = Snapshot.objects.filter(
|
||||
crawl_id=OuterRef("pk"),
|
||||
@@ -104,49 +107,18 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
retry_at=now,
|
||||
modified_at=now,
|
||||
)
|
||||
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF, **result_filter)
|
||||
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED, **result_filter).exclude(
|
||||
process__status=Process.StatusChoices.RUNNING,
|
||||
)
|
||||
# ArchiveResult has no retry_at scheduler. Wake only the parent Snapshots
|
||||
# for result rows we are about to repair, then requeue those exact rows.
|
||||
# Using subqueries keeps million-row recovery in SQLite instead of building
|
||||
# Python ID lists or scanning all sealed snapshots.
|
||||
Snapshot.objects.filter(
|
||||
id__in=backoff_results.values("snapshot_id"),
|
||||
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
|
||||
retry_at__isnull=True,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
# ArchiveResult rows are projections, never work items. Close interrupted
|
||||
# projections as failed and wake their parent Snapshot so abx-dl can replay
|
||||
# the snapshot-level sequence. Indexed subqueries keep this bounded.
|
||||
Snapshot.objects.filter(
|
||||
id__in=orphaned_results.values("snapshot_id"),
|
||||
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
|
||||
retry_at__isnull=True,
|
||||
status=Snapshot.StatusChoices.STARTED,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
cleaned["archiveresults_backoff"] = backoff_results.update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
|
||||
# Targeted plugin rows on final/paused Snapshots are scheduled through the
|
||||
# parent Snapshot.retry_at. retry_at=NULL is the normal idle marker for a
|
||||
# sealed Snapshot and must not be interpreted as queued work just because
|
||||
# old/synthetic ArchiveResult rows exist. If takeover kills the runner
|
||||
# after it leases the Snapshot but before queued ArchiveResult rows finish,
|
||||
# the rows remain QUEUED while retry_at sits in the future. Recovery runs
|
||||
# only after this runner has won the single-runner gate, so it can safely
|
||||
# unlock those stale plugin leases for immediate processing instead of
|
||||
# waiting out the previous owner's full lock timeout.
|
||||
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED, **result_filter)
|
||||
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
|
||||
Snapshot.objects.filter(
|
||||
id__in=queued_plugin_results.values("snapshot_id"),
|
||||
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
|
||||
)
|
||||
.filter(retry_at__gt=now)
|
||||
.update(retry_at=now, modified_at=now)
|
||||
)
|
||||
# Impossible state repair: STARTED ArchiveResults without a live Process
|
||||
# have no owner left to emit completion. Requeue only the result row; the
|
||||
# snapshot/crawl schedulers will pick up normal retry processing.
|
||||
cleaned["archiveresults_started_without_running_process"] = orphaned_results.update(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
process=None,
|
||||
cleaned["archiveresults_interrupted_without_running_process"] = orphaned_results.update(
|
||||
status=ArchiveResult.StatusChoices.FAILED,
|
||||
modified_at=now,
|
||||
)
|
||||
orphaned_hook_processes = Process.objects.filter(
|
||||
@@ -168,14 +140,30 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
orphaned_hook_processes = orphaned_hook_processes.filter(snapshot_pwd_filter)
|
||||
else:
|
||||
orphaned_hook_processes = orphaned_hook_processes.none()
|
||||
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status", "started_at", "ended_at").order_by(
|
||||
"-started_at",
|
||||
"-id",
|
||||
):
|
||||
else:
|
||||
# Global startup recovery must stay database-only. Process.pwd points
|
||||
# into archive payload storage, which may be a cold FUSE mount; scanning
|
||||
# every historical unlinked hook here blocks the sole scheduler before
|
||||
# it can claim newly queued work. Explicit crawl recovery below remains
|
||||
# available for the work the caller asked to resume.
|
||||
orphaned_hook_processes = orphaned_hook_processes.none()
|
||||
for process in orphaned_hook_processes.only(
|
||||
"id",
|
||||
"pwd",
|
||||
"cmd",
|
||||
"process_type",
|
||||
"status",
|
||||
"exit_code",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
).order_by("-started_at", "-id"):
|
||||
hook_script_name = process.hook_script_name
|
||||
if not hook_script_name or not process.pwd:
|
||||
continue
|
||||
plugin_dir = Path(process.pwd)
|
||||
hook_name = _canonical_hook_name(hook_script_name)
|
||||
if crawl_snapshot_ids is not None and plugin_dir.parent.name not in crawl_snapshot_ids:
|
||||
continue
|
||||
try:
|
||||
@@ -187,42 +175,44 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
continue
|
||||
if snapshot is None:
|
||||
continue
|
||||
result, created = ArchiveResult.objects.get_or_create(
|
||||
snapshot=snapshot,
|
||||
plugin=plugin_dir.name,
|
||||
result, created = ArchiveResult.get_or_create_by_hook(
|
||||
snapshot,
|
||||
plugin_dir.name,
|
||||
hook_name,
|
||||
defaults={
|
||||
"hook_name": Path(hook_script_name).stem,
|
||||
"status": ArchiveResult.StatusChoices.QUEUED,
|
||||
"status": ArchiveResult.StatusChoices.FAILED,
|
||||
},
|
||||
)
|
||||
if created or result.status == ArchiveResult.StatusChoices.QUEUED:
|
||||
process_is_newer = bool(process.started_at and (result.start_ts is None or process.started_at >= result.start_ts))
|
||||
if created or process_is_newer:
|
||||
requeue_snapshot = False
|
||||
# A runner can die after the hook Process exits but before the
|
||||
# ProcessCompletedEvent projector links/finalizes ArchiveResult.
|
||||
# Reconstruct the plugin row from its newest durable Process row.
|
||||
output_files, output_size, output_mimetypes = _collect_output_metadata(plugin_dir)
|
||||
# Reconstruct only that exact hook row from the durable Process row.
|
||||
manifest = OutputManifest.scan(plugin_dir, containment_root=snapshot.output_dir)
|
||||
output_files = manifest.as_mapping()
|
||||
output_size = manifest.total_size
|
||||
output_mimetypes = ",".join(manifest.mimetypes)
|
||||
emitted_records = [
|
||||
record
|
||||
for record in Process.parse_records_from_text(process.stdout or "")
|
||||
if record.get("type") == "ArchiveResult"
|
||||
and (record.get("plugin") or plugin_dir.name) == plugin_dir.name
|
||||
and (record.get("hook_name") or Path(hook_script_name).stem) == Path(hook_script_name).stem
|
||||
and _canonical_hook_name(str(record.get("hook_name") or hook_name)) == hook_name
|
||||
]
|
||||
emitted_result = emitted_records[-1] if emitted_records else {}
|
||||
result.hook_name = Path(hook_script_name).stem
|
||||
result.process = process
|
||||
result.start_ts = process.started_at
|
||||
result.end_ts = process.ended_at
|
||||
if _is_signal_interrupted_exit(process.exit_code):
|
||||
# The owning runner died or was asked to stop while the hook was
|
||||
# still active. Keep the work item queued so takeover retries the
|
||||
# same hook; treating an unknown signal exit as success would
|
||||
# silently skip unfinished side effects.
|
||||
# The Process is a durable fact; record the interruption as a
|
||||
# failure and wake the parent Snapshot for replay.
|
||||
result.output_files = {}
|
||||
result.output_size = 0
|
||||
result.output_mimetypes = ""
|
||||
result.output_str = ""
|
||||
result.status = ArchiveResult.StatusChoices.QUEUED
|
||||
result.output_json = None
|
||||
result.status = ArchiveResult.StatusChoices.FAILED
|
||||
requeue_snapshot = True
|
||||
else:
|
||||
result.output_files = output_files
|
||||
@@ -246,7 +236,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
)
|
||||
result.save(
|
||||
update_fields=[
|
||||
"hook_name",
|
||||
"process",
|
||||
"start_ts",
|
||||
"end_ts",
|
||||
@@ -263,7 +252,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
|
||||
if created:
|
||||
cleaned["archiveresults_missing_for_orphaned_hook_processes"] += 1
|
||||
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
|
||||
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
**snapshot_filter,
|
||||
@@ -277,8 +265,8 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
# We only unlock scheduling; normal Snapshot runner code owns the next
|
||||
# transition and side effects.
|
||||
cleaned["snapshots_started_without_running_results"] = (
|
||||
started_snapshots.annotate(has_running_results=Exists(running_archiveresults))
|
||||
.filter(has_running_results=False)
|
||||
started_snapshots.annotate(has_running_process=Exists(running_hook_processes))
|
||||
.filter(has_running_process=False)
|
||||
.update(
|
||||
retry_at=now,
|
||||
modified_at=now,
|
||||
@@ -287,7 +275,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
|
||||
# Broken lock repair: STARTED + retry_at=NULL is an orphaned ownership
|
||||
# lease. Recovery only unlocks scheduling; the runner owns any subsequent
|
||||
# state-machine transition, including sealing rows whose children/results
|
||||
# lifecycle transition, including sealing rows whose children/results
|
||||
# are already final.
|
||||
recoverable_started_crawls = Crawl.objects.filter(status=Crawl.StatusChoices.STARTED).filter(
|
||||
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
|
||||
@@ -335,13 +323,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
"Finishing {count} browser-extension Snapshot(s) that received uploaded files before server extractors started "
|
||||
"(uploaded and server-created results will remain together on the same Snapshot)."
|
||||
),
|
||||
"archiveresults_backoff": (
|
||||
"Retrying {count} extractor result(s) that were waiting to retry "
|
||||
"(ArchiveBox may have been interrupted before it was able to try them again; affected outputs will be retried)."
|
||||
),
|
||||
"archiveresults_started_without_running_process": (
|
||||
"Retrying {count} extractor result(s) that were interrupted before finishing "
|
||||
"(ArchiveBox may have been interrupted before it was able to save their final status; partial files will be overwritten with fresh results upon retry)."
|
||||
"archiveresults_interrupted_without_running_process": (
|
||||
"Closing {count} interrupted extractor result projection(s) "
|
||||
"(their parent Snapshots are resumed through the normal snapshot-level runner)."
|
||||
),
|
||||
"snapshots_started_without_running_results": (
|
||||
"Resuming {count} Snapshot(s) that were interrupted before finishing "
|
||||
|
||||
+114
-23
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlsplit, urlunsplit
|
||||
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
@@ -11,6 +11,8 @@ from archivebox.config.common import get_config, get_request_config
|
||||
_SNAPSHOT_ID_RE = re.compile(r"^[0-9a-fA-F-]{8,36}$")
|
||||
_SNAPSHOT_SUBDOMAIN_RE = re.compile(r"^snap-(?P<suffix>[0-9a-fA-F]{12})$")
|
||||
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api")
|
||||
_URL_PATH_SAFE = "/@-._~!$&'()*+,;="
|
||||
_URL_FRAGMENT_SAFE = "/@-._~!$&'()*+,;="
|
||||
|
||||
|
||||
def split_host_port(host: str) -> tuple[str, str | None]:
|
||||
@@ -60,13 +62,6 @@ def _csrf_trusted_origins(config) -> list[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def _allowed_hosts(config) -> set[str]:
|
||||
raw = (config.ALLOWED_HOSTS or "").strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {entry.strip().lower() for entry in raw.split(",") if entry.strip() and entry.strip() != "*"}
|
||||
|
||||
|
||||
def derive_base_url_from_csrf(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
"""Pick a single CSRF_TRUSTED_ORIGINS entry to act as the implicit BASE_URL.
|
||||
|
||||
@@ -74,7 +69,7 @@ def derive_base_url_from_csrf(config: dict[str, Any] | None = None, **config_kwa
|
||||
had ``CSRF_TRUSTED_ORIGINS=https://archive.example.com`` set (required for
|
||||
admin login to work). On upgrade, ``BASE_URL`` is the new knob — but it
|
||||
defaults to empty, and falling through to ``BIND_ADDR`` produces an
|
||||
unreachable URL like ``http://0.0.0.0:8000``. If the user has exactly one
|
||||
unreachable URL like ``http://0.0.0.0:5797``. If the user has exactly one
|
||||
CSRF origin we treat it as the implicit BASE_URL so links/redirects keep
|
||||
pointing at the public hostname they already configured.
|
||||
|
||||
@@ -93,11 +88,6 @@ def get_listen_host(config: dict[str, Any] | None = None, **config_kwargs: Any)
|
||||
return (config.BIND_ADDR or "").strip()
|
||||
|
||||
|
||||
def get_listen_parts(config: dict[str, Any] | None = None, **config_kwargs: Any) -> tuple[str, str | None]:
|
||||
config = config or get_config(**config_kwargs)
|
||||
return split_host_port(get_listen_host(config=config))
|
||||
|
||||
|
||||
def _with_port(host: str, port: str | None) -> str:
|
||||
return f"{host}:{port}" if port else host
|
||||
|
||||
@@ -133,7 +123,7 @@ def canonical_base_host_for_request(request_host: str) -> str:
|
||||
"""Strip role subdomains and remap loopback hostnames to ``archivebox.localhost``.
|
||||
|
||||
Used by the banner suggestion and the in-browser pin endpoint: when the
|
||||
user is hitting the server on raw ``localhost:9292`` or ``127.0.0.1:9292``
|
||||
user is hitting the server on raw ``localhost:5797`` or ``127.0.0.1:5797``
|
||||
we want to suggest the wildcard-friendly ``archivebox.localhost`` family
|
||||
instead, so the eventual pinned ``BASE_URL`` plays nicely with subdomain
|
||||
routing without forcing the user to add a /etc/hosts entry.
|
||||
@@ -146,7 +136,7 @@ def canonical_base_host_for_request(request_host: str) -> str:
|
||||
|
||||
def _root_host_from_listen(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or get_config(**config_kwargs)
|
||||
listen_host, listen_port = get_listen_parts(config=config)
|
||||
listen_host, listen_port = split_host_port(get_listen_host(config=config))
|
||||
root_host = "archivebox.localhost" if _is_local_bind_host(listen_host) else listen_host
|
||||
return _with_port(root_host, listen_port) if root_host else ""
|
||||
|
||||
@@ -380,11 +370,6 @@ def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any]
|
||||
)
|
||||
|
||||
|
||||
def get_original_base_url(domain: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
|
||||
return _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
|
||||
|
||||
|
||||
def build_admin_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_admin_base_url(request, config=config, **config_kwargs), path)
|
||||
|
||||
@@ -394,11 +379,109 @@ def build_web_url(path: str = "", request=None, config: dict[str, Any] | None =
|
||||
|
||||
|
||||
def build_snapshot_url(snapshot_id: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), path)
|
||||
# `path` is a filesystem-relative output path, not an already-encoded URL.
|
||||
# In particular, wget can save literal `%3A` in filenames; emitting that
|
||||
# unchanged makes the browser request `:`, which cannot match the file.
|
||||
output_path = quote(str(path).lstrip("/"), safe=_URL_PATH_SAFE)
|
||||
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), output_path)
|
||||
|
||||
|
||||
def build_snapshot_detail_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
|
||||
"""Build the live Django snapshot-detail path and optional output anchor."""
|
||||
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
|
||||
detail_path = f"{prefix}{normalized_archive_path}"
|
||||
normalized_output_path = str(output_path or "").lstrip("#/")
|
||||
if not normalized_output_path:
|
||||
return detail_path
|
||||
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
|
||||
return f"{detail_path}#{fragment}"
|
||||
|
||||
|
||||
def build_snapshot_index_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
|
||||
"""Build a path to the literal exported ``index.html`` snapshot file."""
|
||||
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
|
||||
index_path = f"{prefix}{normalized_archive_path}/index.html"
|
||||
normalized_output_path = str(output_path or "").lstrip("#/")
|
||||
if not normalized_output_path:
|
||||
return index_path
|
||||
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
|
||||
return f"{index_path}#{fragment}"
|
||||
|
||||
|
||||
def get_snapshot_output_anchor(plugin: str, output_path: str) -> str:
|
||||
"""Map root-relative legacy outputs to the plugin card that displays them."""
|
||||
normalized_output_path = str(output_path or "").lstrip("/")
|
||||
return normalized_output_path if normalized_output_path.startswith(f"{plugin}/") else plugin
|
||||
|
||||
|
||||
def build_snapshot_detail_url(
|
||||
archive_path: str,
|
||||
output_path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware URL for trusted snapshot UI on the web origin."""
|
||||
return build_web_url(
|
||||
build_snapshot_detail_path(archive_path, output_path=output_path),
|
||||
request=request,
|
||||
config=config,
|
||||
**config_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
role: str,
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
archive_results=None,
|
||||
fallback_to_default: bool = False,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build the first available output URL for a plugin-owned snapshot role."""
|
||||
from archivebox.plugins.discovery import get_plugin_default_output_path, get_snapshot_role_names
|
||||
|
||||
for plugin in get_snapshot_role_names(role):
|
||||
output_path = snapshot.plugin_output_path(plugin, archive_results=archive_results)
|
||||
if not output_path and fallback_to_default:
|
||||
output_path = get_plugin_default_output_path(plugin)
|
||||
if output_path:
|
||||
return build_snapshot_url(str(snapshot.id), output_path, request=request, config=config, **config_kwargs)
|
||||
return ""
|
||||
|
||||
|
||||
def build_snapshot_files_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware directory-browser URL on the isolated replay origin."""
|
||||
directory_url = build_snapshot_url(snapshot_id, path, request=request, config=config, **config_kwargs)
|
||||
parts = urlsplit(directory_url)
|
||||
normalized_directory_path = f"{parts.path.rstrip('/')}/"
|
||||
directory_url = urlunsplit((parts.scheme, parts.netloc, normalized_directory_path, parts.query, parts.fragment))
|
||||
return _replace_url_query_params(directory_url, files="1")
|
||||
|
||||
|
||||
def build_snapshot_zip_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware ZIP download URL for a snapshot directory."""
|
||||
files_url = build_snapshot_files_url(snapshot_id, path, request=request, config=config, **config_kwargs)
|
||||
return _replace_url_query_params(files_url, download="zip")
|
||||
|
||||
|
||||
def build_original_url(domain: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_original_base_url(domain, request=request, config=config, **config_kwargs), path)
|
||||
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
|
||||
base_url = _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
|
||||
return _build_url(base_url, path)
|
||||
|
||||
|
||||
def _build_url(base_url: str, path: str) -> str:
|
||||
@@ -409,3 +492,11 @@ def _build_url(base_url: str, path: str) -> str:
|
||||
if not path:
|
||||
return base_url
|
||||
return f"{base_url}{path if path.startswith('/') else f'/{path}'}"
|
||||
|
||||
|
||||
def _replace_url_query_params(url: str, **params: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
replacement_keys = set(params)
|
||||
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key not in replacement_keys]
|
||||
query.extend((key, value) for key, value in params.items())
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
@@ -76,7 +76,6 @@ INSTALLED_APPS = [
|
||||
"archivebox.crawls", # handles Crawl and CrawlSchedule models and management (depends on core)
|
||||
"archivebox.progressmonitor", # live progress endpoint and admin monitor template
|
||||
"archivebox.api", # Django-Ninja-based Rest API interfaces, config, APIToken model, etc.
|
||||
"archivebox.opencode",
|
||||
# 3rd-party apps from PyPI that need to be loaded last
|
||||
"admin_data_views", # handles rendering some convenient automatic read-only views of data in Django admin
|
||||
"django_extensions", # provides Django Debug Toolbar (and other non-debug helpers)
|
||||
@@ -88,6 +87,7 @@ DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST"
|
||||
MIDDLEWARE = [
|
||||
"archivebox.core.middleware.TimezoneMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"archivebox.core.middleware.CacheControlMiddleware",
|
||||
"archivebox.core.middleware.AdminCookieIsolationMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
@@ -98,7 +98,6 @@ MIDDLEWARE = [
|
||||
"archivebox.core.middleware.ServerSecurityModeMiddleware",
|
||||
"archivebox.core.middleware.HostRoutingMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"archivebox.core.middleware.CacheControlMiddleware",
|
||||
# Additional middlewares from plugins (if any)
|
||||
]
|
||||
|
||||
@@ -391,7 +390,7 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
# get_base_url() also covers deployments that only set CSRF_TRUSTED_ORIGINS (the
|
||||
# implicit-BASE_URL fallback used on 0.7.x->0.9.x upgrades), so HTTPS hardening
|
||||
# isn't lost until BASE_URL is migrated. An explicit plain-http base (e.g. local
|
||||
# http://archivebox.localhost:8000) disables proxy HTTPS handling.
|
||||
# http://archivebox.localhost:5797) disables proxy HTTPS handling.
|
||||
BASE_URL_IS_HTTPS = get_base_url(config=CONFIG).strip().lower().startswith("https://")
|
||||
if BASE_URL_IS_HTTPS or not CONFIG.BASE_URL:
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
|
||||
@@ -101,6 +101,7 @@ def foreground_shutdown_signals(
|
||||
*,
|
||||
first_signal_message: str | None = "\n[🛑] Got {signal_name}, stopping gracefully...\n",
|
||||
on_signal: Callable[[signal.Signals], None] | None = None,
|
||||
interrupt_handlers: dict[signal.Signals, Callable[[], None]] | None = None,
|
||||
raise_on_first_signal: bool = True,
|
||||
) -> Iterator[ShutdownSignalState]:
|
||||
"""Install foreground signal handlers that print an immediate exit notice.
|
||||
@@ -118,10 +119,20 @@ def foreground_shutdown_signals(
|
||||
|
||||
state = ShutdownSignalState()
|
||||
previous_active_state = _active_shutdown_state
|
||||
previous_handlers = {sig: signal.getsignal(sig) for sig in handled_signals}
|
||||
previous_handlers = {sig: signal.getsignal(sig) for sig in (*handled_signals, *(interrupt_handlers or {}))}
|
||||
|
||||
def raise_keyboard_interrupt(signum, _frame):
|
||||
sig = signal.Signals(signum)
|
||||
if interrupt_handlers and sig in interrupt_handlers:
|
||||
# Shield resumable terminal intent from sticky shutdown state.
|
||||
# Ctrl+C is delegated to abx-dl's pause/abort controller; SIGUSR1/2
|
||||
# carry prompt answers. Recording any of these as shutdown here
|
||||
# makes a later skip/retry fail raise_if_shutdown_requested(), or
|
||||
# makes a second Ctrl+C hard-exit before hooks can flush output.
|
||||
# SIGTERM/SIGHUP deliberately bypass this shield: ownership takeover
|
||||
# and parent loss must cancel the old worker without asking for input.
|
||||
interrupt_handlers[sig]()
|
||||
return
|
||||
already_requested = state.signal_name is not None
|
||||
if not already_requested:
|
||||
state.signal_name = sig.name
|
||||
@@ -129,11 +140,9 @@ def foreground_shutdown_signals(
|
||||
os.write(sys.stdout.fileno(), first_signal_message.format(signal_name=state.signal_name).encode())
|
||||
if on_signal is not None:
|
||||
on_signal(sig)
|
||||
# Foreground `archivebox add` uses the first signal to abort the active
|
||||
# hook through the bus-facing runner code, then reserves the second
|
||||
# signal for hard foreground-command shutdown. Server/update/run and
|
||||
# other non-interactive commands raise immediately so their finally
|
||||
# blocks can stop owned children without prompting.
|
||||
# Actual shutdown requests remain sticky and a second signal forces
|
||||
# exit. Resumable interactive actions were handled above without
|
||||
# marking this context as shutting down.
|
||||
if already_requested:
|
||||
os.write(sys.stdout.fileno(), f"\n[🛑] Got {sig.name} again, exiting immediately.\n".encode())
|
||||
os._exit(130)
|
||||
@@ -142,7 +151,7 @@ def foreground_shutdown_signals(
|
||||
|
||||
try:
|
||||
_active_shutdown_state = state
|
||||
for sig in handled_signals:
|
||||
for sig in previous_handlers:
|
||||
signal.signal(sig, raise_keyboard_interrupt)
|
||||
yield state
|
||||
finally:
|
||||
|
||||
+31
-14
@@ -6,15 +6,16 @@ from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count, Exists, F, OuterRef, QuerySet
|
||||
from django.db.models import Count, Exists, F, OuterRef, Prefetch, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag, Tag
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
|
||||
from archivebox.misc.util import sanitize_html_text
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url
|
||||
from archivebox.core.models import Snapshot, SnapshotTag, Tag
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
|
||||
TAG_SNAPSHOT_PREVIEW_LIMIT = 10
|
||||
@@ -152,15 +153,8 @@ def get_or_create_tag(name: str, created_by: User | None = None) -> tuple[Tag, b
|
||||
if not normalized_name:
|
||||
raise ValueError("Tag name is required")
|
||||
|
||||
existing = Tag.objects.filter(name__iexact=normalized_name).first()
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
tag = Tag.objects.create(
|
||||
name=normalized_name,
|
||||
created_by=created_by,
|
||||
)
|
||||
return tag, True
|
||||
defaults = {"created_by": created_by} if created_by is not None else None
|
||||
return Tag.get_or_create_by_name(normalized_name, defaults=defaults)
|
||||
|
||||
|
||||
def rename_tag(tag: Tag, name: str) -> Tag:
|
||||
@@ -209,9 +203,16 @@ def _build_snapshot_preview(snapshot: Snapshot, request: HttpRequest | None = No
|
||||
"id": str(snapshot.pk),
|
||||
"title": _display_snapshot_title(snapshot),
|
||||
"url": snapshot.url,
|
||||
"favicon_url": build_snapshot_url(str(snapshot.pk), "favicon.ico", request=request, config=config),
|
||||
"favicon_url": build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
request=request,
|
||||
config=config,
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
),
|
||||
"admin_url": reverse("admin:core_snapshot_change", args=[snapshot.pk]),
|
||||
"archive_url": build_web_url(f"/{snapshot.archive_path_from_db}/index.html", request=request, config=config),
|
||||
"archive_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=config),
|
||||
"downloaded_at": snapshot.downloaded_at.isoformat() if snapshot.downloaded_at else None,
|
||||
}
|
||||
|
||||
@@ -228,6 +229,22 @@ def _build_snapshot_preview_map(
|
||||
snapshot_tags = (
|
||||
SnapshotTag.objects.filter(tag_id__in=tag_ids)
|
||||
.select_related("snapshot__crawl__created_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"snapshot__archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
"tag_id",
|
||||
F("snapshot__downloaded_at").desc(nulls_last=True),
|
||||
|
||||
@@ -76,6 +76,8 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
|
||||
|
||||
def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
"""Return the newest live local parent allowed to borrow runner/sonic."""
|
||||
from django.db.models import Q
|
||||
|
||||
from archivebox.machine.models import Machine, Process
|
||||
|
||||
machine = Machine.current()
|
||||
@@ -83,7 +85,13 @@ def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
machine=machine,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
pwd=str(data_dir),
|
||||
process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE),
|
||||
).filter(
|
||||
Q(process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE))
|
||||
# A direct `run` owns its own hooks, just as add/update own theirs via
|
||||
# a supervised one-shot worker. Respawning the server daemon must not
|
||||
# steal the gate back from that newer foreground command. Supervised
|
||||
# runner children cannot elect themselves above their parent command.
|
||||
| (Q(process_type=Process.TypeChoices.ORCHESTRATOR) & ~Q(parent__process_type=Process.TypeChoices.SUPERVISORD)),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
qs = qs.exclude(id=exclude_id)
|
||||
@@ -126,12 +134,25 @@ def ensure_daemon_stack(*, reason: str = ""):
|
||||
|
||||
supervisor = get_existing_supervisord_process() or get_or_create_supervisord_process(daemonize=False)
|
||||
worker = get_worker(supervisor, sonic_worker["name"])
|
||||
if isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING"):
|
||||
return worker
|
||||
if not (isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING")):
|
||||
if reason:
|
||||
rprint(f"[yellow][*] Starting daemon stack for {reason}...[/yellow]")
|
||||
worker = start_worker(supervisor, sonic_worker)
|
||||
|
||||
if reason:
|
||||
rprint(f"[yellow][*] Starting daemon stack for {reason}...[/yellow]")
|
||||
return start_worker(supervisor, sonic_worker)
|
||||
# Supervisord marks this worker RUNNING with startsecs=0, before Sonic has
|
||||
# opened its search socket. A query launched at that point fails even though
|
||||
# the daemon is about to become ready.
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
if is_port_listening(sonic_event.host, sonic_event.port):
|
||||
if time.monotonic() < deadline:
|
||||
return worker
|
||||
break
|
||||
worker = get_worker(supervisor, sonic_worker["name"])
|
||||
if not isinstance(worker, dict) or worker.get("statename") not in ("STARTING", "RUNNING"):
|
||||
raise RuntimeError(f"Sonic search backend worker failed to start: {worker}")
|
||||
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
|
||||
raise RuntimeError(f"Sonic search backend is not listening at {sonic_event.url}")
|
||||
|
||||
|
||||
def live_runner_processes(*, data_dir: str | Path):
|
||||
|
||||
@@ -1,38 +1,96 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import Truncator
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_role_output_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_admin_base_url,
|
||||
get_snapshot_base_url,
|
||||
get_web_base_url,
|
||||
)
|
||||
from archivebox.core.setup_wizard import get_base_url_mismatch_context, get_setup_wizard_context
|
||||
from archivebox.plugins.discovery import (
|
||||
get_plugin_catalog,
|
||||
get_plugin_icon,
|
||||
get_plugin_name,
|
||||
get_plugin_template,
|
||||
plugin_card_is_interactive,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
register.filter("plugin_card_is_interactive", plugin_card_is_interactive)
|
||||
|
||||
# Preview cards are clipped windows onto their content, never scroll areas.
|
||||
# Apply inside the document too: parent overflow cannot constrain an iframe.
|
||||
CARD_OVERFLOW_STYLE = (
|
||||
"<style>html,body{height:100%!important;min-height:0!important}"
|
||||
"html,body,body *{overflow:clip!important;scrollbar-width:none!important}</style>"
|
||||
)
|
||||
|
||||
|
||||
@register.filter
|
||||
def snapshot_url_text(url):
|
||||
"""Emphasize the hostname while preserving the exact selectable URL."""
|
||||
url = str(url or "")
|
||||
match = re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//([^/?#]*)", url)
|
||||
if not match:
|
||||
return escape(url)
|
||||
netloc = match[1]
|
||||
authority = netloc.rsplit("@", 1)[-1]
|
||||
start = match.start(1) + len(netloc) - len(authority)
|
||||
host_end = authority.find("]") + 1 if authority.startswith("[") else authority.find(":")
|
||||
hostname = authority[:host_end] if host_end > 0 else authority
|
||||
if hostname.lower().startswith("www."):
|
||||
start += 4
|
||||
hostname = hostname[4:]
|
||||
remainder = url[start + len(hostname) :]
|
||||
suffix = re.search(r"[?&#]", remainder)
|
||||
suffix_start = suffix.start() if suffix else len(remainder)
|
||||
return format_html(
|
||||
'<span class="snapshot-url-prefix">{}</span><span class="snapshot-url-domain">{}</span>'
|
||||
'<span class="snapshot-url-path">{}</span><span class="snapshot-url-suffix">{}</span>',
|
||||
url[:start],
|
||||
hostname,
|
||||
remainder[:suffix_start],
|
||||
remainder[suffix_start:],
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def plugin_ui(context, plugin: str, slot: str):
|
||||
"""Optional plugin UI must never prevent the host page from rendering."""
|
||||
try:
|
||||
source = get_plugin_template(plugin, slot, fallback=False)
|
||||
if not source:
|
||||
return ""
|
||||
return template.engines["django"].from_string(source).render(context.flatten())
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Unable to render optional plugin UI: %s/%s", plugin, slot)
|
||||
return ""
|
||||
|
||||
|
||||
_TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log")
|
||||
_IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif")
|
||||
_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="
|
||||
|
||||
_MEDIA_FILE_EXTS = {
|
||||
_VIDEO_FILE_EXTS = {
|
||||
".mp4",
|
||||
".webm",
|
||||
".mkv",
|
||||
@@ -49,6 +107,9 @@ _MEDIA_FILE_EXTS = {
|
||||
".3gp",
|
||||
".3g2",
|
||||
".ogv",
|
||||
}
|
||||
|
||||
_AUDIO_FILE_EXTS = {
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
@@ -66,16 +127,30 @@ _MEDIA_FILE_EXTS = {
|
||||
".dts",
|
||||
}
|
||||
|
||||
_MEDIA_FILE_EXTS = _VIDEO_FILE_EXTS | _AUDIO_FILE_EXTS
|
||||
_BROWSER_VIDEO_FILE_EXTS = {".mp4", ".webm", ".m4v", ".ogv"}
|
||||
_BROWSER_AUDIO_FILE_EXTS = {".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".wav", ".flac"}
|
||||
|
||||
|
||||
def _is_web_subdomain_alias(browser_url: str, configured_base_url: str) -> bool:
|
||||
"""Hide the informational mismatch banner on the conventional public web alias.
|
||||
|
||||
This is deliberately presentation-only: request routing and the restriction
|
||||
on state-changing requests continue to use the configured BASE_URL.
|
||||
"""
|
||||
browser = urlparse(browser_url)
|
||||
configured = urlparse(configured_base_url)
|
||||
return (
|
||||
browser.scheme.lower() == configured.scheme.lower()
|
||||
and browser.hostname == f"web.{configured.hostname}"
|
||||
and browser.port == configured.port
|
||||
)
|
||||
|
||||
|
||||
def _normalize_output_files(output_files: Any) -> dict[str, dict[str, Any]]:
|
||||
if isinstance(output_files, dict):
|
||||
normalized: dict[str, dict[str, Any]] = {}
|
||||
for path, metadata in output_files.items():
|
||||
if not path:
|
||||
continue
|
||||
normalized[str(path)] = dict(metadata) if isinstance(metadata, dict) else {}
|
||||
return normalized
|
||||
return {}
|
||||
from abx_dl.output_files import OutputManifest
|
||||
|
||||
return OutputManifest.from_value(output_files).as_mapping()
|
||||
|
||||
|
||||
def _snapshot_id(value: Any) -> Any:
|
||||
@@ -122,10 +197,10 @@ def _count_media_files(result) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _list_media_files(result) -> list[dict]:
|
||||
def _list_media_files(result, *, include_filesystem_fallback=True) -> list[dict]:
|
||||
media_files: list[dict] = []
|
||||
try:
|
||||
plugin_dir = Path(result.snapshot_dir) / result.plugin
|
||||
plugin_dir = Path(result.snapshot_dir) / result.plugin if include_filesystem_fallback else None
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return media_files
|
||||
|
||||
@@ -137,7 +212,7 @@ def _list_media_files(result) -> list[dict]:
|
||||
if rel_path.suffix.lower() in _MEDIA_FILE_EXTS:
|
||||
candidates.append((rel_path, _coerce_output_file_size(metadata.get("size"))))
|
||||
|
||||
if not candidates and plugin_dir.exists():
|
||||
if not candidates and plugin_dir is not None and plugin_dir.exists():
|
||||
scanned = 0
|
||||
max_scan = 2000
|
||||
for file_path in plugin_dir.rglob("*"):
|
||||
@@ -158,16 +233,34 @@ def _list_media_files(result) -> list[dict]:
|
||||
candidates.append((rel_path, size))
|
||||
|
||||
for rel_path, size in candidates:
|
||||
href = str(Path(result.plugin) / rel_path)
|
||||
if callable(getattr(result, "output_file_path", None)):
|
||||
href = result.output_file_path(str(rel_path), output_file_map=output_files)
|
||||
else:
|
||||
metadata = output_files.get(str(rel_path)) or {}
|
||||
href = str(rel_path) if metadata.get("root_relative") else str(Path(result.plugin) / rel_path)
|
||||
if not href:
|
||||
continue
|
||||
suffix = rel_path.suffix.lower()
|
||||
media_type = "video" if suffix in _VIDEO_FILE_EXTS else "audio"
|
||||
media_files.append(
|
||||
{
|
||||
"name": rel_path.name,
|
||||
"path": href,
|
||||
"size": size,
|
||||
"media_type": media_type,
|
||||
"is_video": media_type == "video",
|
||||
"is_audio": media_type == "audio",
|
||||
"is_browser_playable": suffix in _BROWSER_VIDEO_FILE_EXTS or suffix in _BROWSER_AUDIO_FILE_EXTS,
|
||||
},
|
||||
)
|
||||
|
||||
media_files.sort(key=lambda item: item["name"].lower())
|
||||
media_files.sort(
|
||||
key=lambda item: (
|
||||
0 if item["is_video"] and item["is_browser_playable"] else 1 if item["is_audio"] and item["is_browser_playable"] else 2,
|
||||
-int(item.get("size") or 0),
|
||||
item["name"].lower(),
|
||||
),
|
||||
)
|
||||
return media_files
|
||||
|
||||
|
||||
@@ -225,12 +318,14 @@ def _snapshot_base_url_for_context(context, snapshot) -> str:
|
||||
|
||||
def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
return build_snapshot_url(
|
||||
path_part, separator, query = str(path or "").partition("?")
|
||||
url = build_snapshot_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
path_part,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
return f"{url}?{query}" if separator else url
|
||||
|
||||
base_url = _static_snapshot_base_url(context, snapshot)
|
||||
raw_path = str(path or "")
|
||||
@@ -242,14 +337,6 @@ def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
|
||||
return f"{base_url.rstrip('/')}/{quoted_path}{suffix}"
|
||||
|
||||
|
||||
def _build_snapshot_files_url(snapshot_id: str, request=None, config=None, base_url: str | None = None) -> str:
|
||||
return (
|
||||
f"{base_url.rstrip('/')}/index.jsonl"
|
||||
if base_url
|
||||
else build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
|
||||
)
|
||||
|
||||
|
||||
def _build_snapshot_preview_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
@@ -261,20 +348,25 @@ def _build_snapshot_preview_url(
|
||||
if path == "about:blank":
|
||||
return path
|
||||
if _is_root_snapshot_output_path(path):
|
||||
return _build_snapshot_files_url(snapshot_id, request=request, config=config, base_url=base_url)
|
||||
return (
|
||||
f"{base_url.rstrip('/')}/index.jsonl"
|
||||
if base_url
|
||||
else build_snapshot_files_url(str(snapshot_id), request=request, config=config)
|
||||
)
|
||||
if base_url:
|
||||
path_part, separator, query = str(path).lstrip("/").partition("?")
|
||||
url = f"{base_url.rstrip('/')}/{quote(path_part, safe=_STATIC_URL_SAFE)}"
|
||||
if separator:
|
||||
url = f"{url}?{query}"
|
||||
else:
|
||||
url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
|
||||
path_part, separator, query = str(path).partition("?")
|
||||
url = build_snapshot_url(str(snapshot_id), path_part, request=request, config=config)
|
||||
if separator:
|
||||
url = f"{url}?{query}"
|
||||
path_parts = Path(path).parts
|
||||
plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "")
|
||||
has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False))
|
||||
if not (
|
||||
_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview or is_archivewebpage_replay_target(path or "")
|
||||
):
|
||||
if not (_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview):
|
||||
return url
|
||||
separator = "&" if "?" in url else "?"
|
||||
return f"{url}{separator}preview=1"
|
||||
@@ -285,7 +377,7 @@ def _render_text_preview(plugin: str, icon_html: str, snippet: str) -> str:
|
||||
plugin_label = escape(plugin or "")
|
||||
escaped = escape(snippet)
|
||||
return (
|
||||
f'<div class="thumbnail-text" data-plugin="{plugin_attr}" data-compact="1">'
|
||||
f'<div class="thumbnail-text" data-plugin="{plugin_attr}">'
|
||||
f'<div class="thumbnail-text-header">'
|
||||
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
|
||||
f'<span class="thumbnail-text-title">{plugin_label}</span>'
|
||||
@@ -300,7 +392,7 @@ def _render_fallback_card(plugin: str, icon_html: str, fallback_label: str) -> s
|
||||
plugin_label = escape(plugin or "")
|
||||
fallback_attr = escape(fallback_label)
|
||||
return (
|
||||
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}" data-compact="1">'
|
||||
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}">'
|
||||
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
|
||||
f'<span class="thumbnail-compact-label">{plugin_label}</span>'
|
||||
f'<span class="thumbnail-compact-meta">{fallback_attr}</span>'
|
||||
@@ -340,12 +432,29 @@ def index(value, position):
|
||||
|
||||
|
||||
@register.filter
|
||||
def file_size(num_bytes: float) -> str:
|
||||
def file_size(num_bytes: float, decimal_places: int = 1) -> str:
|
||||
for count in ["Bytes", "KB", "MB", "GB"]:
|
||||
if num_bytes > -1024.0 and num_bytes < 1024.0:
|
||||
return f"{num_bytes:3.1f} {count}"
|
||||
if num_bytes >= 0:
|
||||
precision = 10 ** max(0, int(decimal_places))
|
||||
num_bytes = math.ceil(num_bytes * precision) / precision
|
||||
return f"{num_bytes:.{decimal_places}f} {count}"
|
||||
num_bytes /= 1024.0
|
||||
return "{:3.1f} {}".format(num_bytes, "TB")
|
||||
if num_bytes >= 0:
|
||||
precision = 10 ** max(0, int(decimal_places))
|
||||
num_bytes = math.ceil(num_bytes * precision) / precision
|
||||
return f"{num_bytes:.{decimal_places}f} TB"
|
||||
|
||||
|
||||
@register.filter
|
||||
def grid_file_size(num_bytes: float | None) -> str:
|
||||
if not num_bytes or num_bytes <= 0:
|
||||
return ""
|
||||
if num_bytes < 1024**2:
|
||||
return f"{max(1, num_bytes / 1024):.0f}kb"
|
||||
if num_bytes < 1024**3:
|
||||
return f"{num_bytes / 1024**2:.0f}mb"
|
||||
return f"{num_bytes / 1024**3:.1f}gb"
|
||||
|
||||
|
||||
@register.filter
|
||||
@@ -416,9 +525,9 @@ def system_warnings_banner(context):
|
||||
"""Render the top-of-page warning banner for one of the conditions below,
|
||||
in priority order (highest first):
|
||||
|
||||
1. ``mode="unconfigured"``— ``BASE_URL`` is empty. Security/correctness
|
||||
issue: until it's pinned, generated URLs can echo any Host the client
|
||||
sends, and admin/web/api routing has no canonical anchor.
|
||||
1. ``mode="unconfigured"``— ``BASE_URL`` is empty and the operator has
|
||||
not selected safe single-domain routing. That mode intentionally uses
|
||||
each device's connection address, including LAN and Tailscale IPs.
|
||||
2. ``mode="base_url_mismatch"`` — the browser reached ArchiveBox through
|
||||
an origin that differs from the configured canonical ``BASE_URL``.
|
||||
3. ``mode="unsafe"`` — ``SERVER_SECURITY_MODE`` explicitly enables
|
||||
@@ -445,10 +554,10 @@ def system_warnings_banner(context):
|
||||
|
||||
config = get_config(resolve_plugins=False)
|
||||
|
||||
if not config.BASE_URL:
|
||||
if not config.BASE_URL and config.SERVER_SECURITY_MODE != "safe-onedomain-nojsreplay":
|
||||
return get_setup_wizard_context(context.get("request"), config)
|
||||
mismatch = get_base_url_mismatch_context(context.get("request"), config)
|
||||
if mismatch:
|
||||
if mismatch and not _is_web_subdomain_alias(mismatch["browser_url"], mismatch["configured_base_url"]):
|
||||
return mismatch
|
||||
if config.IS_LOWER_SECURITY_MODE:
|
||||
return {"mode": "unsafe"}
|
||||
@@ -512,66 +621,140 @@ def snapshot_base_url(context, snapshot) -> str:
|
||||
return _snapshot_base_url_for_context(context, snapshot)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_detail_url(context, snapshot) -> str:
|
||||
"""Keep the trusted snapshot detail page on web.*, not the replay host."""
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return _snapshot_url_for_context(context, snapshot, "index.html")
|
||||
return build_snapshot_detail_url(
|
||||
snapshot.archive_path_from_db,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_url(context, snapshot, path: str = "") -> str:
|
||||
return _snapshot_url_for_context(context, snapshot, path)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
|
||||
snapshot_id = str(_snapshot_id(snapshot))
|
||||
url_cache = snapshot.__dict__.setdefault("_snapshot_archiveresult_url_cache", {})
|
||||
def snapshot_files_url(context, snapshot, path: str = "") -> str:
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return _snapshot_url_for_context(context, snapshot, "index.jsonl")
|
||||
return build_snapshot_files_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_zip_url(context, snapshot, path: str = "") -> str:
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return ""
|
||||
return build_snapshot_zip_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_role_output_url(context, snapshot, role: str, fallback_to_default: bool = False) -> str:
|
||||
url_cache = snapshot.__dict__.setdefault("_snapshot_role_output_url_cache", {})
|
||||
cache_key = (
|
||||
plugin,
|
||||
filename,
|
||||
role,
|
||||
bool(context.get("STATIC_EXPORT")),
|
||||
str(context.get("STATIC_EXPORT_DIR") or ""),
|
||||
fallback_to_default,
|
||||
)
|
||||
if cache_key in url_cache:
|
||||
return url_cache[cache_key]
|
||||
|
||||
results = None
|
||||
if "_admin_archiveresults" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_admin_archiveresults"]
|
||||
elif "archiveresult_set" in snapshot.__dict__.get("_prefetched_objects_cache", {}):
|
||||
results = snapshot.archiveresult_set.all()
|
||||
elif "_snapshot_archiveresult_url_results" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_snapshot_archiveresult_url_results"]
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
if results is None:
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
results = list(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id=snapshot_id,
|
||||
plugin__in=("screenshot", "chrome_extension_screenshot", "favicon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"plugin",
|
||||
"status",
|
||||
"output_files",
|
||||
),
|
||||
if context.get("STATIC_EXPORT"):
|
||||
output_path = None
|
||||
for plugin in get_snapshot_role_names(role):
|
||||
output_path = snapshot.plugin_output_path(plugin)
|
||||
if output_path:
|
||||
break
|
||||
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path) if output_path else ""
|
||||
else:
|
||||
url_cache[cache_key] = build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
role,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
fallback_to_default=fallback_to_default,
|
||||
)
|
||||
snapshot.__dict__["_snapshot_archiveresult_url_results"] = results
|
||||
return url_cache[cache_key]
|
||||
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != "succeeded":
|
||||
continue
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
continue
|
||||
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
|
||||
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path)
|
||||
return url_cache[cache_key]
|
||||
|
||||
url_cache[cache_key] = ""
|
||||
return ""
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_result_file_url(context, snapshot, result, path: str) -> str:
|
||||
"""Build a saved-file URL from one ArchiveResult declaration."""
|
||||
try:
|
||||
output_path = result.output_file_path(path)
|
||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
||||
return ""
|
||||
if not output_path:
|
||||
return ""
|
||||
return _snapshot_url_for_context(context, snapshot, output_path)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_thumbnail(context, snapshot, admin=False, width="100px", height="100px"):
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from archivebox.plugins.output_groups import snapshot_thumbnail_result
|
||||
|
||||
results = snapshot.__dict__.get("_snapshot_card_results")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_admin_output_results")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_admin_archiveresults")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_prefetched_objects_cache", {}).get("archiveresult_set")
|
||||
if results is None:
|
||||
results = snapshot.archiveresult_set.exclude(plugin="").only(
|
||||
"id",
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
"output_size",
|
||||
)
|
||||
|
||||
try:
|
||||
host = urlparse(snapshot.url).hostname or "Archive"
|
||||
except ValueError:
|
||||
host = "Archive"
|
||||
host = host.removeprefix("www.")
|
||||
hue = int(hashlib.sha256(host.encode()).hexdigest()[:6], 16) % 360
|
||||
return mark_safe(
|
||||
render_to_string(
|
||||
"core/snapshot_thumbnail.html",
|
||||
{
|
||||
**context.flatten(),
|
||||
"card_result": snapshot_thumbnail_result(results),
|
||||
"host": host,
|
||||
"initial": host[:1].upper(),
|
||||
"hue": hue,
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_index_row(context, link) -> str:
|
||||
snapshot_base = _snapshot_base_url_for_context(context, link)
|
||||
detail_url = snapshot_detail_url(context, link)
|
||||
|
||||
status = getattr(link, "status", None) or "unknown"
|
||||
bookmarked_at = getattr(link, "bookmarked_at", None)
|
||||
@@ -590,7 +773,6 @@ def snapshot_index_row(context, link) -> str:
|
||||
|
||||
url = getattr(link, "url", "") or ""
|
||||
title = unescape(getattr(link, "title", "") or "")
|
||||
is_pending = status in {"queued", "started", "backoff"}
|
||||
title_text = title or url
|
||||
tags_str = link.tags_str() if callable(getattr(link, "tags_str", None)) else getattr(link, "tags_str", "")
|
||||
tag_html = "".join(f'<span class="snapshot-tag">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag)
|
||||
@@ -608,50 +790,14 @@ def snapshot_index_row(context, link) -> str:
|
||||
archive_size = int(getattr(link, "archive_size", 0) or 0)
|
||||
size_cell = file_size(archive_size) if archive_size else '<span class="empty-value">...</span>'
|
||||
output_plural = "" if num_outputs == 1 else "s"
|
||||
files_url = _snapshot_url_for_context(context, link, "index.jsonl") if context.get("STATIC_EXPORT") else f"{snapshot_base}/?files=1"
|
||||
files_url = snapshot_files_url(context, link)
|
||||
|
||||
if is_pending:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress"></span>'
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
preview_html = (
|
||||
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
|
||||
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
|
||||
"</span>"
|
||||
)
|
||||
elif "_public_preview_paths" in link.__dict__:
|
||||
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
|
||||
if preview_paths:
|
||||
preview_urls = [_snapshot_url_for_context(context, link, path) for path in preview_paths]
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
else:
|
||||
preview_urls = [
|
||||
url
|
||||
for url in (
|
||||
snapshot_archiveresult_url(context, link, "screenshot", "screenshot.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot-1.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot.png"),
|
||||
)
|
||||
if url
|
||||
]
|
||||
if preview_urls:
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
preview_html = snapshot_thumbnail(context, link)
|
||||
|
||||
if "_public_favicon_paths" in link.__dict__:
|
||||
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
|
||||
if favicon_paths:
|
||||
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in favicon_paths]
|
||||
if "_public_list_icon_paths" in link.__dict__:
|
||||
list_icon_paths = list(getattr(link, "_public_list_icon_paths", []) or [])
|
||||
if list_icon_paths:
|
||||
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in list_icon_paths]
|
||||
favicon_html = (
|
||||
f'<img src="{escape(favicon_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
|
||||
@@ -661,7 +807,7 @@ def snapshot_index_row(context, link) -> str:
|
||||
else:
|
||||
favicon_html = '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
|
||||
else:
|
||||
favicon_url = snapshot_archiveresult_url(context, link, "favicon", "favicon.ico")
|
||||
favicon_url = snapshot_role_output_url(context, link, "list_icon")
|
||||
favicon_html = (
|
||||
(
|
||||
f'<img src="{escape(favicon_url)}" '
|
||||
@@ -676,27 +822,24 @@ def snapshot_index_row(context, link) -> str:
|
||||
html = f"""
|
||||
<tr class="snapshot-row status-{escape(status)}">
|
||||
<td class="snapshot-time" title="{escape(title_time)}" data-sort="{escape(sort_value)}">
|
||||
<a href="{escape(snapshot_base)}/index.html">
|
||||
<a href="{escape(detail_url)}">
|
||||
<span>{escape(date_text)}</span>
|
||||
<small>{escape(time_text)}</small>
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-preview-cell">
|
||||
<a href="{escape(snapshot_base)}/index.html" title="Open archived snapshot">
|
||||
<a href="{escape(detail_url)}" title="Open archived snapshot">
|
||||
{preview_html}
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-title-cell" title="{escape(title or url)}">
|
||||
<div class="snapshot-title-line">
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
|
||||
{favicon_html}
|
||||
</a>
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110))}
|
||||
<a href="{escape(detail_url)}" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110) if title_text != url else url)}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{escape(url)}" class="snapshot-url" title="{escape(url)}" target="_blank" rel="noopener noreferrer">
|
||||
{escape(url)}
|
||||
{favicon_html}<span class="snapshot-url-text">{snapshot_url_text(url)}</span>
|
||||
</a>
|
||||
<span class="snapshot-mobile-saved">Saved {escape(date_text)} at {escape(time_text)}</span>
|
||||
</td>
|
||||
@@ -768,6 +911,28 @@ def plugin_card(context, result) -> str:
|
||||
return ""
|
||||
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
# The cover and expanded stack both embed this one card document. Run
|
||||
# plugin code on the snapshot origin so it can read saved files without
|
||||
# exposing raw archives or the admin session to the collection page.
|
||||
card_url = build_snapshot_url(
|
||||
str(_snapshot_id(result.snapshot)),
|
||||
f"_card/{result.id}",
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
return mark_safe(
|
||||
f'<iframe src="{escape(card_url)}" title="{escape(plugin)} card" '
|
||||
'loading="lazy" fetchpriority="low" sandbox="allow-scripts allow-same-origin" '
|
||||
'style="width:100% !important;height:100% !important;transform:none !important;border:0"></iframe>',
|
||||
)
|
||||
return render_plugin_card_document(context, result)
|
||||
|
||||
|
||||
def render_plugin_card_document(context, result) -> str:
|
||||
"""Render one plugin card template for the trusted snapshot-origin frame."""
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
has_card_template = bool(get_plugin_template(plugin, "card", fallback=False))
|
||||
template_str = get_plugin_template(plugin, "card")
|
||||
|
||||
# Use embed_path() for the display path
|
||||
@@ -775,9 +940,8 @@ def plugin_card(context, result) -> str:
|
||||
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path or "")
|
||||
|
||||
icon_html = get_plugin_icon(plugin)
|
||||
plugin_lower = (plugin or "").lower()
|
||||
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
|
||||
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
|
||||
media_files = _list_media_files(result, include_filesystem_fallback=bool(context.get("STATIC_EXPORT")))
|
||||
media_file_count = len(media_files)
|
||||
if context.get("STATIC_EXPORT") and media_files:
|
||||
media_files = [
|
||||
item
|
||||
@@ -796,12 +960,22 @@ def plugin_card(context, result) -> str:
|
||||
output_lower = (raw_output_path or "").lower()
|
||||
force_text_preview = output_lower.endswith(_TEXT_PREVIEW_EXTS)
|
||||
|
||||
# Static exports cannot execute live preview routes; keep their text snippets.
|
||||
if context.get("STATIC_EXPORT") and force_text_preview:
|
||||
template_str = None
|
||||
|
||||
# Create a mini template and render it with context
|
||||
try:
|
||||
if template_str and raw_output_path and str(raw_output_path).strip() not in (".", "/", "./") and not force_text_preview:
|
||||
if (
|
||||
template_str
|
||||
and raw_output_path
|
||||
and str(raw_output_path).strip() not in (".", "/", "./")
|
||||
and (has_card_template or not force_text_preview)
|
||||
):
|
||||
tpl = template.Template(template_str)
|
||||
ctx = template.Context(
|
||||
{
|
||||
**context.flatten(),
|
||||
"result": result,
|
||||
"snapshot": result.snapshot,
|
||||
"output_path": output_url,
|
||||
@@ -815,11 +989,39 @@ def plugin_card(context, result) -> str:
|
||||
rendered = tpl.render(ctx)
|
||||
# Only return non-empty content (strip whitespace to check)
|
||||
if rendered.strip():
|
||||
if rendered.lstrip().lower().startswith("<!doctype html"):
|
||||
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "</head>", 1)
|
||||
if context.get("STATIC_EXPORT") and rendered.lstrip().lower().startswith("<!doctype html"):
|
||||
# Static exports have no _card route. Keep the same card
|
||||
# document in both stack positions, with relative saved-file
|
||||
# URLs resolved against the exported index page.
|
||||
return mark_safe(
|
||||
f'<iframe srcdoc="{escape(rendered)}" title="{escape(plugin)} card" '
|
||||
'loading="lazy" sandbox="allow-scripts allow-same-origin" '
|
||||
'style="width:100% !important;height:100% !important;max-width:100% !important;'
|
||||
'transform:none !important;border:0"></iframe>',
|
||||
)
|
||||
return mark_safe(rendered)
|
||||
except (template.TemplateSyntaxError, AttributeError, TypeError, ValueError):
|
||||
rendered = ""
|
||||
|
||||
if force_text_preview:
|
||||
if force_text_preview and not context.get("STATIC_EXPORT"):
|
||||
# Read archived text only when the browser requests its preview, never
|
||||
# while rendering the outer snapshot HTML (payloads may live on B2).
|
||||
preview_url = _build_snapshot_preview_url(
|
||||
str(_snapshot_id(result.snapshot)),
|
||||
raw_output_path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
plugin=plugin,
|
||||
)
|
||||
return mark_safe(
|
||||
f'<iframe src="{escape(preview_url)}" title="{escape(plugin)} preview" '
|
||||
'loading="lazy" fetchpriority="low" sandbox="allow-scripts allow-same-origin" '
|
||||
'style="border:0;pointer-events:none"></iframe>',
|
||||
)
|
||||
|
||||
if force_text_preview and context.get("STATIC_EXPORT"):
|
||||
preview = _render_text_file_preview(result.snapshot_dir, raw_output_path, plugin, icon_html)
|
||||
if preview:
|
||||
return mark_safe(preview)
|
||||
@@ -872,7 +1074,11 @@ def plugin_full(context, result) -> str:
|
||||
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path)
|
||||
|
||||
try:
|
||||
tpl = template.Template(template_str)
|
||||
catalog = get_plugin_catalog()
|
||||
tpl = template.Engine(
|
||||
dirs=[catalog[plugin].path] if plugin in catalog else [],
|
||||
libraries=template.Engine.get_default().libraries,
|
||||
).from_string(template_str)
|
||||
ctx = template.Context(
|
||||
{
|
||||
"result": result,
|
||||
@@ -880,6 +1086,7 @@ def plugin_full(context, result) -> str:
|
||||
"output_path": output_url,
|
||||
"output_path_raw": raw_output_path,
|
||||
"plugin": plugin,
|
||||
"plugin_icon": get_plugin_icon(plugin),
|
||||
},
|
||||
)
|
||||
rendered = tpl.render(ctx)
|
||||
|
||||
+262
-134
@@ -6,8 +6,8 @@ import posixpath
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, cast
|
||||
from urllib.parse import quote, urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
|
||||
from admin_data_views.typing import ItemContext, SectionData, TableContext
|
||||
from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view
|
||||
from django import template
|
||||
@@ -23,6 +23,7 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidde
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.cache import patch_cache_control, patch_vary_headers
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt, csrf_protect
|
||||
@@ -58,6 +59,8 @@ from archivebox.core.permissions import (
|
||||
)
|
||||
from archivebox.core.routes_util import (
|
||||
build_admin_url,
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_url,
|
||||
build_web_url,
|
||||
get_admin_host,
|
||||
@@ -68,7 +71,7 @@ from archivebox.core.routes_util import (
|
||||
)
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.misc.serve_static import serve_static_with_byterange_support
|
||||
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL, serve_static_with_byterange_support
|
||||
from archivebox.misc.util import (
|
||||
base_url,
|
||||
filter_queryset_by_uuid_substring,
|
||||
@@ -77,7 +80,15 @@ from archivebox.misc.util import (
|
||||
validate_url,
|
||||
without_fragment,
|
||||
)
|
||||
from archivebox.plugins.discovery import get_plugin_name, get_plugin_template
|
||||
from archivebox.plugins.discovery import (
|
||||
get_plugin_catalog,
|
||||
get_plugin_icon,
|
||||
get_plugin_name,
|
||||
get_plugin_template,
|
||||
get_snapshot_role_names,
|
||||
plugin_has_custom_full_response,
|
||||
serve_plugin_replay_asset,
|
||||
)
|
||||
from archivebox.plugins.forms import get_plugin_config_binary_urls
|
||||
from archivebox.plugins.views import get_config_definition_link
|
||||
from archivebox.progressmonitor.views import live_progress_view
|
||||
@@ -110,7 +121,10 @@ def _find_snapshot_by_ref(snapshot_ref: str) -> Snapshot | None:
|
||||
snapshots = Snapshot.objects.select_related("crawl", "crawl__created_by")
|
||||
|
||||
if len(lookup) == 12 and "-" not in lookup:
|
||||
return snapshots.filter(id__endswith=lookup).order_by("-created_at", "-downloaded_at").first()
|
||||
# Resolve suffixes using the covering primary-key index before fetching
|
||||
# wide snapshot rows and joining their crawl/user data.
|
||||
matches = list(Snapshot.objects.filter(id__endswith=lookup).order_by().values_list("id", flat=True))
|
||||
return snapshots.filter(pk__in=matches).order_by("-created_at", "-downloaded_at").first()
|
||||
|
||||
try:
|
||||
return snapshots.get(pk=lookup)
|
||||
@@ -327,6 +341,20 @@ class SnapshotView(View):
|
||||
|
||||
@staticmethod
|
||||
def render_live_index(request, snapshot):
|
||||
if request.GET.get("discover_outputs") == "1":
|
||||
# Reuse the existing output discovery and card/badge classification.
|
||||
request.archivebox_cache_policy = "private"
|
||||
initial_context = snapshot.get_html_details_context(request=request)
|
||||
known_paths = {output["path"] for output in initial_context["archiveresults"]}
|
||||
context = snapshot.get_html_details_context(request=request, discover_files=True)
|
||||
context["archiveresults"] = [output for output in context["archiveresults"] if output["path"] not in known_paths]
|
||||
for output in context["archiveresults"]:
|
||||
output["output_group"] = "other"
|
||||
output["folder_path"] = str(Path(output["path"]).parent)
|
||||
response = render(request, "core/snapshot_output_cards.html", context)
|
||||
patch_cache_control(response, private=True, no_store=True)
|
||||
return response
|
||||
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
return render(
|
||||
template_name="core/snapshot.html",
|
||||
request=request,
|
||||
@@ -343,18 +371,19 @@ class SnapshotView(View):
|
||||
|
||||
# slug is a timestamp
|
||||
if slug.replace(".", "").isdigit():
|
||||
# missing trailing slash -> redirect to index
|
||||
if "/" not in path:
|
||||
return redirect(f"{path}/index.html")
|
||||
|
||||
try:
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
|
||||
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
if not can_view_snapshot(request, snapshot):
|
||||
return _private_snapshot_auth_redirect(request, snapshot, archivefile or "index.html")
|
||||
canonical_base = snapshot.url_path
|
||||
if canonical_base != snapshot.legacy_archive_path:
|
||||
target_path = f"/{canonical_base}/{archivefile or 'index.html'}"
|
||||
target_path = (
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
if not archivefile or archivefile == "index.html"
|
||||
else build_snapshot_url(str(snapshot.id), archivefile, request=request)
|
||||
)
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target_path = f"{target_path}?{query}"
|
||||
@@ -369,8 +398,9 @@ class SnapshotView(View):
|
||||
show_indexes=True,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
elif archivefile == "index.html":
|
||||
# if they requested snapshot index, serve live rendered template instead of static html
|
||||
elif archivefile in ("", "index.html"):
|
||||
# Both the canonical extensionless URL and the legacy
|
||||
# index.html URL render the live Django snapshot page.
|
||||
response = self.render_live_index(request, snapshot)
|
||||
else:
|
||||
target = build_snapshot_url(str(snapshot.id), archivefile, request=request)
|
||||
@@ -404,15 +434,15 @@ class SnapshotView(View):
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot_hrefs = mark_safe("<br/>").join(
|
||||
format_html(
|
||||
'{} <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
'{} <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
snap.archive_path,
|
||||
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
|
||||
snap.timestamp,
|
||||
snap.url,
|
||||
snap.title_stripped[:64] or "",
|
||||
)
|
||||
for snap in direct_snapshots_queryset(request, Snapshot.objects.filter(timestamp__startswith=slug))
|
||||
.only("url", "timestamp", "title", "bookmarked_at")
|
||||
.select_related("crawl__created_by")
|
||||
.order_by("-bookmarked_at")
|
||||
)
|
||||
return HttpResponse(
|
||||
@@ -436,27 +466,27 @@ class SnapshotView(View):
|
||||
<title>Snapshot Not Found</title>
|
||||
</head><body>
|
||||
<center><br/><br/><br/>
|
||||
Snapshot <a href="/{}/index.html" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
|
||||
was queued on {}, but no files have been saved yet in:<br/><b><a href="/{}/" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
|
||||
Snapshot <a href="{}" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
|
||||
was queued on {}, but no files have been saved yet in:<br/><b><a href="{}" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
|
||||
It's possible {} during the last capture on {},<br/>or that the archiving process has not completed yet.<br/>
|
||||
<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>
|
||||
<code style="user-select: all; color: #333">archivebox update -t timestamp {}</code></pre><br/><br/>
|
||||
<div class="text-align: left; width: 100%; max-width: 400px">
|
||||
<i><b>Next steps:</i></b><br/>
|
||||
- list all the <a href="/{}/" target="_top">Snapshot files <code>.*</code></a><br/>
|
||||
- view the <a href="/{}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>
|
||||
- list all the <a href="{}" target="_top">Snapshot files <code>.*</code></a><br/>
|
||||
- view the <a href="{}" target="_top">Snapshot <code>./index.html</code></a><br/>
|
||||
- go to the <a href="/admin/core/snapshot/{}/change/" target="_top">Snapshot admin</a> to edit<br/>
|
||||
- go to the <a href="/admin/core/snapshot/?id__exact={}" target="_top">Snapshot actions</a> to re-archive<br/>
|
||||
- or return to <a href="/" target="_top">the main index...</a></div>
|
||||
</center>
|
||||
</body></html>
|
||||
""",
|
||||
snapshot.archive_path,
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
snapshot.timestamp,
|
||||
snapshot.url,
|
||||
snapshot.url,
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.archive_path,
|
||||
build_snapshot_files_url(str(snapshot.id), request=request),
|
||||
snapshot.timestamp,
|
||||
archivefile if str(archivefile) != "None" else "",
|
||||
f"the {archivefile} resource could not be fetched"
|
||||
@@ -464,8 +494,8 @@ class SnapshotView(View):
|
||||
else "the original site was not available",
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.timestamp,
|
||||
snapshot.archive_path,
|
||||
snapshot.archive_path,
|
||||
build_snapshot_files_url(str(snapshot.id), request=request),
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
snapshot.pk,
|
||||
snapshot.id,
|
||||
),
|
||||
@@ -484,7 +514,7 @@ class SnapshotView(View):
|
||||
return id_qs
|
||||
return SnapshotView.find_snapshots_for_url(slug)
|
||||
|
||||
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path))
|
||||
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path)).select_related("crawl__created_by")
|
||||
try:
|
||||
if "://" in path:
|
||||
snapshot = snapshots.order_by("-bookmarked_at").first()
|
||||
@@ -512,15 +542,15 @@ class SnapshotView(View):
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot_hrefs = mark_safe("<br/>").join(
|
||||
format_html(
|
||||
'{} <code style="font-size: 0.8em">{}</code> <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
'{} <code style="font-size: 0.8em">{}</code> <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
str(snap.id)[:8],
|
||||
snap.archive_path,
|
||||
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
|
||||
snap.timestamp,
|
||||
snap.url,
|
||||
snap.title_stripped[:64] or "",
|
||||
)
|
||||
for snap in snapshots.only("url", "timestamp", "title", "bookmarked_at").order_by("-bookmarked_at")
|
||||
for snap in snapshots.select_related("crawl__created_by").order_by("-bookmarked_at")
|
||||
)
|
||||
return HttpResponse(
|
||||
format_html(
|
||||
@@ -533,7 +563,7 @@ class SnapshotView(View):
|
||||
status=404,
|
||||
)
|
||||
|
||||
target_path = build_snapshot_url(str(snapshot.id), "index.html", request=request)
|
||||
target_path = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target_path = f"{target_path}?{query}"
|
||||
@@ -629,6 +659,7 @@ class SnapshotPathView(View):
|
||||
status=404,
|
||||
)
|
||||
|
||||
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
canonical_base = snapshot.url_path
|
||||
if date:
|
||||
requested_base = f"{username}/{date}/{domain or url or ''}"
|
||||
@@ -637,7 +668,9 @@ class SnapshotPathView(View):
|
||||
if snapshot_id:
|
||||
requested_base = f"{requested_base}/{snapshot_id}"
|
||||
if canonical_base != requested_base:
|
||||
target = f"/{canonical_base}/{path or 'index.html'}"
|
||||
target = f"/{canonical_base}"
|
||||
if path:
|
||||
target = f"{target}/{path}"
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target = f"{target}?{query}"
|
||||
@@ -683,34 +716,57 @@ def _safe_archive_relpath(path: str) -> str | None:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, ArchiveResult | None]:
|
||||
"""Resolve plugin-relative output paths through ArchiveResult.output_files."""
|
||||
def _resolve_archiveresult_relpath(
|
||||
snapshot: Snapshot,
|
||||
rel_path: str,
|
||||
) -> tuple[str, ArchiveResult | None, tuple[str, ...]]:
|
||||
"""Resolve a declared output path and its exact historical fallbacks."""
|
||||
parts = Path(rel_path).parts
|
||||
if len(parts) < 2:
|
||||
return rel_path, None
|
||||
return rel_path, None, ()
|
||||
|
||||
plugin = parts[0]
|
||||
plugin_relpath = posixpath.join(*parts[1:])
|
||||
result = (
|
||||
ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED)
|
||||
.only("plugin", "output_files")
|
||||
.first()
|
||||
results = list(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot=snapshot,
|
||||
plugin=plugin,
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only("plugin", "output_files"),
|
||||
)
|
||||
if not result:
|
||||
return rel_path, None
|
||||
if not result.output_files:
|
||||
return rel_path, result
|
||||
if not results:
|
||||
from archivebox.plugins.discovery import get_plugin_default_output_path
|
||||
|
||||
output_files = result.output_files or {}
|
||||
for candidate in (plugin_relpath, rel_path):
|
||||
file_info = output_files.get(candidate)
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if file_info.get("root_relative"):
|
||||
return candidate, result
|
||||
return rel_path, result
|
||||
fallbacks = (plugin_relpath,) if get_plugin_default_output_path(plugin) == rel_path else ()
|
||||
return rel_path, None, fallbacks
|
||||
|
||||
return rel_path, result
|
||||
declared_paths: list[tuple[str, ArchiveResult]] = []
|
||||
historical_fallbacks: list[str] = []
|
||||
for result in results:
|
||||
output_files = result.output_file_map()
|
||||
for candidate in (plugin_relpath, rel_path):
|
||||
output_path = result.output_file_path(candidate, output_file_map=output_files)
|
||||
if output_path and output_path not in {path for path, _result in declared_paths}:
|
||||
declared_paths.append((output_path, result))
|
||||
|
||||
# Partially migrated 0.7 rows can declare ``singlefile.html`` while
|
||||
# the old file still lives at SNAP_DIR/singlefile.html, without the
|
||||
# later root_relative metadata bit. Keep that one exact declared key as
|
||||
# a fallback after the normal path returns 404. Do not preflight either
|
||||
# path: the static response performs the only happy-path filesystem IO.
|
||||
if plugin_relpath in output_files and plugin_relpath not in historical_fallbacks:
|
||||
historical_fallbacks.append(plugin_relpath)
|
||||
|
||||
if declared_paths:
|
||||
primary_path, primary_result = declared_paths[0]
|
||||
fallbacks = tuple(
|
||||
dict.fromkeys(
|
||||
path for path in (*[path for path, _result in declared_paths[1:]], *historical_fallbacks) if path != primary_path
|
||||
),
|
||||
)
|
||||
return primary_path, primary_result, fallbacks
|
||||
|
||||
return rel_path, results[0], ()
|
||||
|
||||
|
||||
def _plugin_full_preview_response(
|
||||
@@ -720,7 +776,7 @@ def _plugin_full_preview_response(
|
||||
result: ArchiveResult | None,
|
||||
) -> HttpResponse | None:
|
||||
"""Render an explicit plugin full template as a trusted preview wrapper."""
|
||||
if not request.GET.get("preview"):
|
||||
if not request.GET.get("preview") or request.GET.get("raw") or request.GET.get("files"):
|
||||
return None
|
||||
|
||||
path_parts = Path(rel_path).parts
|
||||
@@ -728,23 +784,25 @@ def _plugin_full_preview_response(
|
||||
if not plugin:
|
||||
return None
|
||||
|
||||
# ReplayWeb.page needs plugin-owned WACZ inspection and service-worker
|
||||
# context, so it remains the one narrow preview exception below.
|
||||
if plugin == "archivewebpage" and archivewebpage_replay.is_replay_target(rel_path):
|
||||
return None
|
||||
|
||||
template_str = get_plugin_template(plugin, "full", fallback=False)
|
||||
if not template_str:
|
||||
return None
|
||||
if plugin_has_custom_full_response(plugin):
|
||||
# The plugin owns path selection and rendering for this full template.
|
||||
# Defer to the file-serving response hook so unrelated outputs from the
|
||||
# same plugin continue through their normal MIME-specific renderer.
|
||||
return None
|
||||
|
||||
raw_query = request.GET.copy()
|
||||
raw_query.pop("preview", None)
|
||||
raw_query.pop("titlebar", None)
|
||||
output_url = request.path
|
||||
if raw_query:
|
||||
output_url = f"{output_url}?{raw_query.urlencode()}"
|
||||
|
||||
catalog = get_plugin_catalog()
|
||||
rendered = (
|
||||
template.Engine(debug=False)
|
||||
template.Engine(debug=False, dirs=[catalog[plugin].path] if plugin in catalog else [])
|
||||
.from_string(template_str)
|
||||
.render(
|
||||
template.Context(
|
||||
@@ -753,18 +811,40 @@ def _plugin_full_preview_response(
|
||||
"snapshot": snapshot,
|
||||
"output_path": output_url,
|
||||
"output_path_raw": rel_path,
|
||||
"snapshot_details_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
"plugin": plugin,
|
||||
"plugin_icon": get_plugin_icon(plugin),
|
||||
"preview_base": f"{request.path.rsplit('/', 1)[0]}/",
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
if request.GET.get("titlebar") == "0":
|
||||
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE
|
||||
|
||||
# The card already has a result header and actions. Only its embedded
|
||||
# trusted full viewer drops the duplicate top bar; opening the same
|
||||
# output directly keeps the complete viewer and controls.
|
||||
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "<style>body > header { display: none !important; }</style></head>", 1)
|
||||
response = HttpResponse(rendered, content_type="text/html; charset=utf-8")
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{Path(rel_path).stem}.html"'
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-ArchiveBox-Security-Mode"] = request.archivebox_config.SERVER_SECURITY_MODE
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
# Full plugin viewers are trusted code, but must fetch saved data from the
|
||||
# isolated snapshot origin. The outer collection UI stays on web.*.
|
||||
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
|
||||
return response
|
||||
|
||||
|
||||
def _trusted_snapshot_frame_csp(request: HttpRequest) -> str:
|
||||
# A trusted plugin frame on snap-* may be embedded by web.* or admin.*.
|
||||
# Archived HTML/JS/CSS is still served only inside that snapshot origin;
|
||||
# neither this CSP nor the frame grants it admin cookies or cross-snapshot
|
||||
# reads. In one-domain no-JS mode the hosts coincide, but raw replay scripts
|
||||
# remain blocked by the separate replay response policy.
|
||||
viewer_origins = " ".join(dict.fromkeys((build_web_url(request=request), build_admin_url(request=request))))
|
||||
return (
|
||||
"default-src 'self' data: blob:; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
|
||||
"style-src 'unsafe-inline' data: blob: 'self'; "
|
||||
@@ -777,8 +857,49 @@ def _plugin_full_preview_response(
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'; "
|
||||
"frame-ancestors 'self';"
|
||||
f"frame-ancestors 'self' {viewer_origins};"
|
||||
)
|
||||
|
||||
|
||||
def _plugin_card_document_response(request: HttpRequest, snapshot: Snapshot, result_id: str) -> HttpResponse:
|
||||
"""Serve the existing card template from the snapshot origin, not web.*.
|
||||
|
||||
srcdoc and inline card scripts inherit the containing page's origin. This
|
||||
frame route gives them same-origin access to this snapshot's saved files
|
||||
without opening CORS on raw archives or sharing the admin session cookie.
|
||||
The parent web page receives only the one-bit login hint; delete buttons
|
||||
navigate to admin.* for a real authenticated confirmation.
|
||||
"""
|
||||
try:
|
||||
result_pk = UUID(result_id)
|
||||
except (TypeError, ValueError):
|
||||
raise Http404 from None
|
||||
result = snapshot.archiveresult_set.filter(pk=result_pk).first()
|
||||
if result is None:
|
||||
raise Http404
|
||||
|
||||
# There is exactly one plugin card template. The stack cover and expanded
|
||||
# tray point at this same document; only their containing card width differs.
|
||||
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE, render_plugin_card_document
|
||||
|
||||
fragment = render_plugin_card_document(
|
||||
template.Context({"request": request, "CONFIG": get_request_config(request, resolve_plugins=False)}),
|
||||
result,
|
||||
)
|
||||
if str(fragment).lstrip().lower().startswith("<!doctype html"):
|
||||
card_html = str(fragment)
|
||||
else:
|
||||
card_html = (
|
||||
'<!doctype html><html><head><meta charset="utf-8"><style>'
|
||||
"html,body{margin:0;width:100%;height:100%;overflow:hidden}"
|
||||
"body>iframe{display:block;width:100%;height:100%;border:0}"
|
||||
f"</style>{CARD_OVERFLOW_STYLE}</head><body>"
|
||||
f"{fragment}</body></html>"
|
||||
)
|
||||
response = HttpResponse(card_html, content_type="text/html; charset=utf-8")
|
||||
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
|
||||
|
||||
@@ -885,7 +1006,7 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind
|
||||
return None
|
||||
|
||||
|
||||
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
|
||||
def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, path: str = ""):
|
||||
rel_path = path or ""
|
||||
request_config = get_request_config(
|
||||
request,
|
||||
@@ -893,10 +1014,11 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
|
||||
)
|
||||
request.archivebox_config = request_config
|
||||
request.archivebox_snapshot_url = snapshot.url
|
||||
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
snapshot._runtime_config = request_config
|
||||
|
||||
if rel_path.startswith("replay/") or rel_path == "replay":
|
||||
response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse)
|
||||
response = serve_plugin_replay_asset(rel_path, request_config, HttpResponse)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
@@ -905,11 +1027,26 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
|
||||
# /progress.json on through to the same view used everywhere else. The caller
|
||||
# passes snapshot_id explicitly in the query string — we don't read it from the
|
||||
# subdomain (this keeps the endpoint identical across all security modes).
|
||||
return live_progress_view(request)
|
||||
return live_progress_view(request, authorized_snapshot=snapshot)
|
||||
|
||||
if rel_path.startswith("_card/"):
|
||||
# SnapshotHostView/SnapshotReplayView have already authorized this
|
||||
# snapshot. Look up the result through its relation so a card ID from
|
||||
# another isolated snapshot can never render here.
|
||||
return _plugin_card_document_response(request, snapshot, rel_path.removeprefix("_card/"))
|
||||
|
||||
is_directory_request = bool(path) and path.endswith("/")
|
||||
show_indexes = bool(request.GET.get("files")) or (request_config.USES_SUBDOMAIN_ROUTING and is_directory_request)
|
||||
if not show_indexes and (not rel_path or rel_path == "index.html"):
|
||||
if request_config.CONTROL_PLANE_ENABLED and snapshot.permissions in (PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED):
|
||||
# Snapshot detail is trusted application UI and belongs on web.*.
|
||||
# snap-* is for isolated saved outputs and plugin frames. Keep the
|
||||
# private replay-cookie path below: its grant is host-only and must
|
||||
# never be mistaken for an admin session on web.*.
|
||||
target = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=request_config)
|
||||
if request.META.get("QUERY_STRING"):
|
||||
target = f"{target}?{request.META['QUERY_STRING']}"
|
||||
return redirect(target)
|
||||
return SnapshotView.render_live_index(request, snapshot)
|
||||
|
||||
if not rel_path or rel_path.endswith("/"):
|
||||
@@ -921,22 +1058,23 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
|
||||
if rel_path is None:
|
||||
raise Http404
|
||||
|
||||
rel_path, archive_result = _resolve_archiveresult_relpath(snapshot, rel_path)
|
||||
rel_path, archive_result, fallback_relpaths = _resolve_archiveresult_relpath(snapshot, rel_path)
|
||||
|
||||
plugin_preview = _plugin_full_preview_response(request, snapshot, rel_path, archive_result)
|
||||
if plugin_preview is not None:
|
||||
return plugin_preview
|
||||
|
||||
try:
|
||||
return serve_static_with_byterange_support(
|
||||
request,
|
||||
rel_path,
|
||||
document_root=snapshot.output_dir,
|
||||
show_indexes=show_indexes,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
except Http404:
|
||||
pass
|
||||
for candidate_path in (rel_path, *fallback_relpaths):
|
||||
try:
|
||||
return serve_static_with_byterange_support(
|
||||
request,
|
||||
candidate_path,
|
||||
document_root=snapshot.output_dir,
|
||||
show_indexes=show_indexes,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
except Http404:
|
||||
continue
|
||||
|
||||
host = urlparse(snapshot.url).hostname or snapshot.domain
|
||||
responses_root = Path(snapshot.output_dir) / "responses" / host
|
||||
@@ -948,6 +1086,22 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
|
||||
raise Http404
|
||||
|
||||
|
||||
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
|
||||
response = _build_snapshot_replay_response(request, snapshot, path)
|
||||
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
|
||||
return response
|
||||
if path != "progress.json" and response.status_code in (200, 206, 304):
|
||||
if not response.get("Cache-Control"):
|
||||
policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
response.headers["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300"
|
||||
if snapshot.permissions != PERMISSIONS_PUBLIC:
|
||||
patch_cache_control(response, private=True)
|
||||
else:
|
||||
response.headers["Cache-Control"] = "private, no-store"
|
||||
patch_vary_headers(response, ("Cookie", "Authorization"))
|
||||
return response
|
||||
|
||||
|
||||
def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str = ""):
|
||||
request_config = get_request_config(request, resolve_plugins=False)
|
||||
request.archivebox_config = request_config
|
||||
@@ -971,6 +1125,8 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
|
||||
|
||||
root_match = (match[0], match[1]) if match else _latest_responses_root(snapshots, domain)
|
||||
responses_root = root_match[1] if root_match else None
|
||||
if root_match:
|
||||
request.archivebox_cache_policy = "public" if root_match[0].permissions == PERMISSIONS_PUBLIC else "private"
|
||||
if request_config.USES_SUBDOMAIN_ROUTING:
|
||||
snapshot = root_match[0] if root_match else (snapshots[0] if requested_root_index and snapshots else None)
|
||||
if snapshot:
|
||||
@@ -1148,10 +1304,13 @@ class PublicIndexView(ListView):
|
||||
and context["paginator"].count == 0,
|
||||
)
|
||||
snapshots = list(context.get("object_list") or ())
|
||||
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
|
||||
from archivebox.plugins.output_groups import plugin_output_sizes
|
||||
|
||||
all_results_by_snapshot = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
icons_by_snapshot: dict[str, dict[str, ArchiveResult]] = {str(snapshot.id): {} for snapshot in snapshots}
|
||||
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
preview_paths_by_snapshot: dict[str, list[tuple[int, int, str]]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
favicon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
list_icon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
|
||||
progress_by_snapshot: dict[str, dict[str, int]] = {
|
||||
str(snapshot.id): {
|
||||
"total": 0,
|
||||
@@ -1172,78 +1331,47 @@ class PublicIndexView(ListView):
|
||||
):
|
||||
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
|
||||
|
||||
preview_plugin_order = {
|
||||
"screenshot": 0,
|
||||
"chrome_extension_screenshot": 1,
|
||||
}
|
||||
preview_candidates = {
|
||||
"screenshot": ("screenshot.png",),
|
||||
"chrome_extension_screenshot": ("screenshot-1.png", "screenshot.png"),
|
||||
}
|
||||
|
||||
def result_output_path(result: ArchiveResult, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
for snapshot_id, plugin, status in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
)
|
||||
results = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=icons_by_snapshot.keys())
|
||||
.exclude(plugin="")
|
||||
.values_list("snapshot_id", "plugin", "status")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
snapshot_key = str(snapshot_id)
|
||||
.only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
"output_size",
|
||||
)
|
||||
)
|
||||
for result in results.iterator(chunk_size=1000):
|
||||
snapshot_key = str(result.snapshot_id)
|
||||
all_results_by_snapshot[snapshot_key].append(result)
|
||||
progress = progress_by_snapshot[snapshot_key]
|
||||
progress["total"] += 1
|
||||
if status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
icons_by_snapshot[snapshot_key].add(plugin)
|
||||
if result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
icons_by_snapshot[snapshot_key].setdefault(result.plugin, result)
|
||||
progress["succeeded"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.FAILED:
|
||||
elif result.status == ArchiveResult.StatusChoices.FAILED:
|
||||
progress["failed"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.STARTED:
|
||||
elif result.status == ArchiveResult.StatusChoices.STARTED:
|
||||
progress["running"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.SKIPPED:
|
||||
elif result.status == ArchiveResult.StatusChoices.SKIPPED:
|
||||
progress["skipped"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.NORESULTS:
|
||||
elif result.status == ArchiveResult.StatusChoices.NORESULTS:
|
||||
progress["noresults"] += 1
|
||||
|
||||
for result in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
plugin__in=(*preview_candidates, "favicon"),
|
||||
)
|
||||
.only("snapshot_id", "plugin", "output_files")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
snapshot_key = str(result.snapshot_id)
|
||||
if result.plugin in preview_candidates:
|
||||
plugin_rank = preview_plugin_order[result.plugin]
|
||||
for filename_rank, filename in enumerate(preview_candidates[result.plugin]):
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
preview_paths_by_snapshot[snapshot_key].append((plugin_rank, filename_rank, output_path))
|
||||
elif result.plugin == "favicon":
|
||||
output_path = result_output_path(result, "favicon.ico")
|
||||
if output_path:
|
||||
favicon_paths_by_snapshot[snapshot_key].append(output_path)
|
||||
if result.plugin in list_icon_plugins and result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
if output_path := result.embed_path():
|
||||
list_icon_paths_by_snapshot[snapshot_key].append(output_path)
|
||||
|
||||
for snapshot in snapshots:
|
||||
snapshot._icons_compact = True
|
||||
snapshot._icons_output_sizes = plugin_output_sizes(all_results_by_snapshot[str(snapshot.id)])
|
||||
snapshot._icons_archive_results = icons_by_snapshot.get(str(snapshot.id), set())
|
||||
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
|
||||
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
|
||||
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
|
||||
snapshot._public_preview_paths = [
|
||||
output_path for _plugin_rank, _filename_rank, output_path in sorted(preview_paths_by_snapshot.get(str(snapshot.id), []))
|
||||
]
|
||||
snapshot._public_favicon_paths = favicon_paths_by_snapshot.get(str(snapshot.id), [])
|
||||
snapshot._snapshot_card_results = all_results_by_snapshot[str(snapshot.id)]
|
||||
snapshot._public_list_icon_paths = list_icon_paths_by_snapshot.get(str(snapshot.id), [])
|
||||
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
|
||||
context["object_list"] = snapshots
|
||||
return context
|
||||
@@ -1533,7 +1661,7 @@ class WebAddView(AddView):
|
||||
if requested_url:
|
||||
snapshot = self._latest_snapshot_for_url(requested_url)
|
||||
if snapshot:
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
request_host = (request.get_host() or "").lower()
|
||||
request_config = get_request_config(request)
|
||||
@@ -1573,7 +1701,7 @@ class WebAddView(AddView):
|
||||
|
||||
snapshot = self._latest_snapshot_for_url(requested_url)
|
||||
if snapshot:
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
add_url = self._normalize_add_url(requested_url)
|
||||
assert self.form_class is not None
|
||||
@@ -1605,7 +1733,7 @@ class WebAddView(AddView):
|
||||
crawl = self._create_crawl_from_form(form)
|
||||
snapshot = Snapshot.from_json({"url": add_url, "tags": form.cleaned_data.get("tag", "")}, overrides={"crawl": crawl})
|
||||
assert snapshot is not None
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
|
||||
class HealthCheckView(View):
|
||||
|
||||
+55
-12
@@ -8,7 +8,7 @@ from urllib.parse import urlencode, urlparse
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count, F, Q
|
||||
from django.db.models import Count, F, Prefetch, Q
|
||||
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
@@ -30,9 +30,11 @@ from archivebox.core.permissions import (
|
||||
PERMISSIONS_VALUES,
|
||||
normalize_permissions,
|
||||
)
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
|
||||
from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
|
||||
from archivebox.crawls.models import Crawl, CrawlSchedule
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.workers.models import RETRY_AT_MAX
|
||||
|
||||
@@ -72,13 +74,32 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
|
||||
# Keep ArchiveResult counters as scalar subqueries so the paginated
|
||||
# Snapshot queryset does not become a join+GROUP BY over every result row.
|
||||
snapshots_qs = filtered_qs.order_by("-created_at").annotate(
|
||||
total_results=ArchiveResult.snapshot_count_expr(),
|
||||
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
|
||||
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
|
||||
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
|
||||
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
|
||||
snapshot_permissions=F("permissions"),
|
||||
snapshots_qs = (
|
||||
filtered_qs.order_by("-created_at")
|
||||
.annotate(
|
||||
total_results=ArchiveResult.snapshot_count_expr(),
|
||||
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
|
||||
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
|
||||
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
|
||||
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
|
||||
snapshot_permissions=F("permissions"),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
page_number = request.GET.get(page_param, 1) if request is not None else 1
|
||||
@@ -219,6 +240,14 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
|
||||
# Format date
|
||||
date_str = snapshot.created_at.strftime("%Y-%m-%d %H:%M") if snapshot.created_at else "-"
|
||||
detail_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
favicon_url = build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
request=request,
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
)
|
||||
|
||||
rows.append(f'''
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
@@ -231,8 +260,8 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
<span title="{permissions}" style="display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:999px; font-size:12px; color:{permission_fg}; background:{permission_bg};">{permission_icon}</span>
|
||||
</td>
|
||||
<td style="padding: 6px 8px; white-space: nowrap;">
|
||||
<a href="/{snapshot.archive_path}/" style="text-decoration: none;">
|
||||
<img src="/{snapshot.archive_path}/favicon.ico"
|
||||
<a href="{escape(detail_url)}" style="text-decoration: none;">
|
||||
<img src="{escape(favicon_url)}"
|
||||
style="width: 16px; height: 16px; vertical-align: middle; margin-right: 4px;"
|
||||
onerror="this.style.display='none'"/>
|
||||
</a>
|
||||
@@ -1230,7 +1259,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
(
|
||||
"Configuration",
|
||||
{
|
||||
"fields": ("schedule", "template"),
|
||||
"fields": ("is_enabled", "schedule", "template"),
|
||||
"classes": ("card",),
|
||||
},
|
||||
),
|
||||
@@ -1276,7 +1305,21 @@ class CrawlScheduleAdmin(BaseModelAdmin):
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
self.request = request
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
from django.db import connections, router
|
||||
|
||||
connection = connections[router.db_for_write(self.model)]
|
||||
if request.method != "POST" or connection.vendor != "sqlite":
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
# Reserve the writer before Django's atomic admin form reads. A
|
||||
# deferred read transaction cannot safely upgrade while the runner
|
||||
# writes, even when SQLite's existing busy timeout has time left.
|
||||
connection.ensure_connection()
|
||||
previous_mode = connection.transaction_mode
|
||||
connection.transaction_mode = "IMMEDIATE"
|
||||
try:
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
finally:
|
||||
connection.transaction_mode = previous_mode
|
||||
|
||||
def add_view(self, request, form_url="", extra_context=None):
|
||||
return redirect("/add/#schedule")
|
||||
|
||||
@@ -5,11 +5,3 @@ class CrawlsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "archivebox.crawls"
|
||||
label = "crawls"
|
||||
|
||||
def ready(self):
|
||||
"""Import models to register state machines with the registry"""
|
||||
import sys
|
||||
|
||||
# Skip during makemigrations to avoid premature state machine access
|
||||
if "makemigrations" not in sys.argv:
|
||||
from archivebox.crawls.models import CrawlMachine # noqa: F401
|
||||
+116
-450
@@ -19,7 +19,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.conf import settings
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from statemachine import State, registry
|
||||
from archivebox.config.common import rprint as print
|
||||
from archivebox.core.permissions import PERMISSIONS_VALUES, normalize_permissions
|
||||
|
||||
@@ -32,7 +31,7 @@ from archivebox.base_models.models import (
|
||||
ModelWithHealthStats,
|
||||
get_or_create_system_user_pk,
|
||||
)
|
||||
from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
|
||||
from archivebox.workers.models import ModelWithQueue
|
||||
from archivebox.crawls.schedule_util import next_run_for_schedule, validate_schedule
|
||||
from archivebox.misc.util import parse_date, sanitize_html_text, validate_url, validate_url_length
|
||||
|
||||
@@ -91,6 +90,8 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
|
||||
@property
|
||||
def last_run_at(self):
|
||||
if self.kind == "update":
|
||||
return self.modified_at
|
||||
latest_crawl = self.crawl_set.order_by("-created_at").first()
|
||||
if latest_crawl:
|
||||
return latest_crawl.created_at
|
||||
@@ -106,6 +107,22 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
now = now or timezone.now()
|
||||
return self.is_enabled and self.next_run_at <= now
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return str((self.config or {}).get("SCHEDULE_KIND") or "crawl")
|
||||
|
||||
def dispatch(self, queued_at=None) -> "Crawl | None":
|
||||
"""Run maintenance directly or enqueue one ordinary Crawl."""
|
||||
queued_at = queued_at or timezone.now()
|
||||
if self.kind == "update":
|
||||
from archivebox.cli.archivebox_update import run_scheduled_maintenance
|
||||
|
||||
run_scheduled_maintenance()
|
||||
type(self).objects.filter(pk=self.pk).update(modified_at=queued_at)
|
||||
self.modified_at = queued_at
|
||||
return None
|
||||
return self.enqueue(queued_at=queued_at)
|
||||
|
||||
def enqueue(self, queued_at=None) -> "Crawl":
|
||||
from archivebox.config.common import build_crawl_config_snapshot
|
||||
|
||||
@@ -113,10 +130,11 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
template = self.template
|
||||
label = template.label or self.label
|
||||
persona = template.persona if template.persona_id else None
|
||||
crawl_config = {key: value for key, value in (self.config or {}).items() if key != "SCHEDULE_KIND"}
|
||||
|
||||
return Crawl.objects.create(
|
||||
urls=template.urls,
|
||||
config=build_crawl_config_snapshot(persona=persona, overrides=self.config or {}),
|
||||
config=build_crawl_config_snapshot(persona=persona, overrides=crawl_config),
|
||||
max_depth=template.max_depth,
|
||||
tags_str=template.tags_str,
|
||||
persona_id=template.persona_id,
|
||||
@@ -129,7 +147,7 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
|
||||
)
|
||||
|
||||
|
||||
class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithStateMachine):
|
||||
class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithQueue):
|
||||
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False)
|
||||
@@ -158,16 +176,19 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
notes = models.TextField(blank=True, null=False, default="")
|
||||
schedule = models.ForeignKey(CrawlSchedule, on_delete=models.SET_NULL, null=True, blank=True, editable=True)
|
||||
|
||||
status = ModelWithStateMachine.StatusField(
|
||||
choices=ModelWithStateMachine.StatusChoices,
|
||||
default=ModelWithStateMachine.StatusChoices.QUEUED,
|
||||
status = ModelWithQueue.StatusField(
|
||||
choices=ModelWithQueue.StatusChoices,
|
||||
default=ModelWithQueue.StatusChoices.QUEUED,
|
||||
)
|
||||
retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now)
|
||||
retry_at = ModelWithQueue.RetryAtField(default=timezone.now)
|
||||
|
||||
state_machine_name = "archivebox.crawls.models.CrawlMachine"
|
||||
retry_at_field_name = "retry_at"
|
||||
state_field_name = "status"
|
||||
StatusChoices = ModelWithStateMachine.StatusChoices
|
||||
StatusChoices = ModelWithQueue.StatusChoices
|
||||
INITIAL_STATE = StatusChoices.QUEUED
|
||||
ACTIVE_STATE = StatusChoices.STARTED
|
||||
FINAL_STATES = (StatusChoices.SEALED,)
|
||||
FINAL_OR_ACTIVE_STATES = (*FINAL_STATES, ACTIVE_STATE)
|
||||
active_state = StatusChoices.STARTED
|
||||
delete_after_final_statuses = (StatusChoices.SEALED,)
|
||||
RUNNABLE_STATES = (StatusChoices.QUEUED, StatusChoices.STARTED)
|
||||
@@ -177,17 +198,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
snapshot_set: models.Manager["Snapshot"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def sm(self) -> "CrawlMachine": ...
|
||||
|
||||
class Meta(
|
||||
ModelWithDeleteAfter.Meta,
|
||||
ModelWithOutputDir.Meta,
|
||||
ModelWithConfig.Meta,
|
||||
ModelWithHealthStats.Meta,
|
||||
ModelWithStateMachine.Meta,
|
||||
ModelWithQueue.Meta,
|
||||
):
|
||||
app_label = "crawls"
|
||||
verbose_name = "Crawl"
|
||||
@@ -220,7 +236,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
def resume(self, *, when=None, save: bool = True) -> bool:
|
||||
resumed = super().resume(when=when, save=save)
|
||||
if resumed and self.pk:
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
resume_at = when or timezone.now()
|
||||
active_snapshots = self.snapshot_set.filter(
|
||||
@@ -231,7 +247,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
retry_at=resume_at,
|
||||
modified_at=timezone.now(),
|
||||
)
|
||||
ArchiveResult.resume_queryset(ArchiveResult.objects.filter(snapshot__crawl=self), when=resume_at)
|
||||
return resumed
|
||||
|
||||
def cancel(self) -> None:
|
||||
@@ -255,7 +270,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
now = timezone.now()
|
||||
# Cancellation seals the Crawl first, then lets the runner seal each
|
||||
# child Snapshot through its own state machine. Active children that
|
||||
# child Snapshot through its own lifecycle. Active children that
|
||||
# are already due need no write; the runner will claim them as-is.
|
||||
active_children = self.snapshot_set.filter(
|
||||
status__in=Snapshot.OPEN_STATES,
|
||||
@@ -272,8 +287,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
now = timezone.now()
|
||||
# Parent pause is a scheduler command. Wake child rows only; each
|
||||
# Snapshot runner claim performs the real pause transition and cascades
|
||||
# its own ArchiveResults, keeping request/admin transactions tiny.
|
||||
# Snapshot runner claim performs the real pause transition, keeping
|
||||
# request/admin transactions tiny.
|
||||
active_children = self.snapshot_set.filter(
|
||||
status__in=Snapshot.RUNNABLE_STATES,
|
||||
)
|
||||
@@ -452,6 +467,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
"tags_str": self.tags_str,
|
||||
"label": self.label,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"modified_at": self.modified_at.isoformat() if self.modified_at else None,
|
||||
"created_by": self.created_by.username,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -518,18 +535,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
return []
|
||||
return [url for _raw_line, url in self._iter_url_lines() if url]
|
||||
|
||||
def has_internal_input_root(self) -> bool:
|
||||
"""Return True when Crawl.urls is preserved source text, not the work queue.
|
||||
|
||||
The runner creates a synthetic root snapshot for raw import text so
|
||||
parser hooks use the same Snapshot lifecycle as every other extractor.
|
||||
In that mode the submitted text must remain in Crawl.urls forever;
|
||||
parsed URLs live as child Snapshot rows and should not be appended back.
|
||||
"""
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
return self.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL, depth=0).exists()
|
||||
|
||||
@staticmethod
|
||||
def normalize_domain(value: str) -> str:
|
||||
candidate = (value or "").strip().lower()
|
||||
@@ -799,15 +804,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
"deleted_snapshots": filter_result["deleted_snapshots"],
|
||||
}
|
||||
|
||||
def get_system_task(self) -> str | None:
|
||||
urls = self.get_urls_list()
|
||||
if len(urls) != 1:
|
||||
return None
|
||||
system_url = urls[0].strip().lower()
|
||||
if system_url.startswith("archivebox://"):
|
||||
return system_url
|
||||
return None
|
||||
|
||||
def resolve_persona(self):
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
@@ -850,24 +846,31 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
output_dir: Path | None = None,
|
||||
num_snapshots: int | None = None,
|
||||
) -> str:
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.limits import parse_filesize_to_bytes
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = self.output_dir
|
||||
if config is None:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self, include_machine=False).for_crawl_runtime(
|
||||
crawl=self,
|
||||
persona=self.resolve_persona(),
|
||||
crawl_output_dir=output_dir,
|
||||
)
|
||||
config = get_config(crawl=self, include_machine=False)
|
||||
|
||||
limits_path = output_dir / ".abx-dl" / "limits.json"
|
||||
if limits_path.exists():
|
||||
stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
|
||||
if stop_reason:
|
||||
return stop_reason
|
||||
# The DB is the shared authority for workers and the UI. A second
|
||||
# ledger beside the artifacts can disagree after cancellation/retry and
|
||||
# would require filesystem access just to render a crawl's status.
|
||||
# Recompute against the current limits so increasing a cap permits work.
|
||||
timeout = int(self._config_value(config, "CRAWL_TIMEOUT", 0) or 0)
|
||||
if timeout:
|
||||
# Snapshot creation already supplies a durable crawl clock. Using
|
||||
# that existing fact avoids another timestamp to keep in sync and
|
||||
# survives cancellation, which deliberately removes unfinished
|
||||
# ArchiveResult rows while preserving the Snapshot for retry.
|
||||
first_snapshot_time = self.snapshot_set.aggregate(first=models.Min("created_at"))["first"]
|
||||
if first_snapshot_time and (timezone.now() - first_snapshot_time).total_seconds() >= timeout:
|
||||
return "crawl_timeout"
|
||||
max_size = parse_filesize_to_bytes(self._config_value(config, "CRAWL_MAX_SIZE", 0) or 0)
|
||||
if max_size:
|
||||
total_size = self.snapshot_set.aggregate(total=models.Sum("output_size"))["total"] or 0
|
||||
if total_size >= max_size:
|
||||
return "crawl_max_size"
|
||||
|
||||
max_urls = int(self._config_value(config, "CRAWL_MAX_URLS", 0) or 0)
|
||||
if num_snapshots is None:
|
||||
@@ -971,14 +974,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
if self.status == self.StatusChoices.SEALED:
|
||||
return []
|
||||
# Internal-input crawls preserve the submitted text verbatim in
|
||||
# Crawl.urls. The root snapshot's parser hooks are the only supported
|
||||
# path for turning that text into child snapshots, otherwise a later
|
||||
# runner pass could reinterpret plain URL-looking lines as direct
|
||||
# depth-0 work and bypass format-specific metadata parsing.
|
||||
if self.has_internal_input_root():
|
||||
return []
|
||||
|
||||
created_snapshots = []
|
||||
crawl_tag_names = self.current_tag_names()
|
||||
tags_by_name: dict[str, Tag] = {}
|
||||
@@ -1194,7 +1189,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
),
|
||||
}
|
||||
if tag_names:
|
||||
tag_ids = [Tag.objects.get_or_create(name=tag_name)[0].pk for tag_name in tag_names]
|
||||
tag_ids = [Tag.get_or_create_by_name(tag_name)[0].pk for tag_name in tag_names]
|
||||
snapshot.add_tag_ids(tag_ids)
|
||||
|
||||
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
|
||||
@@ -1251,16 +1246,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
if not created_snapshots:
|
||||
return []
|
||||
|
||||
crawl_urls = {url for _raw_line, url in self._iter_url_lines() if url}
|
||||
new_url_lines = [snapshot.url for snapshot in created_snapshots if snapshot.url not in crawl_urls]
|
||||
# For internal-input crawls, Crawl.urls is the immutable source text.
|
||||
# Child snapshots are the parsed/indexed representation, so appending
|
||||
# discovered URLs here would both duplicate state and destroy the exact
|
||||
# import artifact users submitted through CLI/API/UI.
|
||||
if new_url_lines and not self.has_internal_input_root():
|
||||
self.urls = (self.urls.rstrip() + "\n" + "\n".join(new_url_lines)).lstrip("\n")
|
||||
self.save(update_fields=["urls", "modified_at"])
|
||||
|
||||
tag_names_by_url: dict[str, set[str]] = {}
|
||||
for snapshot in created_snapshots:
|
||||
tag_names = {
|
||||
@@ -1272,10 +1257,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
}
|
||||
if tag_names:
|
||||
tag_names_by_url[snapshot.url] = tag_names
|
||||
# Snapshot.save() handles model-level validation. The crawl symlink
|
||||
# can still wait until after commit so SQLite does not hold a write
|
||||
# lock while touching the filesystem.
|
||||
transaction.on_commit(lambda snapshot=snapshot: snapshot.ensure_crawl_symlink())
|
||||
|
||||
tag_names = {tag for tags in tag_names_by_url.values() for tag in tags}
|
||||
if tag_names:
|
||||
@@ -1296,170 +1277,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
return created_snapshots
|
||||
|
||||
def install_declared_binaries(self, binary_names: set[str], machine=None) -> None:
|
||||
"""Install crawl-declared binaries through their unified state machine."""
|
||||
from archivebox.crawls.locks import binary_lifecycle_lock
|
||||
from archivebox.machine.models import Binary, Machine
|
||||
|
||||
if not binary_names:
|
||||
return
|
||||
|
||||
machine = machine or Machine.current()
|
||||
binaries = Binary.objects.filter(machine=machine, name__in=binary_names).order_by("name")
|
||||
for binary in binaries:
|
||||
with binary_lifecycle_lock(str(binary.id)):
|
||||
binary.refresh_from_db()
|
||||
if binary.status == Binary.StatusChoices.INSTALLED:
|
||||
continue
|
||||
binary.update_and_requeue(retry_at=timezone.now())
|
||||
binary.refresh_from_db()
|
||||
binary.tick_claimed(lock_seconds=600)
|
||||
|
||||
unresolved_binaries = list(
|
||||
Binary.objects.filter(
|
||||
machine=machine,
|
||||
name__in=binary_names,
|
||||
)
|
||||
.exclude(
|
||||
status=Binary.StatusChoices.INSTALLED,
|
||||
)
|
||||
.order_by("name"),
|
||||
)
|
||||
if unresolved_binaries:
|
||||
binary_details = ", ".join(
|
||||
f"{binary.name} (status={binary.status}, retry_at={binary.retry_at})" for binary in unresolved_binaries
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Crawl dependencies failed to install before continuing: {binary_details}",
|
||||
)
|
||||
|
||||
def run(self) -> "Snapshot | None":
|
||||
"""
|
||||
Execute this Crawl: run hooks, process JSONL, create snapshots.
|
||||
|
||||
Called by the state machine when entering the 'started' state.
|
||||
|
||||
Returns:
|
||||
The root Snapshot for this crawl, or None for system crawls that don't create snapshots
|
||||
"""
|
||||
import time
|
||||
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
def get_runtime_config():
|
||||
return get_config(crawl=self).for_crawl_runtime(
|
||||
crawl=self,
|
||||
persona=persona,
|
||||
runtime_overrides=persona_runtime_overrides,
|
||||
)
|
||||
|
||||
system_task = self.get_system_task()
|
||||
if system_task == "archivebox://update":
|
||||
from archivebox.cli.archivebox_update import process_all_db_snapshots
|
||||
|
||||
process_all_db_snapshots()
|
||||
return None
|
||||
|
||||
machine = Machine.current()
|
||||
declared_binary_names: set[str] = set()
|
||||
persona_runtime_overrides: dict[str, str] = {}
|
||||
persona = self.resolve_persona()
|
||||
if persona:
|
||||
base_runtime_config = get_config(crawl=self, persona=persona)
|
||||
chrome_binary = str(base_runtime_config.get("CHROME_BINARY") or "")
|
||||
persona_runtime_overrides = persona.prepare_runtime_for_crawl(
|
||||
crawl=self,
|
||||
chrome_binary=chrome_binary,
|
||||
)
|
||||
|
||||
def run_crawl_hook(hook: Path) -> set[str]:
|
||||
primary_url = next(
|
||||
(line.strip() for line in self.urls.splitlines() if line.strip()),
|
||||
self.urls.strip(),
|
||||
)
|
||||
|
||||
hook_start = time.time()
|
||||
plugin_name = hook.parent.name
|
||||
output_dir = self.output_dir / plugin_name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
process = run_hook(
|
||||
hook,
|
||||
output_dir=output_dir,
|
||||
config=get_runtime_config(),
|
||||
crawl_id=str(self.id),
|
||||
source_url=self.urls,
|
||||
url=primary_url,
|
||||
snapshot_id=str(self.id),
|
||||
)
|
||||
hook_elapsed = time.time() - hook_start
|
||||
if hook_elapsed > 0.5:
|
||||
print(f"[yellow]⏱️ Hook {hook.name} took {hook_elapsed:.2f}s[/yellow]")
|
||||
|
||||
if process.status == process.StatusChoices.RUNNING:
|
||||
if process.poll() is None:
|
||||
return set()
|
||||
|
||||
from archivebox.plugins.hooks import extract_records_from_process
|
||||
|
||||
records = []
|
||||
# A hook can exit before its completed Process metadata is visible.
|
||||
# Give successful hooks a brief chance to flush JSONL stdout into
|
||||
# the Process row before downstream hooks.
|
||||
for delay in (0.0, 0.05, 0.1, 0.25, 0.5):
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
records = extract_records_from_process(process)
|
||||
if records:
|
||||
break
|
||||
if records:
|
||||
print(f"[cyan]📝 Processing {len(records)} records from {hook.name}[/cyan]")
|
||||
for record in records[:3]:
|
||||
print(f" Record: type={record.get('type')}, keys={list(record.keys())[:5]}")
|
||||
if system_task:
|
||||
records = [record for record in records if record.get("type") in ("BinaryRequest", "Binary")]
|
||||
overrides = {"crawl": self}
|
||||
stats = process_hook_records(records, overrides=overrides)
|
||||
if stats:
|
||||
print(f"[green]✓ Created: {stats}[/green]")
|
||||
|
||||
hook_binary_names = {
|
||||
str(record.get("name")).strip()
|
||||
for record in records
|
||||
if record.get("type") in ("BinaryRequest", "Binary") and record.get("name")
|
||||
}
|
||||
hook_binary_names.discard("")
|
||||
if hook_binary_names:
|
||||
declared_binary_names.update(hook_binary_names)
|
||||
return hook_binary_names
|
||||
|
||||
hooks = discover_hooks("Crawl", config=get_runtime_config())
|
||||
|
||||
for hook in hooks:
|
||||
hook_binary_names = run_crawl_hook(hook)
|
||||
if hook_binary_names:
|
||||
self.install_declared_binaries(hook_binary_names, machine=machine)
|
||||
|
||||
# Safety check: don't create snapshots if any crawl-declared dependency
|
||||
# is still unresolved after all crawl hooks have run.
|
||||
self.install_declared_binaries(declared_binary_names, machine=machine)
|
||||
|
||||
# Create snapshots from all URLs in self.urls
|
||||
if system_task:
|
||||
leaked_snapshots = self.snapshot_set.all()
|
||||
if leaked_snapshots.exists():
|
||||
leaked_count = leaked_snapshots.count()
|
||||
leaked_snapshots.delete()
|
||||
print(f"[yellow]⚠️ Removed {leaked_count} leaked snapshot(s) created during system crawl {system_task}[/yellow]")
|
||||
return None
|
||||
|
||||
self.create_snapshots_from_urls()
|
||||
|
||||
# Return first snapshot for this crawl (newly created or existing)
|
||||
# This ensures the crawl doesn't seal if snapshots exist, even if they weren't just created
|
||||
return self.snapshot_set.first()
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
"""Check if crawl is finished (all snapshots sealed or no snapshots exist)."""
|
||||
from archivebox.core.models import Snapshot
|
||||
@@ -1483,11 +1300,64 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
|
||||
return True
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up background hooks and run on_CrawlEnd hooks."""
|
||||
from archivebox.plugins.hooks import run_hook, discover_hooks
|
||||
def can_start(self) -> bool:
|
||||
return bool(self.urls and self.get_urls_list())
|
||||
|
||||
# Clean up .pid files from output directory
|
||||
def has_finished_snapshots(self) -> bool:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = self.snapshot_set.all()
|
||||
return snapshots.exists() and not snapshots.exclude(status=Snapshot.StatusChoices.SEALED).exists()
|
||||
|
||||
def mark_started(self) -> bool:
|
||||
now = timezone.now()
|
||||
updated = self.safe_update(
|
||||
{
|
||||
"status": self.StatusChoices.STARTED,
|
||||
"retry_at": now + timedelta(seconds=2),
|
||||
},
|
||||
extra_filter={"status": self.StatusChoices.QUEUED},
|
||||
)
|
||||
return updated
|
||||
|
||||
def seal(self) -> bool:
|
||||
"""Finalize a runner-owned Crawl without dispatching hooks directly."""
|
||||
now = timezone.now()
|
||||
updated = self.safe_update(
|
||||
{
|
||||
"status": self.StatusChoices.SEALED,
|
||||
"retry_at": None,
|
||||
"modified_at": now,
|
||||
},
|
||||
refresh=False,
|
||||
extra_filter={"status__in": (*self.RUNNABLE_STATES, self.StatusChoices.SEALED)},
|
||||
)
|
||||
if not updated:
|
||||
self.refresh_from_db()
|
||||
return False
|
||||
self.status = self.StatusChoices.SEALED
|
||||
self.retry_at = None
|
||||
self.modified_at = now
|
||||
self.schedule_child_snapshots_for_sealing()
|
||||
self.cleanup_runtime()
|
||||
return True
|
||||
|
||||
def advance_lifecycle(self) -> bool:
|
||||
"""Advance one explicit lifecycle step after the runner claims this row."""
|
||||
if self.status == self.StatusChoices.PAUSED:
|
||||
return False
|
||||
if self.status == self.StatusChoices.QUEUED:
|
||||
if self.has_finished_snapshots():
|
||||
return self.seal()
|
||||
if not self.can_start():
|
||||
return False
|
||||
return self.mark_started()
|
||||
if self.status == self.StatusChoices.STARTED and self.is_finished():
|
||||
return self.seal()
|
||||
return False
|
||||
|
||||
def cleanup_runtime(self) -> None:
|
||||
"""Remove runner-owned runtime artifacts after abx-dl cleanup hooks finish."""
|
||||
if self.output_dir.exists():
|
||||
for pid_file in self.output_dir.glob("**/*.pid"):
|
||||
pid_file.unlink(missing_ok=True)
|
||||
@@ -1495,207 +1365,3 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
persona = self.resolve_persona()
|
||||
if persona:
|
||||
persona.cleanup_runtime_for_crawl(self)
|
||||
|
||||
# Run on_CrawlEnd hooks
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self)
|
||||
|
||||
hooks = discover_hooks("CrawlEnd", config=config)
|
||||
|
||||
for hook in hooks:
|
||||
plugin_name = hook.parent.name
|
||||
output_dir = self.output_dir / plugin_name
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
process = run_hook(
|
||||
hook,
|
||||
output_dir=output_dir,
|
||||
config=config,
|
||||
crawl_id=str(self.id),
|
||||
source_url=self.urls, # Pass full newline-separated URLs
|
||||
)
|
||||
|
||||
# Log failures but don't block
|
||||
if process.exit_code != 0:
|
||||
print(f"[yellow]⚠️ CrawlEnd hook failed: {hook.name}[/yellow]")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State Machines
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CrawlMachine(BaseStateMachine):
|
||||
crawl: Crawl
|
||||
|
||||
"""
|
||||
State machine for managing Crawl lifecycle.
|
||||
|
||||
Hook Lifecycle:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ QUEUED State │
|
||||
│ • Waiting for crawl to be ready (has URLs) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ tick() when can_start()
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ STARTED State → enter_started() │
|
||||
│ 1. crawl.run() │
|
||||
│ • discover_hooks('Crawl') → finds all crawl hooks │
|
||||
│ • For each hook: │
|
||||
│ - run_hook(script, output_dir, ...) │
|
||||
│ - Parse JSONL from hook output │
|
||||
│ - process_hook_records() → creates Snapshots │
|
||||
│ • create_snapshots_from_urls() → from self.urls field │
|
||||
│ │
|
||||
│ 2. Snapshots process independently with their own │
|
||||
│ state machines (see SnapshotMachine) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ tick() when is_finished()
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SEALED State → enter_sealed() │
|
||||
│ • cleanup() → runs on_CrawlEnd hooks, kills background │
|
||||
│ • Set retry_at=None (no more processing) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
"""
|
||||
|
||||
model_attr_name = "crawl"
|
||||
|
||||
# States
|
||||
queued = State(value=Crawl.StatusChoices.QUEUED, initial=True)
|
||||
started = State(value=Crawl.StatusChoices.STARTED)
|
||||
paused = State(value=Crawl.StatusChoices.PAUSED)
|
||||
sealed = State(value=Crawl.StatusChoices.SEALED, final=True)
|
||||
|
||||
# Tick Event (polled by workers)
|
||||
tick = (
|
||||
queued.to(sealed, cond="has_finished_snapshots")
|
||||
| queued.to.itself(unless="can_start")
|
||||
| queued.to(started, cond="can_start")
|
||||
| started.to(sealed, cond="is_finished")
|
||||
| paused.to.itself()
|
||||
)
|
||||
|
||||
# Manual event (triggered by last Snapshot sealing, or by direct
|
||||
# index-only/bg creation when every requested URL is rejected before any
|
||||
# Snapshot rows exist).
|
||||
seal = queued.to(sealed) | started.to(sealed) | paused.to(sealed)
|
||||
pause_requested = queued.to(paused) | started.to(paused)
|
||||
resume_requested = paused.to(queued)
|
||||
|
||||
def can_start(self) -> bool:
|
||||
if not self.crawl.urls:
|
||||
print(f"[red]⚠️ Crawl {self.crawl.id} cannot start: no URLs[/red]")
|
||||
return False
|
||||
urls_list = self.crawl.get_urls_list()
|
||||
if not urls_list:
|
||||
print(f"[red]⚠️ Crawl {self.crawl.id} cannot start: no valid URLs in urls field[/red]")
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
"""Check if all Snapshots for this crawl are finished."""
|
||||
return self.crawl.is_finished()
|
||||
|
||||
def has_finished_snapshots(self) -> bool:
|
||||
"""A queued crawl with only final Snapshot rows was interrupted before sealing."""
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = self.crawl.snapshot_set.all()
|
||||
return snapshots.exists() and not snapshots.exclude(status=Snapshot.StatusChoices.SEALED).exists()
|
||||
|
||||
@queued.enter
|
||||
def enter_queued(self):
|
||||
self.crawl.update_and_requeue(
|
||||
retry_at=timezone.now(),
|
||||
status=Crawl.StatusChoices.QUEUED,
|
||||
)
|
||||
|
||||
@started.enter
|
||||
def enter_started(self):
|
||||
import sys
|
||||
|
||||
print(f"[cyan]🔄 CrawlMachine.enter_started() - creating snapshots for {self.crawl.id}[/cyan]", file=sys.stderr)
|
||||
|
||||
try:
|
||||
# Run the crawl - runs hooks, processes JSONL, creates snapshots
|
||||
first_snapshot = self.crawl.run()
|
||||
|
||||
if first_snapshot:
|
||||
print(
|
||||
f"[cyan]🔄 Created {self.crawl.snapshot_set.count()} snapshot(s), first: {first_snapshot.url}[/cyan]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Update status to STARTED
|
||||
# Set retry_at to near future so tick() can poll and check is_finished()
|
||||
self.crawl.update_and_requeue(
|
||||
retry_at=timezone.now() + timedelta(seconds=2),
|
||||
status=Crawl.StatusChoices.STARTED,
|
||||
)
|
||||
else:
|
||||
# No snapshots (system crawl that only runs setup hooks)
|
||||
print("[cyan]🔄 No snapshots created, sealing crawl immediately[/cyan]", file=sys.stderr)
|
||||
# Seal immediately since there's no work to do
|
||||
self.seal()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[red]⚠️ Crawl {self.crawl.id} failed to start: {e}[/red]")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
@paused.enter
|
||||
def enter_paused(self):
|
||||
paused = self.crawl.safe_update(
|
||||
{
|
||||
"retry_at": RETRY_AT_MAX,
|
||||
"status": Crawl.StatusChoices.PAUSED,
|
||||
},
|
||||
extra_filter={"status__in": Crawl.RUNNABLE_STATES},
|
||||
)
|
||||
if paused:
|
||||
self.crawl.schedule_child_snapshots_for_pause()
|
||||
|
||||
@sealed.enter
|
||||
def enter_sealed(self):
|
||||
now = timezone.now()
|
||||
self.crawl.status = Crawl.StatusChoices.SEALED
|
||||
self.crawl.retry_at = None
|
||||
# Guard: never seal a row that a concurrent writer flipped to PAUSED.
|
||||
# Sealing is idempotent (SEALED→SEALED is a no-op rewrite), so
|
||||
# status__in covers both the QUEUED/STARTED→SEALED transition and the
|
||||
# rare re-entry case.
|
||||
updated = self.crawl.safe_update(
|
||||
{
|
||||
"status": Crawl.StatusChoices.SEALED,
|
||||
"retry_at": None,
|
||||
"modified_at": now,
|
||||
},
|
||||
refresh=False,
|
||||
extra_filter={
|
||||
"status__in": [
|
||||
Crawl.StatusChoices.QUEUED,
|
||||
Crawl.StatusChoices.STARTED,
|
||||
Crawl.StatusChoices.SEALED,
|
||||
],
|
||||
},
|
||||
)
|
||||
if not updated:
|
||||
self.crawl.refresh_from_db()
|
||||
return
|
||||
self.crawl.modified_at = now
|
||||
|
||||
self.crawl.schedule_child_snapshots_for_sealing()
|
||||
# Clean up background hooks and run on_CrawlEnd hooks after the final
|
||||
# state is visible so cleanup projectors cannot resurrect the crawl.
|
||||
self.crawl.cleanup()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Register State Machines
|
||||
# =============================================================================
|
||||
|
||||
# Manually register state machines with python-statemachine registry
|
||||
# (normally auto-discovered from statemachines.py, but we define them here for clarity)
|
||||
registry.register(CrawlMachine)
|
||||
@@ -10,14 +10,6 @@ class MachineConfig(AppConfig):
|
||||
label = "machine" # Explicit label for migrations
|
||||
verbose_name = "Machine Info"
|
||||
|
||||
def ready(self):
|
||||
"""Import models to register state machines with the registry"""
|
||||
import sys
|
||||
|
||||
# Skip during makemigrations to avoid premature state machine access
|
||||
if "makemigrations" not in sys.argv:
|
||||
from archivebox.machine import models # noqa: F401
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
from archivebox.machine.admin import register_admin
|
||||
|
||||
@@ -57,7 +57,10 @@ def get_vm_info():
|
||||
|
||||
hw_manufacturer = "Docker" if hw_in_docker else "Unknown"
|
||||
hw_product = "Container" if hw_in_docker else "Unknown"
|
||||
hw_uuid = machineid.id()
|
||||
try:
|
||||
hw_uuid = machineid.id()
|
||||
except machineid.MachineIdNotFound:
|
||||
hw_uuid = "Unknown"
|
||||
|
||||
if platform.system().lower() == "darwin":
|
||||
# Get macOS machine info
|
||||
@@ -88,7 +91,7 @@ def get_vm_info():
|
||||
try:
|
||||
# Getting SMBIOS data from sysfs.
|
||||
# SMBIOS 2.8 present.
|
||||
# argo-1 | 2024-10-01T10:40:51Z ERR error="Incoming request ended abruptly: context canceled" connIndex=2 event=1 ingressRule=0 originService=http://archivebox:8000 │
|
||||
# argo-1 | 2024-10-01T10:40:51Z ERR error="Incoming request ended abruptly: context canceled" connIndex=2 event=1 ingressRule=0 originService=http://archivebox:5797 │
|
||||
# Handle 0x0100, DMI type 1, 27 bytes
|
||||
# System Information
|
||||
# Manufacturer: DigitalOcean
|
||||
@@ -301,7 +304,9 @@ def get_host_stats() -> dict[str, Any]:
|
||||
|
||||
|
||||
def get_host_guid() -> str:
|
||||
return machineid.hashed_id("archivebox")
|
||||
from archivebox.config.paths import get_machine_id
|
||||
|
||||
return get_machine_id(length=64)
|
||||
|
||||
|
||||
# Example usage
|
||||
|
||||
+83
-240
@@ -12,8 +12,6 @@ from archivebox.uuid_compat import CompactUUIDField, uuid7
|
||||
from datetime import timedelta, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from statemachine import State, registry
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
@@ -23,7 +21,7 @@ from django.utils.functional import cached_property
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import rprint
|
||||
from archivebox.base_models.models import ModelWithDeleteAfter, ModelWithHealthStats, normalize_config_json_values
|
||||
from archivebox.workers.models import BaseStateMachine, ModelWithStateMachine
|
||||
from archivebox.workers.models import ModelWithQueue
|
||||
from .detect import get_host_guid, get_os_info, get_vm_info, get_host_network, get_host_stats
|
||||
|
||||
_psutil: Any | None = None
|
||||
@@ -229,6 +227,9 @@ class Machine(ModelWithHealthStats):
|
||||
_CURRENT_MACHINE = None
|
||||
if _CURRENT_MACHINE:
|
||||
if timezone.now() < _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
|
||||
# Reconciliation can save() and clear the shared cache; keep
|
||||
# this call's instance so we never return None after that save.
|
||||
machine = _CURRENT_MACHINE
|
||||
# One-time-per-process reconciliation between ArchiveBox.conf
|
||||
# and Machine.config. Fast-path: bool check + early-return when
|
||||
# the sync has already run, so the cached-machine return path
|
||||
@@ -236,16 +237,21 @@ class Machine(ModelWithHealthStats):
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
|
||||
sync_machine_and_file(_CURRENT_MACHINE)
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
return _CURRENT_MACHINE
|
||||
return machine
|
||||
else:
|
||||
_CURRENT_MACHINE = None
|
||||
|
||||
host_guid = get_host_guid()
|
||||
# save() intentionally invalidates _CURRENT_MACHINE so config edits
|
||||
# made through other instances become visible in this process. Host
|
||||
# refresh (after seven days), sanitation, and reconciliation can all
|
||||
# save here, so the working instance must not live only in that cache.
|
||||
# Otherwise a routine refresh leaves startup dereferencing None.
|
||||
try:
|
||||
_CURRENT_MACHINE = cls.objects.get(guid=host_guid)
|
||||
machine = cls.objects.get(guid=host_guid)
|
||||
except cls.DoesNotExist:
|
||||
config = {}
|
||||
try:
|
||||
@@ -255,7 +261,7 @@ class Machine(ModelWithHealthStats):
|
||||
config = _coerce_from_str_dict(file_config)
|
||||
except Exception:
|
||||
config = {}
|
||||
_CURRENT_MACHINE = cls.objects.create(
|
||||
machine = cls.objects.create(
|
||||
guid=host_guid,
|
||||
hostname=socket.gethostname(),
|
||||
config=config,
|
||||
@@ -264,15 +270,15 @@ class Machine(ModelWithHealthStats):
|
||||
stats=get_host_stats(),
|
||||
)
|
||||
else:
|
||||
if timezone.now() >= _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
|
||||
if timezone.now() >= machine.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
|
||||
for key, value in {
|
||||
"hostname": socket.gethostname(),
|
||||
**get_os_info(),
|
||||
**get_vm_info(),
|
||||
"stats": get_host_stats(),
|
||||
}.items():
|
||||
setattr(_CURRENT_MACHINE, key, value)
|
||||
_CURRENT_MACHINE.save(
|
||||
setattr(machine, key, value)
|
||||
machine.save(
|
||||
update_fields=[
|
||||
"hostname",
|
||||
"hw_in_docker",
|
||||
@@ -289,16 +295,19 @@ class Machine(ModelWithHealthStats):
|
||||
"modified_at",
|
||||
],
|
||||
)
|
||||
machine = cls._sanitize_config(_CURRENT_MACHINE)
|
||||
machine = cls._sanitize_config(machine)
|
||||
# Same one-time sync as the cached-return path. Triggers here on the
|
||||
# very first ``Machine.current()`` call in a process before any
|
||||
# cached return can occur.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
|
||||
sync_machine_and_file(_CURRENT_MACHINE)
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
# Publish only after all save-capable steps; publishing earlier would
|
||||
# let those steps invalidate the cache we are in the middle of filling.
|
||||
_CURRENT_MACHINE = machine
|
||||
return machine
|
||||
|
||||
@classmethod
|
||||
@@ -515,19 +524,21 @@ class BinaryManager(models.Manager):
|
||||
)
|
||||
|
||||
|
||||
class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
class Binary(ModelWithHealthStats, ModelWithQueue):
|
||||
"""
|
||||
Tracks a binary on a specific machine.
|
||||
|
||||
Simple state machine with 2 states:
|
||||
Simple queue lifecycle with 2 states:
|
||||
- queued: Binary needs to be installed
|
||||
- installed: Binary installed successfully (abspath, version, sha256 populated)
|
||||
|
||||
Installation is synchronous during queued→installed transition.
|
||||
If installation fails, Binary stays in queued with retry_at set for later retry.
|
||||
|
||||
State machine calls run(), which emits an abxpkg BinaryRequestEvent through
|
||||
the ArchiveBox runner and installs the binary using the specified providers.
|
||||
BinaryService claims queued rows with a conditional update, then run()
|
||||
emits an abxpkg BinaryRequestEvent and persists the resolved installation.
|
||||
The database row is the only lifecycle state; there is intentionally no
|
||||
second in-memory state machine to reconcile after a worker interruption.
|
||||
"""
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
@@ -566,9 +577,9 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
version = models.CharField(max_length=32, default="", null=False, blank=True)
|
||||
sha256 = models.CharField(max_length=64, default="", null=False, blank=True)
|
||||
|
||||
# State machine fields
|
||||
status = ModelWithStateMachine.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED, max_length=16)
|
||||
retry_at = ModelWithStateMachine.RetryAtField(
|
||||
# Durable queue lifecycle fields
|
||||
status = ModelWithQueue.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED, max_length=16)
|
||||
retry_at = ModelWithQueue.RetryAtField(
|
||||
default=timezone.now,
|
||||
help_text="When to retry this binary installation",
|
||||
)
|
||||
@@ -579,18 +590,16 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
|
||||
machine_id: uuid.UUID
|
||||
|
||||
state_machine_name: str | None = "archivebox.machine.models.BinaryMachine"
|
||||
INITIAL_STATE = StatusChoices.QUEUED
|
||||
ACTIVE_STATE = StatusChoices.QUEUED
|
||||
FINAL_STATES = (StatusChoices.INSTALLED,)
|
||||
FINAL_OR_ACTIVE_STATES = (*FINAL_STATES, ACTIVE_STATE)
|
||||
active_state: str = StatusChoices.QUEUED
|
||||
warn_on_save_outside_runner = False
|
||||
|
||||
objects = BinaryManager() # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@property
|
||||
def sm(self) -> BinaryMachine: ...
|
||||
|
||||
class Meta(ModelWithHealthStats.Meta, ModelWithStateMachine.Meta):
|
||||
class Meta(ModelWithHealthStats.Meta, ModelWithQueue.Meta):
|
||||
app_label = "machine"
|
||||
verbose_name = "Binary"
|
||||
verbose_name_plural = "Binaries"
|
||||
@@ -604,6 +613,10 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
"""A binary is valid if it has a resolved path and is marked installed."""
|
||||
return bool(self.abspath) and self.status == self.StatusChoices.INSTALLED
|
||||
|
||||
@property
|
||||
def can_install(self) -> bool:
|
||||
return bool(self.name and self.binproviders)
|
||||
|
||||
@cached_property
|
||||
def binary_info(self) -> dict:
|
||||
"""Return info about the binary."""
|
||||
@@ -748,11 +761,43 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
|
||||
|
||||
run_binary(str(self.id))
|
||||
|
||||
def install(self) -> bool:
|
||||
"""Run one synchronous installation attempt for a claimed Binary."""
|
||||
if self.status == self.StatusChoices.INSTALLED:
|
||||
return True
|
||||
if not self.can_install:
|
||||
return False
|
||||
|
||||
rprint(f"[cyan] 🔄 installing {self.name}[/cyan]", file=sys.stderr)
|
||||
self.run()
|
||||
self.refresh_from_db()
|
||||
if self.status != self.StatusChoices.INSTALLED:
|
||||
self.update_and_requeue(
|
||||
retry_at=timezone.now() + timedelta(seconds=300),
|
||||
status=self.StatusChoices.QUEUED,
|
||||
)
|
||||
self.increment_health_stats(success=False)
|
||||
raise RuntimeError(f"Binary {self.name} installation failed")
|
||||
|
||||
self.update_and_requeue(retry_at=None, status=self.StatusChoices.INSTALLED)
|
||||
self.increment_health_stats(success=True)
|
||||
return True
|
||||
|
||||
def advance_lifecycle(self) -> bool:
|
||||
"""Advance the explicit binary lifecycle after its queue row is claimed."""
|
||||
return self.install()
|
||||
|
||||
def install_claimed(self, *, lock_seconds: int = 600) -> bool:
|
||||
if not self.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
self.refresh_from_db()
|
||||
return self.advance_lifecycle()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Clean up background binary installation hooks.
|
||||
|
||||
Called by state machine if needed (not typically used for binaries
|
||||
Called after an installation attempt if needed (not typically used for binaries
|
||||
since installations are foreground, but included for consistency).
|
||||
"""
|
||||
|
||||
@@ -953,12 +998,14 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
One Process can optionally be associated with an ArchiveResult (via OneToOne),
|
||||
but Process can also exist standalone for internal operations.
|
||||
|
||||
Follows the unified state machine pattern:
|
||||
Follows the unified process lifecycle:
|
||||
- queued: Process ready to launch
|
||||
- running: Process actively executing
|
||||
- exited: Process completed (check exit_code for success/failure)
|
||||
|
||||
State machine calls launch() to spawn the process and monitors its lifecycle.
|
||||
Direct Process methods and the abx-dl event projector own these transitions.
|
||||
Keeping the DB row as the only lifecycle state makes interrupted subprocess
|
||||
recovery observable without reconciling a second in-memory state machine.
|
||||
"""
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
@@ -1117,7 +1164,7 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
# Reverse relation to ArchiveResult (OneToOne from AR side)
|
||||
# archiveresult: OneToOneField defined on ArchiveResult model
|
||||
|
||||
# State machine fields
|
||||
# Durable process lifecycle fields
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=StatusChoices.choices,
|
||||
@@ -1138,7 +1185,6 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
children: models.Manager[Process]
|
||||
archiveresult: ArchiveResult
|
||||
|
||||
state_machine_name: str = "archivebox.machine.models.ProcessMachine"
|
||||
delete_after_final_statuses = (StatusChoices.EXITED,)
|
||||
|
||||
objects = ProcessManager() # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
@@ -1308,8 +1354,8 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
"""
|
||||
Compare-and-swap update for short Process scheduler writes.
|
||||
|
||||
Process is not a ModelWithStateMachine subclass yet, but its
|
||||
state-machine methods still need the same modified_at CAS behavior as
|
||||
Process is not a ModelWithQueue subclass, but its scheduler methods
|
||||
still need the same modified_at CAS behavior as
|
||||
Crawl/Snapshot/Binary without falling back to save().
|
||||
"""
|
||||
values = dict(update_fields)
|
||||
@@ -2233,8 +2279,8 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
"""
|
||||
Gracefully terminate process: SIGTERM → wait → SIGKILL.
|
||||
|
||||
This consolidates the scattered SIGTERM/SIGKILL logic from:
|
||||
- crawls/models.py Crawl.cleanup()
|
||||
This consolidates SIGTERM/SIGKILL logic used by:
|
||||
- workers/management/commands/runner_watch.py
|
||||
- workers/pid_utils.py stop_worker()
|
||||
- supervisord_util.py stop_existing_supervisord_process()
|
||||
|
||||
@@ -2297,8 +2343,8 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
Uses parallel polling approach - sends SIGTERM to all processes at once,
|
||||
then polls all simultaneously with individual deadline tracking.
|
||||
|
||||
This consolidates the scattered child-killing logic from:
|
||||
- crawls/models.py Crawl.cleanup() os.killpg()
|
||||
This consolidates child-killing logic used by:
|
||||
- core/takeover_util.py
|
||||
- supervisord_util.py stop_existing_supervisord_process()
|
||||
|
||||
Args:
|
||||
@@ -2598,206 +2644,3 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
if cleaned:
|
||||
rprint(f"[yellow]🧹 Cleaned up {cleaned} orphaned worker/hook process record(s)[/yellow]")
|
||||
return cleaned
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Binary State Machine
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BinaryMachine(BaseStateMachine):
|
||||
"""
|
||||
State machine for managing Binary installation lifecycle.
|
||||
|
||||
Simple 2-state machine:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ QUEUED State │
|
||||
│ • Binary needs to be installed │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ tick() when can_install()
|
||||
↓ Synchronous installation during transition
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ INSTALLED State │
|
||||
│ • Binary installed (abspath, version, sha256 set) │
|
||||
│ • Health stats incremented │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
If installation fails, Binary stays in QUEUED with retry_at bumped.
|
||||
"""
|
||||
|
||||
model_attr_name = "binary"
|
||||
binary: Binary
|
||||
|
||||
# States
|
||||
queued = State(value=Binary.StatusChoices.QUEUED, initial=True)
|
||||
installed = State(value=Binary.StatusChoices.INSTALLED, final=True)
|
||||
|
||||
# Tick Event - install happens during transition
|
||||
tick = queued.to.itself(unless="can_install") | queued.to(installed, cond="can_install", on="on_install")
|
||||
|
||||
def can_install(self) -> bool:
|
||||
"""Check if binary installation can start."""
|
||||
return bool(self.binary.name and self.binary.binproviders)
|
||||
|
||||
@queued.enter
|
||||
def enter_queued(self):
|
||||
"""Binary is queued for installation."""
|
||||
self.binary.update_and_requeue(
|
||||
retry_at=timezone.now(),
|
||||
status=Binary.StatusChoices.QUEUED,
|
||||
)
|
||||
|
||||
def on_install(self):
|
||||
"""Called during queued→installed transition. Runs installation synchronously."""
|
||||
import sys
|
||||
|
||||
rprint(f"[cyan] 🔄 BinaryMachine.on_install() - installing {self.binary.name}[/cyan]", file=sys.stderr)
|
||||
|
||||
# Run installation hooks (synchronous, updates abspath/version/sha256 and sets status)
|
||||
self.binary.run()
|
||||
|
||||
# Check if installation succeeded by looking at updated status
|
||||
# Note: Binary.run() updates self.binary.status internally but doesn't refresh our reference
|
||||
self.binary.refresh_from_db()
|
||||
|
||||
if self.binary.status != Binary.StatusChoices.INSTALLED:
|
||||
# Installation failed - abort transition, stay in queued
|
||||
rprint(f"[red] ❌ BinaryMachine - {self.binary.name} installation failed, retrying later[/red]", file=sys.stderr)
|
||||
|
||||
# Bump retry_at to try again later
|
||||
self.binary.update_and_requeue(
|
||||
retry_at=timezone.now() + timedelta(seconds=300), # Retry in 5 minutes
|
||||
status=Binary.StatusChoices.QUEUED, # Ensure we stay queued
|
||||
)
|
||||
|
||||
# Increment health stats for failure
|
||||
self.binary.increment_health_stats(success=False)
|
||||
|
||||
# Abort the transition - this will raise an exception and keep us in queued
|
||||
raise Exception(f"Binary {self.binary.name} installation failed")
|
||||
|
||||
rprint(f"[cyan] ✅ BinaryMachine - {self.binary.name} installed successfully[/cyan]", file=sys.stderr)
|
||||
|
||||
@installed.enter
|
||||
def enter_installed(self):
|
||||
"""Binary installed successfully."""
|
||||
self.binary.update_and_requeue(
|
||||
retry_at=None,
|
||||
status=Binary.StatusChoices.INSTALLED,
|
||||
)
|
||||
|
||||
# Increment health stats
|
||||
self.binary.increment_health_stats(success=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Process State Machine
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ProcessMachine(BaseStateMachine):
|
||||
"""
|
||||
State machine for managing Process (OS subprocess) lifecycle.
|
||||
|
||||
Process Lifecycle:
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ QUEUED State │
|
||||
│ • Process ready to launch, waiting for resources │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ tick() when can_start()
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ RUNNING State → enter_running() │
|
||||
│ 1. process.launch() │
|
||||
│ • Spawn subprocess with cmd, pwd, env, timeout │
|
||||
│ • Set pid, started_at │
|
||||
│ • Process runs in background or foreground │
|
||||
│ 2. Monitor process completion │
|
||||
│ • Check exit code when process completes │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓ tick() checks is_exited()
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ EXITED State │
|
||||
│ • Process completed (exit_code set) │
|
||||
│ • Health stats incremented │
|
||||
│ • stdout/stderr captured │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Note: This is a simpler state machine than ArchiveResult.
|
||||
Process is just about execution lifecycle. ArchiveResult handles
|
||||
the archival-specific logic (status, output parsing, etc.).
|
||||
"""
|
||||
|
||||
model_attr_name = "process"
|
||||
process: Process
|
||||
|
||||
# States
|
||||
queued = State(value=Process.StatusChoices.QUEUED, initial=True)
|
||||
running = State(value=Process.StatusChoices.RUNNING)
|
||||
exited = State(value=Process.StatusChoices.EXITED, final=True)
|
||||
|
||||
# Tick Event - transitions based on conditions
|
||||
tick = (
|
||||
queued.to.itself(unless="can_start")
|
||||
| queued.to(running, cond="can_start")
|
||||
| running.to.itself(unless="is_exited")
|
||||
| running.to(exited, cond="is_exited")
|
||||
)
|
||||
|
||||
# Additional events (for explicit control)
|
||||
launch = queued.to(running)
|
||||
kill = running.to(exited)
|
||||
|
||||
def can_start(self) -> bool:
|
||||
"""Check if process can start (has cmd and machine)."""
|
||||
return bool(self.process.cmd and self.process.machine)
|
||||
|
||||
def is_exited(self) -> bool:
|
||||
"""Check if process has exited (exit_code is set)."""
|
||||
return self.process.exit_code is not None
|
||||
|
||||
@queued.enter
|
||||
def enter_queued(self):
|
||||
"""Process is queued for execution."""
|
||||
self.process.update_and_requeue(
|
||||
retry_at=timezone.now(),
|
||||
status=Process.StatusChoices.QUEUED,
|
||||
)
|
||||
|
||||
@running.enter
|
||||
def enter_running(self):
|
||||
"""Start process execution."""
|
||||
# Lock the process while it runs
|
||||
self.process.update_and_requeue(
|
||||
retry_at=timezone.now() + timedelta(seconds=self.process.timeout),
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
started_at=timezone.now(),
|
||||
)
|
||||
|
||||
# Launch the subprocess
|
||||
# NOTE: This is a placeholder - actual launch logic would
|
||||
# be implemented based on how hooks currently spawn processes
|
||||
# For now, Process is a data model that tracks execution metadata
|
||||
# The actual subprocess spawning is still handled by run_hook()
|
||||
|
||||
# Mark as immediately exited for now (until we refactor run_hook)
|
||||
# In the future, this would actually spawn the subprocess
|
||||
self.process.exit_code = 0 # Placeholder
|
||||
self.process.save()
|
||||
|
||||
@exited.enter
|
||||
def enter_exited(self):
|
||||
"""Process has exited."""
|
||||
self.process.update_and_requeue(
|
||||
retry_at=None,
|
||||
status=Process.StatusChoices.EXITED,
|
||||
ended_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# State Machine Registration
|
||||
# =============================================================================
|
||||
|
||||
# Manually register state machines with python-statemachine registry
|
||||
registry.register(BinaryMachine)
|
||||
registry.register(ProcessMachine)
|
||||
@@ -255,7 +255,7 @@ def check_data_dir_permissions(config=None, **config_kwargs):
|
||||
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#permissions]https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#permissions[/link]",
|
||||
)
|
||||
STDERR.print(
|
||||
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid]https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid[/link]",
|
||||
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#puid--pgid]https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#puid--pgid[/link]",
|
||||
)
|
||||
STDERR.print(
|
||||
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#filesystem-doesnt-support-fsync-eg-network-mounts]https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#filesystem-doesnt-support-fsync-eg-network-mounts[/link]",
|
||||
|
||||
+18
-7
@@ -380,29 +380,40 @@ def log_sqlite_lock_holders(console: Any, *, db_path: Path = CONSTANTS.DATABASE_
|
||||
def sqlite_lock_error(error: BaseException) -> bool:
|
||||
from django.db import OperationalError as DjangoOperationalError
|
||||
|
||||
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and "database is locked" in str(error).lower()
|
||||
message = str(error).lower()
|
||||
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and (
|
||||
"database is locked" in message or "database table is locked" in message
|
||||
)
|
||||
|
||||
|
||||
def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO | None = None) -> Any:
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, connections
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(file=stderr or None, stderr=stderr is None)
|
||||
started_at = time.monotonic()
|
||||
retry_timeout = settings.CONFIG.SQLITE_LOCK_RETRY_TIMEOUT
|
||||
retry_interval = settings.CONFIG.SQLITE_LOCK_RETRY_INTERVAL
|
||||
while True:
|
||||
try:
|
||||
return action()
|
||||
except OperationalError as err:
|
||||
if "database is locked" not in str(err).lower():
|
||||
raise
|
||||
except SQLiteOperationalError as err:
|
||||
except (OperationalError, SQLiteOperationalError) as err:
|
||||
if not sqlite_lock_error(err):
|
||||
raise
|
||||
if retry_timeout:
|
||||
remaining = retry_timeout - (time.monotonic() - started_at)
|
||||
if remaining <= 0:
|
||||
raise
|
||||
sleep_for = min(retry_interval, remaining)
|
||||
else:
|
||||
sleep_for = retry_interval
|
||||
|
||||
connections.close_all()
|
||||
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in 5s...[/yellow]")
|
||||
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in {sleep_for:g}s...[/yellow]")
|
||||
log_sqlite_lock_holders(console)
|
||||
with console.status("[yellow]Waiting for SQLite database lock to clear...[/yellow]", spinner="dots"):
|
||||
time.sleep(5.0)
|
||||
time.sleep(sleep_for)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
# Bootable hashing helpers (file content + mtime/size cache, mime detection).
|
||||
# MUST NOT import archivebox.config, archivebox.core, or Django — stdlib only.
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _cached_file_hash(filepath: str, size: int, mtime: float) -> str:
|
||||
"""Internal function to calculate file hash with cache key based on path, size and mtime."""
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(chunk)
|
||||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def hash_file(file_path: Path, pwd: Path | None = None) -> str:
|
||||
"""Calculate SHA256 hash of a file with caching based on path, size and mtime."""
|
||||
pwd = Path(pwd) if pwd else None
|
||||
file_path = Path(file_path)
|
||||
if not file_path.is_absolute():
|
||||
file_path = pwd / file_path if pwd else file_path.absolute()
|
||||
|
||||
abs_path = file_path.resolve()
|
||||
stat_info = abs_path.stat()
|
||||
|
||||
return _cached_file_hash(
|
||||
str(abs_path),
|
||||
stat_info.st_size,
|
||||
stat_info.st_mtime,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def get_dir_hashes(dir_path: Path, pwd: Path | None = None, filter_func: Callable | None = None, max_depth: int = -1) -> dict[str, str]:
|
||||
"""Calculate SHA256 hashes for all files and directories recursively."""
|
||||
pwd = Path(pwd) if pwd else None
|
||||
dir_path = Path(dir_path)
|
||||
if not dir_path.is_absolute():
|
||||
dir_path = pwd / dir_path if pwd else dir_path.absolute()
|
||||
|
||||
if not dir_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {dir_path}")
|
||||
if max_depth < -1:
|
||||
raise ValueError(f"max_depth must be >= -1, got {max_depth}")
|
||||
|
||||
# Get all files recursively
|
||||
all_files = get_dir_entries(
|
||||
dir_path,
|
||||
pwd=pwd,
|
||||
recursive=True,
|
||||
include_files=True,
|
||||
include_dirs=False,
|
||||
filter_func=filter_func,
|
||||
)
|
||||
|
||||
hashes: dict[str, str] = {}
|
||||
hashable_summary = []
|
||||
|
||||
# Calculate hashes for all files
|
||||
for subfile in all_files:
|
||||
subfile_path = dir_path / subfile
|
||||
sha256_hash = hash_file(subfile_path)
|
||||
hashes[subfile] = sha256_hash
|
||||
hashable_summary.append(f"{sha256_hash} ./{subfile}")
|
||||
|
||||
# Calculate hashes for all directories
|
||||
subdirs = get_dir_entries(
|
||||
dir_path,
|
||||
pwd=pwd,
|
||||
recursive=True,
|
||||
include_files=False,
|
||||
include_dirs=True,
|
||||
include_hidden=False,
|
||||
filter_func=filter_func,
|
||||
max_depth=max_depth,
|
||||
)
|
||||
|
||||
for subdir in subdirs:
|
||||
subdir_path = dir_path / subdir
|
||||
subdir_hashes = get_dir_hashes(
|
||||
subdir_path,
|
||||
filter_func=filter_func,
|
||||
max_depth=0,
|
||||
)
|
||||
hashes[subdir] = subdir_hashes["."]
|
||||
|
||||
# Filter results by max_depth
|
||||
if max_depth >= 0:
|
||||
hashes = {path: value for path, value in hashes.items() if len(Path(path).parts) <= max_depth + 1}
|
||||
|
||||
# Calculate root directory hash
|
||||
hashable_summary.sort()
|
||||
root_sha256 = hashlib.sha256("\n".join(hashable_summary).encode()).hexdigest()
|
||||
hashes["."] = root_sha256
|
||||
|
||||
return hashes
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_dir_entries(
|
||||
dir_path: Path,
|
||||
pwd: Path | None = None,
|
||||
recursive: bool = True,
|
||||
include_files: bool = True,
|
||||
include_dirs: bool = True,
|
||||
include_hidden: bool = False,
|
||||
filter_func: Callable | None = None,
|
||||
max_depth: int = -1,
|
||||
) -> tuple[str, ...]:
|
||||
"""Get filtered list of directory entries."""
|
||||
pwd = Path(pwd) if pwd else None
|
||||
dir_path = Path(dir_path)
|
||||
if not dir_path.is_absolute():
|
||||
dir_path = pwd / dir_path if pwd else dir_path.absolute()
|
||||
|
||||
results = []
|
||||
|
||||
def process_path(path: Path, depth: int):
|
||||
if not include_hidden and path.name.startswith("."):
|
||||
return False
|
||||
if max_depth >= 0 and depth > max_depth:
|
||||
return False
|
||||
if filter_func:
|
||||
info = {
|
||||
"abspath": str(path.absolute()),
|
||||
"relpath": str(path.relative_to(dir_path)),
|
||||
}
|
||||
if not filter_func(info):
|
||||
return False
|
||||
return True
|
||||
|
||||
for path in dir_path.rglob("*") if recursive else dir_path.glob("*"):
|
||||
current_depth = len(path.relative_to(dir_path).parts)
|
||||
|
||||
if path.is_file() and include_files and process_path(path, current_depth):
|
||||
results.append(str(path.relative_to(dir_path)))
|
||||
elif path.is_dir() and include_dirs and process_path(path, current_depth):
|
||||
results.append(str(path.relative_to(dir_path)))
|
||||
|
||||
if not recursive:
|
||||
break
|
||||
|
||||
return tuple(sorted(results)) # Make immutable for caching
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def get_dir_sizes(dir_path: Path, pwd: Path | None = None, **kwargs) -> dict[str, int]:
|
||||
"""Calculate sizes for all files and directories recursively."""
|
||||
sizes: dict[str, int] = {}
|
||||
hashes = get_dir_hashes(dir_path, pwd=pwd, **kwargs)
|
||||
dir_path = Path(dir_path)
|
||||
|
||||
for path_key in hashes:
|
||||
full_path = dir_path / path_key
|
||||
if full_path.is_file():
|
||||
sizes[path_key] = full_path.stat().st_size
|
||||
else:
|
||||
total = 0
|
||||
for file_path in full_path.rglob("*"):
|
||||
if file_path.is_file() and not file_path.name.startswith("."):
|
||||
total += file_path.stat().st_size
|
||||
sizes[path_key + "/"] = total
|
||||
|
||||
return sizes
|
||||
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def get_dir_info(dir_path: Path, pwd: Path | None = None, filter_func: Callable | None = None, max_depth: int = -1) -> dict:
|
||||
"""Get detailed information about directory contents including hashes and sizes."""
|
||||
pwd = Path(pwd) if pwd else None
|
||||
dir_path = Path(dir_path)
|
||||
if not dir_path.is_absolute():
|
||||
dir_path = pwd / dir_path if pwd else dir_path.absolute()
|
||||
|
||||
hashes = get_dir_hashes(dir_path, pwd=pwd, filter_func=filter_func, max_depth=max_depth)
|
||||
sizes = get_dir_sizes(str(dir_path), pwd=pwd, filter_func=filter_func, max_depth=max_depth)
|
||||
|
||||
num_total_subpaths = sum(1 for name in hashes if name != ".")
|
||||
details = {}
|
||||
|
||||
for filename, sha256_hash in sorted(hashes.items()):
|
||||
abs_path = (dir_path / filename).resolve()
|
||||
stat_info = abs_path.stat()
|
||||
num_subpaths = sum(1 for p in hashes if p.startswith(filename + "/"))
|
||||
is_dir = abs_path.is_dir()
|
||||
if is_dir:
|
||||
mime_type = "inode/directory"
|
||||
basename = abs_path.name
|
||||
extension = ""
|
||||
num_bytes = sizes[filename + "/"]
|
||||
if filename == ".":
|
||||
num_subpaths = num_total_subpaths
|
||||
else:
|
||||
filename += "/"
|
||||
num_subpaths = num_subpaths
|
||||
else: # is_file
|
||||
num_subpaths = None
|
||||
mime_type = mimetypes.guess_type(str(abs_path))[0]
|
||||
extension = abs_path.suffix
|
||||
basename = abs_path.name.rsplit(extension, 1)[0]
|
||||
num_bytes = sizes[filename]
|
||||
|
||||
details[filename] = {
|
||||
"basename": basename,
|
||||
"mime_type": mime_type,
|
||||
"extension": extension,
|
||||
"num_subpaths": num_subpaths,
|
||||
"num_bytes": num_bytes,
|
||||
"hash_sha256": sha256_hash,
|
||||
"created_at": datetime.fromtimestamp(stat_info.st_ctime).isoformat(),
|
||||
"modified_at": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
|
||||
}
|
||||
|
||||
if filter_func and not filter_func(details[filename]):
|
||||
del details[filename]
|
||||
|
||||
return details
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
|
||||
dir_info = get_dir_info(Path("."), max_depth=6)
|
||||
with open(".hashes.json", "w") as f:
|
||||
json.dump(dir_info, f, indent=4)
|
||||
print("Wrote .hashes.json")
|
||||
|
||||
# Example output:
|
||||
# {
|
||||
# ".": {
|
||||
# "basename": "misc",
|
||||
# "mime_type": "inode/directory",
|
||||
# "extension": "",
|
||||
# "num_subpaths": 25,
|
||||
# "num_bytes": 214677,
|
||||
# "hash_sha256": "addfacf88b2ff6b564846415fb7b21dcb7e63ee4e911bc0aec255ee354958530",
|
||||
# "created_at": "2024-12-04T00:08:38.537449",
|
||||
# "modified_at": "2024-12-04T00:08:38.537449"
|
||||
# },
|
||||
# "__init__.py": {
|
||||
# "basename": "__init__",
|
||||
# "mime_type": "text/x-python",
|
||||
# "extension": ".py",
|
||||
# "num_subpaths": null,
|
||||
# "num_bytes": 32,
|
||||
# "hash_sha256": "b0e5e7ff17db3b60535cf664282787767c336e3e203a43e21b6326c6fe457551",
|
||||
# "created_at": "2024-10-08T00:51:41.001359",
|
||||
# "modified_at": "2024-10-08T00:51:41.001359"
|
||||
# },
|
||||
# ...
|
||||
# }
|
||||
@@ -32,7 +32,6 @@ import sys
|
||||
import json
|
||||
from typing import Any, TextIO
|
||||
from collections.abc import Iterable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Type constants for JSONL records
|
||||
@@ -120,19 +119,6 @@ def read_stdin(stream: TextIO | None = None) -> Iterator[dict[str, Any]]:
|
||||
yield record
|
||||
|
||||
|
||||
def read_file(path: Path) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Read JSONL or plain URLs from a file.
|
||||
|
||||
Yields parsed records as dicts.
|
||||
"""
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
record = parse_line(line)
|
||||
if record:
|
||||
yield record
|
||||
|
||||
|
||||
def read_args_or_stdin(args: Iterable[str], stream: TextIO | None = None) -> Iterator[dict[str, Any]]:
|
||||
"""
|
||||
Read from CLI arguments if provided, otherwise from stdin.
|
||||
|
||||
+145
-48
@@ -14,10 +14,10 @@ import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlencode, urljoin
|
||||
|
||||
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.handlers.asgi import ASGIRequest
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotModified, StreamingHttpResponse
|
||||
@@ -29,10 +29,32 @@ from django.views import static
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.plugins.discovery import render_plugin_full_response
|
||||
|
||||
_HASHES_CACHE: dict[Path, tuple[float, dict[str, str]]] = {}
|
||||
FAVICON_CACHE_CONTROL = "public, max-age=31536000, s-maxage=31536000, immutable"
|
||||
IMG_SRC_ATTR_RE = re.compile(r'(<img\b[^>]*?\s(?:src|data-src)=["\'])([^"\']+)(["\'])', re.IGNORECASE)
|
||||
TRANSFORMED_HTML_PREVIEW_STYLE = """<style id="archivebox-static-html-preview-style">
|
||||
html {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 72rem);
|
||||
max-width: none;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
body > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
img:not([width]):not([height]) {
|
||||
max-width: min(100%, 12rem);
|
||||
max-height: 12rem;
|
||||
@@ -109,7 +131,7 @@ def _resolve_archive_path(document_root: str | Path, rel_path: str) -> tuple[Pat
|
||||
|
||||
def _cache_policy(config=None, **config_kwargs) -> str:
|
||||
config = config or get_config(resolve_plugins=False, **config_kwargs)
|
||||
return "private" if config.PERMISSIONS == "private" else "public"
|
||||
return "public" if config.PERMISSIONS == "public" else "private"
|
||||
|
||||
|
||||
def _format_direntry_timestamp(stat_result: os.stat_result) -> str:
|
||||
@@ -507,6 +529,26 @@ def _index_responses_paths_for_html_images(
|
||||
|
||||
best_matches: dict[str, str] = {}
|
||||
image_matches: set[str] = set()
|
||||
# Optional responses plugin file contract: index.jsonl records contain method,
|
||||
# url, status, and a path relative to responses/. No plugin code is imported.
|
||||
# Legacy captures without an index still use the filename lookup below.
|
||||
# The index retains complete URLs even when response filenames are shortened.
|
||||
index = responses_root / "index.jsonl"
|
||||
if index.is_file():
|
||||
with index.open(errors="replace") as records:
|
||||
for line in records:
|
||||
try:
|
||||
record = json.loads(line)
|
||||
encoded = _encoded_responses_image_url(record.get("url", ""), None)
|
||||
if encoded not in encoded_urls or record.get("method") != "GET" or record.get("status") != 200:
|
||||
continue
|
||||
candidate = (responses_root / record.get("path", "")).resolve()
|
||||
if not candidate.is_relative_to(responses_root.resolve()) or not candidate.is_file():
|
||||
continue
|
||||
best_matches[encoded] = posixpath.relpath(candidate, start=snapshot_root / posixpath.dirname(html_rel_path))
|
||||
image_matches.add(encoded)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
image_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"}
|
||||
for candidate in responses_root.rglob("*"):
|
||||
if not candidate.is_file():
|
||||
@@ -541,10 +583,30 @@ def _rewrite_html_image_sources_to_responses(
|
||||
if image_url not in encoded_urls_by_src:
|
||||
encoded_urls_by_src[image_url] = _encoded_responses_image_url(image_url, page_url)
|
||||
|
||||
# Optional dom plugin artifact: dom/output.html. Only image src and
|
||||
# data-canonical-src attributes are read; no scripts or plugin code execute.
|
||||
# Absence of this artifact simply disables proxy-to-original aliases.
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
class ImageAliases(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
original = attrs.get("data-canonical-src")
|
||||
proxy = attrs.get("src")
|
||||
if tag == "img" and original and proxy:
|
||||
original_key = _encoded_responses_image_url(original, page_url)
|
||||
proxy_key = _encoded_responses_image_url(proxy, page_url)
|
||||
if original_key and proxy_key:
|
||||
aliases[original_key] = proxy_key
|
||||
|
||||
dom = snapshot_root / "dom" / "output.html"
|
||||
if dom.is_file() and dom.stat().st_size <= 10 * 1024 * 1024:
|
||||
ImageAliases().feed(dom.read_text(errors="replace"))
|
||||
wanted = {encoded for encoded in encoded_urls_by_src.values() if encoded}
|
||||
response_paths = _index_responses_paths_for_html_images(
|
||||
snapshot_root,
|
||||
html_rel_path,
|
||||
{encoded_url for encoded_url in encoded_urls_by_src.values() if encoded_url},
|
||||
wanted | {aliases[key] for key in wanted if key in aliases},
|
||||
)
|
||||
rewrites = 0
|
||||
|
||||
@@ -552,7 +614,7 @@ def _rewrite_html_image_sources_to_responses(
|
||||
nonlocal rewrites
|
||||
image_url = html.unescape(match.group(2))
|
||||
encoded_url = encoded_urls_by_src.get(image_url)
|
||||
local_path = response_paths.get(encoded_url or "")
|
||||
local_path = response_paths.get(encoded_url or "") or response_paths.get(aliases.get(encoded_url or "", ""))
|
||||
if not local_path:
|
||||
return match.group(0)
|
||||
rewrites += 1
|
||||
@@ -569,12 +631,17 @@ def _rewrite_html_image_sources_for_request(
|
||||
) -> tuple[str, int]:
|
||||
if not document_root:
|
||||
return html_text, 0
|
||||
return _rewrite_html_image_sources_to_responses(
|
||||
html_text,
|
||||
document_root,
|
||||
html_rel_path,
|
||||
request.__dict__.get("archivebox_snapshot_url"),
|
||||
)
|
||||
try:
|
||||
return _rewrite_html_image_sources_to_responses(
|
||||
html_text,
|
||||
document_root,
|
||||
html_rel_path,
|
||||
request.__dict__.get("archivebox_snapshot_url"),
|
||||
)
|
||||
except (OSError, ValueError, TypeError, RecursionError):
|
||||
# Saved plugin artifacts are optional and may be incomplete, malformed,
|
||||
# or removed concurrently. Image enrichment must not break file replay.
|
||||
return html_text, 0
|
||||
|
||||
|
||||
def _apply_transformed_html_preview_style(html_text: str) -> str:
|
||||
@@ -585,16 +652,22 @@ def _apply_transformed_html_preview_style(html_text: str) -> str:
|
||||
return f"{TRANSFORMED_HTML_PREVIEW_STYLE}\n{html_text}"
|
||||
|
||||
|
||||
def _set_transformed_response_headers(response, fullpath: Path, statobj: os.stat_result, encoding: str | None, config) -> None:
|
||||
def _set_transformed_response_headers(
|
||||
response,
|
||||
fullpath: Path,
|
||||
statobj: os.stat_result,
|
||||
encoding: str | None,
|
||||
cache_policy: str,
|
||||
) -> None:
|
||||
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
|
||||
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
|
||||
if encoding:
|
||||
response.headers["Content-Encoding"] = encoding
|
||||
|
||||
|
||||
def _render_markdown_fallback(text: str) -> str:
|
||||
if _markdown is not None and not HTML_TAG_RE.search(text):
|
||||
if _markdown is not None:
|
||||
try:
|
||||
return _markdown(
|
||||
text,
|
||||
@@ -838,19 +911,22 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
config = request.__dict__.get("archivebox_config")
|
||||
if config is None:
|
||||
config = get_config(resolve_plugins=False)
|
||||
cache_policy = getattr(request, "archivebox_cache_policy", None) or _cache_policy(config=config)
|
||||
fullpath, path = _resolve_archive_path(document_root, path)
|
||||
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
|
||||
if request.GET.get("download") == "zip" and show_indexes:
|
||||
return _build_directory_zip_response(
|
||||
response = _build_directory_zip_response(
|
||||
fullpath,
|
||||
path,
|
||||
is_archive_replay=is_archive_replay,
|
||||
use_async_stream=_is_asgi_request(request),
|
||||
config=config,
|
||||
)
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
return response
|
||||
if show_indexes:
|
||||
response = _render_directory_index(request, path, fullpath)
|
||||
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
response.headers["Last-Modified"] = http_date(fullpath.stat().st_mtime)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
@@ -864,6 +940,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
raise Http404(_("“%(path)s” does not exist") % {"path": fullpath})
|
||||
|
||||
statobj = fullpath.stat()
|
||||
# Captured site icons are intentionally shareable even for private snapshots.
|
||||
# Only raw icon responses qualify, not generated previews or directory pages.
|
||||
favicon_cache_control = (
|
||||
FAVICON_CACHE_CONTROL
|
||||
if fullpath.stem.lower() == "favicon"
|
||||
and fullpath.suffix.lower() in {".ico", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"}
|
||||
and not request.GET.get("preview")
|
||||
else None
|
||||
)
|
||||
document_root = Path(document_root) if document_root else None
|
||||
rel_path = path
|
||||
etag = None
|
||||
@@ -879,7 +964,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if etag in inm_list or etag.strip('"') in [i.strip('"') for i in inm_list]:
|
||||
not_modified = HttpResponseNotModified()
|
||||
not_modified.headers["ETag"] = etag
|
||||
not_modified.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
|
||||
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=31536000, immutable"
|
||||
not_modified.headers["Last-Modified"] = http_date(statobj.st_mtime)
|
||||
return _apply_archive_replay_headers(
|
||||
not_modified,
|
||||
@@ -890,6 +975,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
)
|
||||
|
||||
content_type, encoding = mimetypes.guess_type(str(fullpath))
|
||||
# Captured favicons retain the conventional .ico filename even when a site
|
||||
# returns SVG. Browsers cannot decode SVG served as image/x-icon.
|
||||
if fullpath.suffix.lower() == ".ico":
|
||||
with fullpath.open("rb") as icon_file:
|
||||
icon_head = icon_file.read(4096)
|
||||
if re.search(rb"<svg(?:\s|>)", icon_head):
|
||||
content_type = "image/svg+xml"
|
||||
preserve_plain_text = fullpath.suffix.lower() in {".log", ".sh", ".jsonl"}
|
||||
if preserve_plain_text:
|
||||
content_type = "text/plain"
|
||||
content_type = content_type or "application/octet-stream"
|
||||
# Add charset for text-like types (best guess), but don't override the type.
|
||||
is_text_like = content_type.startswith("text/") or content_type in {
|
||||
@@ -904,21 +999,21 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
preview_as_text_html = (
|
||||
bool(request.GET.get("preview"))
|
||||
and is_text_like
|
||||
and not content_type.startswith("text/html")
|
||||
and (bool(request.GET.get("raw")) or not content_type.startswith("text/html"))
|
||||
and not content_type.startswith("image/svg+xml")
|
||||
)
|
||||
preview_as_image_html = (
|
||||
bool(request.GET.get("preview")) and content_type.startswith("image/") and not content_type.startswith("image/svg+xml")
|
||||
)
|
||||
preview_as_archivewebpage_html = bool(request.GET.get("preview")) and archivewebpage_replay.is_replay_target(fullpath.name)
|
||||
|
||||
# Respect the If-Modified-Since header for non-markdown responses.
|
||||
if not content_type.startswith(("text/plain", "text/html")) and not static.was_modified_since(
|
||||
request.META.get("HTTP_IF_MODIFIED_SINCE"),
|
||||
statobj.st_mtime,
|
||||
):
|
||||
not_modified = HttpResponseNotModified()
|
||||
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
return _apply_archive_replay_headers(
|
||||
HttpResponseNotModified(),
|
||||
not_modified,
|
||||
fullpath=fullpath,
|
||||
content_type=content_type,
|
||||
is_archive_replay=is_archive_replay,
|
||||
@@ -933,7 +1028,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
decoded = fullpath.read_text(encoding="utf-8", errors="replace")
|
||||
wrapped = _render_text_preview_document(decoded, fullpath.name)
|
||||
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
@@ -942,7 +1037,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
config=config,
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, ValueError):
|
||||
preview_as_text_html = False
|
||||
pass
|
||||
|
||||
if preview_as_image_html:
|
||||
try:
|
||||
@@ -953,7 +1048,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
raw_image_url = f"{raw_image_url}?{urlencode(list(preview_query.lists()), doseq=True)}"
|
||||
wrapped = _render_image_preview_document(raw_image_url, fullpath.name)
|
||||
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
@@ -962,16 +1057,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
config=config,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
preview_as_image_html = False
|
||||
pass
|
||||
|
||||
if preview_as_archivewebpage_html:
|
||||
if request.GET.get("preview"):
|
||||
try:
|
||||
raw_query = request.GET.copy()
|
||||
raw_query.pop("preview", None)
|
||||
raw_output_path = request.path
|
||||
if raw_query:
|
||||
raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}"
|
||||
body, preview_content_type, headers = archivewebpage_replay.render_preview_response(
|
||||
plugin_replay = render_plugin_full_response(
|
||||
fullpath.name,
|
||||
raw_output_path,
|
||||
wacz_path=fullpath,
|
||||
@@ -979,28 +1074,28 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
last_modified=http_date(statobj.st_mtime),
|
||||
etag=etag or "",
|
||||
cache_control=(
|
||||
f"{_cache_policy(config=config)}, max-age=31536000, immutable"
|
||||
if etag
|
||||
else f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
|
||||
f"{cache_policy}, max-age=31536000, immutable" if etag else f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
),
|
||||
content_encoding=encoding or "",
|
||||
)
|
||||
response = HttpResponse(body, content_type=preview_content_type)
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = value
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
content_type=preview_content_type,
|
||||
is_archive_replay=is_archive_replay,
|
||||
config=config,
|
||||
)
|
||||
if plugin_replay is not None:
|
||||
body, replay_content_type, headers = plugin_replay
|
||||
response = HttpResponse(body, content_type=replay_content_type)
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = value
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
content_type=replay_content_type,
|
||||
is_archive_replay=is_archive_replay,
|
||||
config=config,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
preview_as_archivewebpage_html = False
|
||||
pass
|
||||
|
||||
# Heuristic fix: some archived HTML outputs are stored with HTML-escaped markup
|
||||
# or markdown sources. If so, render sensibly.
|
||||
if content_type.startswith(("text/plain", "text/html")):
|
||||
if not request.GET.get("raw") and not preserve_plain_text and content_type.startswith(("text/plain", "text/html", "text/markdown")):
|
||||
try:
|
||||
max_unescape_size = 10 * 1024 * 1024 # 10MB cap to avoid heavy memory use
|
||||
if statobj.st_size <= max_unescape_size:
|
||||
@@ -1014,12 +1109,12 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if content_type.startswith("text/html") and document_root:
|
||||
rewritten_html, rewritten_count = _rewrite_html_image_sources_for_request(request, decoded, document_root, rel_path)
|
||||
markdown_candidate = _extract_markdown_candidate(decoded)
|
||||
if _looks_like_markdown(markdown_candidate):
|
||||
if content_type.startswith("text/markdown") or _looks_like_markdown(markdown_candidate):
|
||||
wrapped = _render_markdown_document(markdown_candidate)
|
||||
wrapped, _rewrite_count = _rewrite_html_image_sources_for_request(request, wrapped, document_root, rel_path)
|
||||
wrapped = _apply_transformed_html_preview_style(wrapped)
|
||||
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
@@ -1030,7 +1125,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if rewritten_count:
|
||||
rewritten_html = _apply_transformed_html_preview_style(rewritten_html)
|
||||
response = HttpResponse(rewritten_html, content_type=content_type)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
@@ -1041,7 +1136,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if escaped_count and escaped_count > tag_count * 2:
|
||||
decoded = _apply_transformed_html_preview_style(decoded)
|
||||
response = HttpResponse(decoded, content_type=content_type)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
|
||||
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
@@ -1061,13 +1156,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
|
||||
if etag:
|
||||
response.headers["ETag"] = etag
|
||||
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=31536000, immutable"
|
||||
else:
|
||||
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
if is_text_like:
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
|
||||
if content_type.startswith("image/"):
|
||||
response.headers["Cache-Control"] = "public, max-age=604800, immutable"
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=604800, immutable"
|
||||
if favicon_cache_control:
|
||||
response.headers["Cache-Control"] = favicon_cache_control
|
||||
|
||||
# handle byte-range requests by serving chunk of file
|
||||
if stat.S_ISREG(statobj.st_mode):
|
||||
|
||||
@@ -2,94 +2,11 @@ from typing import Any, cast
|
||||
from collections.abc import Callable
|
||||
|
||||
import json
|
||||
import ast
|
||||
import inspect
|
||||
import toml
|
||||
import re
|
||||
import configparser
|
||||
|
||||
from pathlib import Path, PosixPath
|
||||
|
||||
from pydantic.json_schema import GenerateJsonSchema
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
JSONValue = str | bool | int | None | list["JSONValue"]
|
||||
|
||||
TOML_HEADER = "# Converted from INI to TOML format: https://toml.io/en/\n\n"
|
||||
|
||||
|
||||
def load_ini_value(val: str) -> JSONValue:
|
||||
"""Convert lax INI values into strict TOML-compliant (JSON) values"""
|
||||
if val.lower() in ("true", "yes", "1"):
|
||||
return True
|
||||
if val.lower() in ("false", "no", "0"):
|
||||
return False
|
||||
if val.isdigit():
|
||||
return int(val)
|
||||
|
||||
try:
|
||||
return ast.literal_eval(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return val
|
||||
|
||||
|
||||
def convert(ini_str: str) -> str:
|
||||
"""Convert a string of INI config into its TOML equivalent (warning: strips comments)"""
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
setattr(config, "optionxform", str) # capitalize key names
|
||||
config.read_string(ini_str)
|
||||
|
||||
# Initialize an empty dictionary to store the TOML representation
|
||||
toml_dict = {}
|
||||
|
||||
# Iterate over each section in the INI configuration
|
||||
for section in config.sections():
|
||||
toml_dict[section] = {}
|
||||
|
||||
# Iterate over each key-value pair in the section
|
||||
for key, value in config.items(section):
|
||||
parsed_value = load_ini_value(value)
|
||||
|
||||
# Convert the parsed value to its TOML-compatible JSON representation
|
||||
toml_dict[section.upper()][key.upper()] = json.dumps(parsed_value)
|
||||
|
||||
# Build the TOML string
|
||||
toml_str = TOML_HEADER
|
||||
for section, items in toml_dict.items():
|
||||
toml_str += f"[{section}]\n"
|
||||
for key, value in items.items():
|
||||
toml_str += f"{key} = {value}\n"
|
||||
toml_str += "\n"
|
||||
|
||||
return toml_str.strip()
|
||||
|
||||
|
||||
class JSONSchemaWithLambdas(GenerateJsonSchema):
|
||||
"""
|
||||
Encode lambda functions in default values properly.
|
||||
Usage:
|
||||
>>> json.dumps(value, encoder=JSONSchemaWithLambdas())
|
||||
"""
|
||||
|
||||
def encode_default(self, dft: Any) -> Any:
|
||||
config = self._config
|
||||
if isinstance(dft, Callable):
|
||||
return "{{lambda " + inspect.getsource(dft).split("=lambda ")[-1].strip()[:-1] + "}}"
|
||||
return to_jsonable_python(
|
||||
dft,
|
||||
timedelta_mode=config.ser_json_timedelta,
|
||||
bytes_mode=config.ser_json_bytes,
|
||||
serialize_unknown=True,
|
||||
)
|
||||
|
||||
|
||||
def better_toml_dump_str(val: Any) -> str:
|
||||
try:
|
||||
|
||||
+10
-4
@@ -192,7 +192,10 @@ def validate_url_length(url: str) -> str:
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
url = validate_url_length((url or "").strip())
|
||||
url = (url or "").strip()
|
||||
if "\n" in url or "\r" in url:
|
||||
raise ValueError("URL must be a single line.")
|
||||
url = validate_url_length(url)
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https") or not parsed.hostname:
|
||||
raise ValueError("URL must start with http:// or https:// and include a hostname.")
|
||||
@@ -385,9 +388,12 @@ def parse_date(date: Any) -> datetime | None:
|
||||
if date.tzinfo is None:
|
||||
return date.replace(tzinfo=timezone.utc)
|
||||
|
||||
offset = date.utcoffset()
|
||||
assert offset == datetime.now(timezone.utc).utcoffset(), "Refusing to load a non-UTC date!"
|
||||
return date
|
||||
# Legacy SQLite ArchiveResult rows can retain explicit timezone offsets
|
||||
# through migration. Django loads those as aware datetimes, whereas JSON
|
||||
# imports reach the string branch below. Normalize both representations
|
||||
# consistently so opening a migrated snapshot does not raise (#1889).
|
||||
# Convert the instant; replacing tzinfo here would shift the capture time.
|
||||
return date.astimezone(timezone.utc)
|
||||
|
||||
if isinstance(date, (float, int)):
|
||||
date = str(date)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpencodeConfig(AppConfig):
|
||||
name = "archivebox.opencode"
|
||||
label = "opencode_plugin"
|
||||
@@ -1,211 +0,0 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Agent{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› Agent
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extrastyle %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
body.opencode-agent #content {
|
||||
padding: 0;
|
||||
}
|
||||
body.opencode-agent #content-main {
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 560px;
|
||||
}
|
||||
.opencode-agent-error {
|
||||
margin: 24px;
|
||||
color: #0f172a;
|
||||
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.opencode-agent-error code {
|
||||
color: #0369a1;
|
||||
}
|
||||
.opencode-agent-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.opencode-agent-shell {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
.opencode-agent-welcome {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
color: #111827;
|
||||
}
|
||||
.opencode-agent-welcome[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.opencode-agent-welcome-panel {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(720px, calc(100vh - 190px));
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
|
||||
padding: 24px;
|
||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.opencode-agent-welcome-panel h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.opencode-agent-welcome-panel p {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.opencode-agent-welcome-panel ul {
|
||||
margin: 0 0 16px 20px;
|
||||
padding: 0;
|
||||
}
|
||||
.opencode-agent-welcome-panel li {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.opencode-agent-welcome-warning {
|
||||
margin: 16px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, #f59e0b 12%, Canvas);
|
||||
}
|
||||
.opencode-agent-welcome-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.opencode-agent-welcome button {
|
||||
cursor: pointer;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 6px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
padding: 8px 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.opencode-agent-welcome {
|
||||
padding: 12px;
|
||||
}
|
||||
.opencode-agent-welcome-panel {
|
||||
max-height: calc(100vh - 170px);
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} opencode-agent{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
{% if error %}
|
||||
<div class="opencode-agent-error">
|
||||
{{ error }}
|
||||
{% if command %}<br><br><code>{{ command }}</code>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<script>
|
||||
(() => {
|
||||
const workdir = "{{ workdir|escapejs }}";
|
||||
const recentSessionId = "{{ recent_session_id|escapejs }}";
|
||||
const merge = (left, right) => {
|
||||
if (!left || typeof left !== "object" || Array.isArray(left)) return right;
|
||||
const out = {...left};
|
||||
for (const [key, value] of Object.entries(right)) {
|
||||
out[key] = value && typeof value === "object" && !Array.isArray(value) ? merge(out[key], value) : value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const seed = (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(merge(JSON.parse(localStorage.getItem(key) || "{}"), value)));
|
||||
} catch {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
const setJSON = (key, value) => localStorage.setItem(key, JSON.stringify(value));
|
||||
const page = {
|
||||
activeProject: workdir,
|
||||
activeWorkspace: workdir,
|
||||
gettingStartedDismissed: true,
|
||||
workspaceExpanded: {[workdir]: true},
|
||||
};
|
||||
if (recentSessionId) {
|
||||
page.lastProjectSession = {[workdir]: {directory: workdir, id: recentSessionId, at: Date.now()}};
|
||||
}
|
||||
seed("opencode.global.dat:layout.page", page);
|
||||
seed("opencode.global.dat:layout", {
|
||||
sidebar: {opened: true, width: 280},
|
||||
fileTree: {opened: true, width: 280, tab: "all"},
|
||||
});
|
||||
const serverKey = `${location.origin}/admin/agent/opencode`;
|
||||
setJSON("opencode.global.dat:server", {
|
||||
list: [],
|
||||
projects: {
|
||||
[serverKey]: [{worktree: workdir, expanded: true}],
|
||||
},
|
||||
lastProject: {
|
||||
[serverKey]: workdir,
|
||||
},
|
||||
});
|
||||
localStorage.setItem("opencode.settings.dat:defaultServerUrl", serverKey);
|
||||
})();
|
||||
</script>
|
||||
<div class="opencode-agent-shell">
|
||||
<iframe src="{{ proxy_url }}" title="OpenCode Agent" class="opencode-agent-frame"></iframe>
|
||||
<div class="opencode-agent-welcome" id="opencode-agent-welcome" hidden>
|
||||
<div class="opencode-agent-welcome-panel" role="dialog" aria-modal="true" aria-labelledby="opencode-agent-welcome-title">
|
||||
<h1 id="opencode-agent-welcome-title">ArchiveBox AI Agent</h1>
|
||||
<p>This agent can work directly with your ArchiveBox collection. It can inspect the crawl database, create and monitor crawls, run maintenance operations, answer research questions, query archived data, and help with complex collection workflows.</p>
|
||||
<p>Example prompts:</p>
|
||||
<ul>
|
||||
<li>Archive this list of URLs with depth 0, then report which ones failed and why.</li>
|
||||
<li>Find snapshots from the last month that failed PDF or screenshot extraction and retry them.</li>
|
||||
<li>Create a constrained crawl for this site, avoid login/privacy/sitemap URLs, and watch the logs as it runs.</li>
|
||||
<li>Summarize what my collection contains about this topic and link to the best saved pages.</li>
|
||||
</ul>
|
||||
<div class="opencode-agent-welcome-warning">
|
||||
The agent has unrestricted access to this collection and can make destructive edits. Review requests carefully. Permission settings can be changed from the OpenCode gear icon in the lower left.
|
||||
</div>
|
||||
<div class="opencode-agent-welcome-actions">
|
||||
<button type="button" id="opencode-agent-welcome-dismiss">Start using Agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const key = "archivebox.opencode.agentWelcomeDismissed.v1";
|
||||
const welcome = document.getElementById("opencode-agent-welcome");
|
||||
const dismiss = document.getElementById("opencode-agent-welcome-dismiss");
|
||||
if (!welcome || !dismiss) return;
|
||||
if (localStorage.getItem(key) !== "1") {
|
||||
welcome.hidden = false;
|
||||
}
|
||||
dismiss.addEventListener("click", () => {
|
||||
localStorage.setItem(key, "1");
|
||||
welcome.hidden = true;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
+118
-694
@@ -1,716 +1,140 @@
|
||||
from __future__ import annotations
|
||||
"""Django adapter for the optional plugin; never import its runtime at startup."""
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from abx_plugins.plugins import opencode as opencode_plugin
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.routes_util import build_admin_url, get_api_base_url, get_base_url
|
||||
from django.http import (
|
||||
Http404,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseForbidden,
|
||||
StreamingHttpResponse,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.handlers.asgi import ASGIRequest
|
||||
from django.db import close_old_connections
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden, StreamingHttpResponse
|
||||
from django.template import engines
|
||||
from django.urls import Resolver404, resolve
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_request_config
|
||||
from archivebox.core.middleware import ReverseProxyAuthMiddleware
|
||||
from archivebox.core.routes_util import build_admin_url, get_admin_host, get_api_base_url, get_base_url, host_matches
|
||||
from archivebox.plugins.discovery import get_plugin_template
|
||||
|
||||
_PROCESS: subprocess.Popen | None = None
|
||||
_PROCESS_LOCK = threading.Lock()
|
||||
_PROXY_PREFIX = "/admin/agent/opencode"
|
||||
_PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/")
|
||||
_PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/")
|
||||
_CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json")
|
||||
|
||||
_TEXT_CONTENT_TYPES = (
|
||||
"text/",
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"application/x-javascript",
|
||||
)
|
||||
_HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
_ARCHIVEBOX_SKILL = """---
|
||||
name: archivebox
|
||||
description: Use ArchiveBox's CLI and local REST API from an ArchiveBox collection.
|
||||
---
|
||||
|
||||
You are running inside an ArchiveBox collection directory.
|
||||
|
||||
- ArchiveBox collection directory: {archivebox_data_dir}
|
||||
- ArchiveBox BASE_URL: {archivebox_base_url}
|
||||
- ArchiveBox Admin URL: {archivebox_admin_url}
|
||||
- ArchiveBox REST API URL: {archivebox_api_url}
|
||||
- Prefer the `archivebox` CLI for authenticated changes, e.g. `archivebox add`, `archivebox schedule`, `archivebox update`, and `archivebox shell`.
|
||||
- Run ArchiveBox CLI commands from the ArchiveBox collection directory above.
|
||||
- Get command help with `archivebox list --help`, `archivebox add --help`, `archivebox schedule --help`, etc. Do not use `archivebox help <command>`.
|
||||
- Use `--depth=0` by default. Only use recursive crawling when the user explicitly asks for it; use `--depth=1` when you need pages one hop out.
|
||||
- Before any recursive crawl, constrain scope with ArchiveBox config such as `CRAWL_MAX_URLS`, `CRAWL_MAX_SIZE`, `SNAPSHOT_MAX_*`, `URL_ALLOWLIST`, `URL_DENYLIST`, and related limits.
|
||||
- Respect the configured `archivebox config --get ONLY_NEW` behavior unless the user explicitly says otherwise. Remind users that expected crawl URLs can be skipped when the collection already contains snapshots with the same URL.
|
||||
- Always audit newly discovered crawl URLs before letting a crawl run broadly. Treat junk URLs such as privacy policies, legal pages, tag archives, sitemap files, feeds, login/logout URLs, and other low-value boilerplate as unwanted unless the user explicitly asked to archive them.
|
||||
- Always watch crawl output and logs as the crawl progresses, and correct errors early instead of waiting until the crawl finishes.
|
||||
- If a crawl contains bad URLs, pause it, edit the crawl's `urls` field to remove them, delete any unneeded snapshots already created under that crawl, then resume the crawl.
|
||||
- Use `archivebox shell -c '...'` or `archivebox shell <<'PY' ... PY` for Django ORM work. Shell Plus prints an import banner first; keep stderr visible while debugging.
|
||||
- Use full ArchiveBox module paths in shell code: `from archivebox.crawls.models import Crawl, CrawlSchedule` and `from archivebox.core.models import Snapshot, ArchiveResult`.
|
||||
- If a model/field/relation is unclear, inspect `_meta.fields` before guessing, e.g. `archivebox shell -c "from archivebox.crawls.models import Crawl; print([f.name for f in Crawl._meta.fields])"`.
|
||||
- Use `archivebox config --get BASE_URL` only to verify the configured base URL; prefer the seeded URLs above for API/admin requests.
|
||||
- Use `$ARCHIVEBOX_API_URL` for REST API inspection when helpful. Do not assume admin session cookies authenticate API subdomain requests; prefer CLI/shell for authenticated mutations unless the admin provides or asks you to create an API token.
|
||||
- Discover REST endpoints from `${{ARCHIVEBOX_API_URL}}v1/openapi.json`; crawl endpoints live under `/api/v1/crawls/`, snapshots under `/api/v1/core/`.
|
||||
- Do not bypass ArchiveBox auth, expose API keys, or modify config unless the admin explicitly asks.
|
||||
- After creating crawls or snapshots, report the crawl/snapshot IDs and the exact command or API request used.
|
||||
"""
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _stop_owned_process(process: subprocess.Popen | None = None) -> None:
|
||||
global _PROCESS
|
||||
owned_process = process or _PROCESS
|
||||
if owned_process is None:
|
||||
return
|
||||
if owned_process.poll() is None:
|
||||
owned_process.terminate()
|
||||
try:
|
||||
owned_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
owned_process.kill()
|
||||
owned_process.wait()
|
||||
if _PROCESS is owned_process:
|
||||
_PROCESS = None
|
||||
def _runtime_settings(request, config):
|
||||
from abx_plugins.plugins.opencode import runtime
|
||||
|
||||
|
||||
atexit.register(_stop_owned_process)
|
||||
|
||||
|
||||
def _machine_config() -> dict[str, Any]:
|
||||
resolved = get_config()
|
||||
return dict(resolved.model_dump(mode="json"))
|
||||
|
||||
|
||||
def _archivebox_data_dir_default() -> Path:
|
||||
return Path(CONSTANTS.DATA_DIR)
|
||||
|
||||
|
||||
def _archivebox_route_urls(request: HttpRequest, route_config) -> tuple[str, str, str]:
|
||||
base_url = get_base_url(
|
||||
request=request,
|
||||
config=route_config,
|
||||
).rstrip("/")
|
||||
admin_url = build_admin_url(
|
||||
"/admin/",
|
||||
request=request,
|
||||
config=route_config,
|
||||
).rstrip("/")
|
||||
api_url = f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/"
|
||||
return base_url, admin_url, api_url
|
||||
|
||||
|
||||
def _config_value(config: dict, key: str, default):
|
||||
value = config.get(key, default)
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _opencode_enabled(config: dict) -> bool:
|
||||
value = config.get("OPENCODE_ENABLED", False)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _require_enabled(config: dict) -> None:
|
||||
if not _opencode_enabled(config):
|
||||
raise Http404
|
||||
|
||||
|
||||
def _require_superuser(request: HttpRequest):
|
||||
user = getattr(request, "user", None)
|
||||
if user is None:
|
||||
return redirect(f"/admin/login/?next={request.get_full_path()}")
|
||||
if (
|
||||
bool(getattr(user, "is_authenticated", False))
|
||||
and bool(getattr(user, "is_active", False))
|
||||
and bool(getattr(user, "is_superuser", False))
|
||||
):
|
||||
return None
|
||||
if bool(getattr(user, "is_authenticated", False)):
|
||||
return HttpResponseForbidden(
|
||||
b"ArchiveBox agent access requires a superuser account.",
|
||||
)
|
||||
return redirect(f"/admin/login/?next={request.get_full_path()}")
|
||||
|
||||
|
||||
def _origin_allowed(request: HttpRequest, path: str | None = None) -> bool:
|
||||
if request.method in {"GET", "HEAD", "OPTIONS", "TRACE"}:
|
||||
return True
|
||||
|
||||
expected_host = request.get_host()
|
||||
pty_connect = bool(
|
||||
path and path.startswith("pty/") and path.endswith("/connect-token"),
|
||||
)
|
||||
if pty_connect:
|
||||
return True
|
||||
|
||||
origin = _request_header(request, "Origin")
|
||||
if origin:
|
||||
return _same_host(origin, expected_host)
|
||||
|
||||
referer = _request_header(request, "Referer")
|
||||
if referer:
|
||||
return _same_host(referer, expected_host)
|
||||
|
||||
fetch_site = _request_header(request, "Sec-Fetch-Site")
|
||||
if fetch_site:
|
||||
return fetch_site in {"same-origin", "same-site", "none"}
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _same_host(value: str, expected_host: str) -> bool:
|
||||
parsed = urlsplit(value)
|
||||
return parsed.scheme in {"http", "https"} and parsed.netloc == expected_host
|
||||
|
||||
|
||||
def _settings(config: dict) -> dict:
|
||||
host = str(_config_value(config, "OPENCODE_HOST", "127.0.0.1"))
|
||||
port = int(_config_value(config, "OPENCODE_PORT", 4096))
|
||||
default_data_dir = _archivebox_data_dir_default()
|
||||
workdir = Path(
|
||||
str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)),
|
||||
).expanduser()
|
||||
opencode_dir = Path(
|
||||
str(_config_value(config, "OPENCODE_STATE_DIR", workdir / "opencode")),
|
||||
).expanduser()
|
||||
binary = str(_config_value(config, "OPENCODE_BINARY", "opencode"))
|
||||
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 30))
|
||||
return {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"origin": f"http://{host}:{port}",
|
||||
"workdir": workdir,
|
||||
"opencode_dir": opencode_dir,
|
||||
"config_home": opencode_dir / "config",
|
||||
"data_home": opencode_dir / "data",
|
||||
"state_home": opencode_dir / "state",
|
||||
"cache_home": opencode_dir / "cache",
|
||||
"home": opencode_dir / "home",
|
||||
"binary": binary,
|
||||
"config": config,
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_binary(binary: str, config: dict) -> tuple[Any, Any, dict[str, str]]:
|
||||
try:
|
||||
from abxpkg import BinProvider
|
||||
from abx_plugins.plugins.base.utils import load_required_binary_from_config
|
||||
|
||||
binary_environ = os.environ.copy()
|
||||
lib_dir = config.get("ABXPKG_LIB_DIR")
|
||||
if lib_dir:
|
||||
binary_environ["ABXPKG_LIB_DIR"] = str(lib_dir)
|
||||
loaded_dependencies = [
|
||||
load_required_binary_from_config(
|
||||
required_binary,
|
||||
_CONFIG_PATH,
|
||||
global_config=config,
|
||||
environ=binary_environ,
|
||||
install=False,
|
||||
)
|
||||
for required_binary in (
|
||||
str(config.get("NODE_BINARY") or "node"),
|
||||
str(config.get("GIT_BINARY") or "git"),
|
||||
binary,
|
||||
)
|
||||
]
|
||||
except Exception as err:
|
||||
raise RuntimeError(
|
||||
f"OpenCode dependency is not installed from required_binaries: {err}",
|
||||
) from err
|
||||
|
||||
if any(not loaded.loaded_abspath for loaded in loaded_dependencies):
|
||||
raise RuntimeError(
|
||||
"OpenCode dependency is not installed from required_binaries.",
|
||||
)
|
||||
|
||||
providers = [loaded.loaded_binprovider for loaded in loaded_dependencies if loaded.loaded_binprovider is not None]
|
||||
binary_env = BinProvider.build_exec_env(
|
||||
providers=providers,
|
||||
base_env=binary_environ,
|
||||
)
|
||||
return (
|
||||
loaded_dependencies[-1],
|
||||
loaded_dependencies[1],
|
||||
binary_env,
|
||||
)
|
||||
|
||||
|
||||
def _project_route(workdir: Path, session_id: str = "") -> str:
|
||||
encoded = base64.b64encode(str(workdir.resolve()).encode()).decode()
|
||||
encoded = encoded.replace("+", "-").replace("/", "_").rstrip("=")
|
||||
route = f"{_PROXY_PREFIX}/{encoded}/session"
|
||||
return f"{route}/{session_id}" if session_id else route
|
||||
|
||||
|
||||
def _ensure_project_files(settings: dict) -> None:
|
||||
workdir = settings["workdir"].resolve()
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
git_marker = workdir / ".git" / "not-a-git"
|
||||
if git_marker.exists():
|
||||
# Current OpenCode hangs on the legacy fake marker, so remove only
|
||||
# that invalid shape before initializing the real worktree.
|
||||
shutil.rmtree(git_marker.parent)
|
||||
|
||||
editable_skill_path = settings["opencode_dir"] / "SKILL.md"
|
||||
editable_skill_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not editable_skill_path.exists():
|
||||
editable_skill_path.write_text(
|
||||
_ARCHIVEBOX_SKILL.format(
|
||||
archivebox_data_dir=workdir,
|
||||
archivebox_base_url=settings.get("archivebox_base_url", ""),
|
||||
archivebox_admin_url=settings.get("archivebox_admin_url", ""),
|
||||
archivebox_api_url=settings.get("archivebox_api_url", ""),
|
||||
),
|
||||
)
|
||||
|
||||
opencode_skill_path = settings["config_home"] / "opencode" / "skills" / "archivebox" / "SKILL.md"
|
||||
opencode_skill_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if opencode_skill_path.resolve() != editable_skill_path.resolve():
|
||||
if opencode_skill_path.exists() or opencode_skill_path.is_symlink():
|
||||
opencode_skill_path.unlink()
|
||||
opencode_skill_path.symlink_to(editable_skill_path)
|
||||
|
||||
|
||||
def _ensure_default_session(settings: dict) -> str:
|
||||
workdir = settings["workdir"].resolve()
|
||||
params = {"directory": str(workdir)}
|
||||
timeout = settings["timeout"]
|
||||
project = requests.post(
|
||||
f"{settings['origin']}/project/git/init",
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
project.raise_for_status()
|
||||
project_data = project.json()
|
||||
if Path(str(project_data.get("worktree") or "/")).resolve() != workdir:
|
||||
raise RuntimeError(
|
||||
f"OpenCode initialized the wrong project worktree: {project_data.get('worktree')!r}",
|
||||
)
|
||||
|
||||
sessions = requests.get(
|
||||
f"{settings['origin']}/session",
|
||||
params={**params, "roots": "true", "limit": 55},
|
||||
timeout=timeout,
|
||||
)
|
||||
sessions.raise_for_status()
|
||||
session_data = sessions.json()
|
||||
if not isinstance(session_data, list):
|
||||
raise RuntimeError("OpenCode returned an invalid project session list.")
|
||||
for session_data_item in session_data:
|
||||
if not isinstance(session_data_item, dict):
|
||||
continue
|
||||
session_id = str(session_data_item.get("id") or "")
|
||||
session_directory = session_data_item.get("directory")
|
||||
if session_id and session_directory and Path(str(session_directory)).resolve() == workdir:
|
||||
return session_id
|
||||
|
||||
session = requests.post(
|
||||
f"{settings['origin']}/session",
|
||||
params=params,
|
||||
json={},
|
||||
timeout=timeout,
|
||||
)
|
||||
session.raise_for_status()
|
||||
session_data = session.json()
|
||||
session_id = str(session_data.get("id") or "")
|
||||
session_directory = session_data.get("directory")
|
||||
if not session_id or not session_directory or Path(str(session_directory)).resolve() != workdir:
|
||||
raise RuntimeError(
|
||||
"OpenCode did not create a session for the requested worktree.",
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def _recent_session_id(settings: dict) -> str:
|
||||
workdir = str(settings["workdir"].resolve())
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings['origin']}/session",
|
||||
params={"directory": workdir, "roots": "true", "limit": 1},
|
||||
timeout=settings["timeout"],
|
||||
)
|
||||
response.raise_for_status()
|
||||
sessions = response.json()
|
||||
if sessions:
|
||||
return str(sessions[0].get("id") or "")
|
||||
except requests.RequestException:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _health(settings: dict) -> bool:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings['origin']}/global/health",
|
||||
timeout=2,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
|
||||
global _PROCESS
|
||||
started_process: subprocess.Popen | None = None
|
||||
workdir = settings["workdir"].resolve()
|
||||
try:
|
||||
binary, git_binary, binary_env = _resolve_binary(
|
||||
settings["binary"],
|
||||
settings["config"],
|
||||
)
|
||||
except RuntimeError as err:
|
||||
return False, str(err)
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
**binary_env,
|
||||
"ARCHIVEBOX_BASE_URL": str(settings.get("archivebox_base_url", "")),
|
||||
"ARCHIVEBOX_ADMIN_URL": str(settings.get("archivebox_admin_url", "")),
|
||||
"ARCHIVEBOX_API_URL": str(settings.get("archivebox_api_url", "")),
|
||||
"BROWSER": "false",
|
||||
"GIT_CEILING_DIRECTORIES": str(workdir),
|
||||
"HOME": str(settings["home"]),
|
||||
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
|
||||
"XDG_CONFIG_HOME": str(settings["config_home"]),
|
||||
"XDG_DATA_HOME": str(settings["data_home"]),
|
||||
"XDG_STATE_HOME": str(settings["state_home"]),
|
||||
"XDG_CACHE_HOME": str(settings["cache_home"]),
|
||||
}
|
||||
|
||||
with _PROCESS_LOCK:
|
||||
settings["workdir"].mkdir(parents=True, exist_ok=True)
|
||||
settings["config_home"].mkdir(parents=True, exist_ok=True)
|
||||
settings["data_home"].mkdir(parents=True, exist_ok=True)
|
||||
settings["state_home"].mkdir(parents=True, exist_ok=True)
|
||||
settings["cache_home"].mkdir(parents=True, exist_ok=True)
|
||||
settings["home"].mkdir(parents=True, exist_ok=True)
|
||||
_ensure_project_files(settings)
|
||||
|
||||
if not (workdir / ".git").exists():
|
||||
try:
|
||||
git_init = git_binary.exec(
|
||||
cmd=("init", "--quiet"),
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
timeout=settings["timeout"],
|
||||
)
|
||||
except (AssertionError, OSError, subprocess.SubprocessError) as err:
|
||||
return False, f"OpenCode project initialization failed: {err}"
|
||||
if git_init.returncode != 0:
|
||||
output = (git_init.stderr or git_init.stdout or "").strip()
|
||||
return (
|
||||
False,
|
||||
f"OpenCode project initialization failed: {output or f'git exited with {git_init.returncode}'}",
|
||||
)
|
||||
|
||||
if _health(settings):
|
||||
try:
|
||||
_ensure_default_session(settings)
|
||||
except (requests.RequestException, RuntimeError, ValueError) as err:
|
||||
return False, f"OpenCode project initialization failed: {err}"
|
||||
return True, ""
|
||||
|
||||
binary_abspath = binary.loaded_abspath
|
||||
if binary.loaded_binprovider is not None:
|
||||
binary_abspath = binary.loaded_binprovider._exec_bin_abspath(
|
||||
Path(binary.loaded_abspath),
|
||||
)
|
||||
cmd = [
|
||||
str(binary_abspath),
|
||||
"serve",
|
||||
"--hostname",
|
||||
settings["host"],
|
||||
"--port",
|
||||
str(settings["port"]),
|
||||
]
|
||||
try:
|
||||
_PROCESS = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
started_process = _PROCESS
|
||||
except FileNotFoundError:
|
||||
return False, f"OpenCode binary not found: {settings['binary']}"
|
||||
|
||||
deadline = time.monotonic() + settings["timeout"]
|
||||
while time.monotonic() < deadline:
|
||||
if _health(settings):
|
||||
try:
|
||||
_ensure_default_session(settings)
|
||||
except (requests.RequestException, RuntimeError, ValueError) as err:
|
||||
_stop_owned_process(started_process)
|
||||
return False, f"OpenCode project initialization failed: {err}"
|
||||
return True, ""
|
||||
if started_process and started_process.poll() is not None:
|
||||
if _PROCESS is started_process:
|
||||
_PROCESS = None
|
||||
return False, "OpenCode exited before the web server became ready."
|
||||
time.sleep(0.25)
|
||||
|
||||
_stop_owned_process(started_process)
|
||||
return False, "Timed out waiting for OpenCode to start."
|
||||
|
||||
|
||||
def agent_view(request: HttpRequest):
|
||||
config = _machine_config()
|
||||
_require_enabled(config)
|
||||
auth_response = _require_superuser(request)
|
||||
if auth_response:
|
||||
return auth_response
|
||||
|
||||
settings = _settings(config)
|
||||
settings = runtime._settings(config, CONSTANTS.DATA_DIR)
|
||||
route_config = request.__dict__.get("archivebox_config")
|
||||
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
|
||||
settings["archivebox_base_url"] = base_url
|
||||
settings["archivebox_admin_url"] = admin_url
|
||||
settings["archivebox_api_url"] = api_url
|
||||
ok, error = _ensure_opencode(settings)
|
||||
from archivebox.core.admin_site import archivebox_admin
|
||||
|
||||
recent_session_id = _recent_session_id(settings) if ok else ""
|
||||
context = {
|
||||
**archivebox_admin.each_context(request),
|
||||
"title": "Agent",
|
||||
"error": "" if ok else error,
|
||||
"command": f"{settings['binary']} serve --hostname {settings['host']} --port {settings['port']}" if error else "",
|
||||
# OpenCode 1.17+ keeps durable sessions on the explicit
|
||||
# /<dirBase64>/session/<sessionId> route. We still seed localStorage
|
||||
# below because the sidebar state uses it, but the iframe itself must
|
||||
# open the durable session URL so a fresh browser does not land on the
|
||||
# transient new-session route and appear to have lost prior sessions.
|
||||
"proxy_url": _project_route(settings["workdir"], recent_session_id),
|
||||
"workdir": str(settings["workdir"].resolve()),
|
||||
"recent_session_id": recent_session_id,
|
||||
}
|
||||
return render(
|
||||
request,
|
||||
"opencode/agent.html",
|
||||
context,
|
||||
status=200 if ok else 502,
|
||||
settings.update(
|
||||
archivebox_base_url=get_base_url(request=request, config=route_config).rstrip("/"),
|
||||
archivebox_admin_url=build_admin_url("/admin/", request=request, config=route_config).rstrip("/"),
|
||||
archivebox_api_url=f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/",
|
||||
)
|
||||
return runtime, settings
|
||||
|
||||
|
||||
def _proxy_url(settings: dict, path: str | None) -> str:
|
||||
rel = "/" if not path else f"/{path}"
|
||||
return urljoin(settings["origin"], rel)
|
||||
def _dispatch(request, path=None):
|
||||
try:
|
||||
# Middleware already merged server defaults and live Machine overrides.
|
||||
# Re-resolving every extractor's config here stalls the UI request burst.
|
||||
config = get_request_config(request).model_dump(mode="json")
|
||||
if not config.get("OPENCODE_ENABLED", False):
|
||||
raise Http404
|
||||
if not request.user.is_authenticated:
|
||||
return redirect_to_login(request.get_full_path(), login_url="/admin/login/")
|
||||
if not request.user.is_active or not request.user.is_superuser:
|
||||
return HttpResponseForbidden("Agent access requires a superuser account.")
|
||||
|
||||
runtime, settings = _runtime_settings(request, config)
|
||||
if path is None:
|
||||
from archivebox.core.admin_site import archivebox_admin
|
||||
|
||||
context = {**archivebox_admin.each_context(request), **runtime.agent_context(settings)}
|
||||
source = get_plugin_template("opencode", "agent", fallback=False)
|
||||
if source is None:
|
||||
raise RuntimeError("Agent template unavailable")
|
||||
return HttpResponse(engines["django"].from_string(source).render(context, request))
|
||||
|
||||
if not runtime._origin_allowed(request.method, request.get_host(), request.headers):
|
||||
return HttpResponseForbidden("Cross-origin agent requests are blocked.")
|
||||
status, headers, body = runtime.proxy(
|
||||
settings,
|
||||
request.method,
|
||||
path,
|
||||
tuple((key, value) for key, values in request.GET.lists() for value in values),
|
||||
request.headers,
|
||||
request.body,
|
||||
)
|
||||
response_type = HttpResponse if isinstance(body, bytes) else StreamingHttpResponse
|
||||
response = response_type(body, status=status, headers=headers)
|
||||
response.xframe_options_exempt = True
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
return response
|
||||
except Http404:
|
||||
raise
|
||||
except Exception:
|
||||
# Optional-service boundary, including imports and template rendering.
|
||||
_LOGGER.exception("Optional AI service failed")
|
||||
return HttpResponse("AI service unavailable. See server logs.", status=503, content_type="text/plain")
|
||||
|
||||
|
||||
def _request_header(request: HttpRequest, name: str) -> str | None:
|
||||
meta = getattr(request, "META", {})
|
||||
if name == "Content-Type":
|
||||
value = meta.get("CONTENT_TYPE")
|
||||
elif name == "Content-Length":
|
||||
value = meta.get("CONTENT_LENGTH")
|
||||
else:
|
||||
value = meta.get(f"HTTP_{name.upper().replace('-', '_')}")
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _request_headers(request: HttpRequest, settings: dict) -> dict[str, str]:
|
||||
forwarded = {}
|
||||
for key in ("Accept", "Accept-Language", "Content-Type", "Range", "User-Agent"):
|
||||
value = _request_header(request, key)
|
||||
if value:
|
||||
forwarded[key] = value
|
||||
return forwarded
|
||||
|
||||
|
||||
def _request_params(request: HttpRequest) -> tuple[tuple[str, str], ...]:
|
||||
if hasattr(request.GET, "lists"):
|
||||
return tuple((key, str(value)) for key, values in request.GET.lists() for value in values)
|
||||
return tuple((key, str(value)) for key, value in dict(request.GET).items())
|
||||
|
||||
|
||||
async def _event_chunks(request: HttpRequest, settings: dict, path: str | None):
|
||||
timeout = httpx.Timeout(settings["timeout"], read=None)
|
||||
url = _proxy_url(settings, path)
|
||||
method = request.method or "GET"
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||
async with client.stream(
|
||||
method,
|
||||
url,
|
||||
params=_request_params(request),
|
||||
headers=_request_headers(request, settings),
|
||||
) as upstream:
|
||||
async for chunk in upstream.aiter_raw(chunk_size=512):
|
||||
yield chunk
|
||||
|
||||
|
||||
def _rewrite_text(body: bytes, settings: dict) -> bytes:
|
||||
text = body.decode("utf-8", errors="replace")
|
||||
text = text.replace(settings["origin"], _PROXY_PREFIX)
|
||||
text = text.replace("location.origin", f'location.origin+"{_PROXY_PREFIX}"')
|
||||
text = text.replace(
|
||||
"k(k5,{get component(){return t.router??Az},",
|
||||
f'k(k5,{{base:"{_PROXY_PREFIX}",get component(){{return t.router??Az}},',
|
||||
)
|
||||
text = text.replace('"/assets/', f'"{_PROXY_PREFIX}/assets/')
|
||||
text = text.replace("'/assets/", f"'{_PROXY_PREFIX}/assets/")
|
||||
proxy_path = rf'(\1.replace(/^{_PROXY_PREFIX_REGEX}(?=\/|$)/,"")||"/")'
|
||||
text = re.sub(r"\b(window\.location\.pathname)\b", proxy_path, text)
|
||||
text = re.sub(r"(?<![.\w])(location\.pathname)\b", proxy_path, text)
|
||||
text = text.replace(
|
||||
'window.history.replaceState(nz(o),"",r):window.history.pushState(o,"",r)',
|
||||
(
|
||||
f'window.history.replaceState(nz(o),"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r):'
|
||||
f'window.history.pushState(o,"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r)'
|
||||
),
|
||||
)
|
||||
text = text.replace(
|
||||
'const BL="modulepreload",UL=function(t){return"/"+t}',
|
||||
f'const BL="modulepreload",UL=function(t){{return"{_PROXY_PREFIX}/"+t}}',
|
||||
)
|
||||
text = re.sub(
|
||||
rf"""(?P<prefix>\b(?:href|src|action)=["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
|
||||
rf"\g<prefix>{_PROXY_PREFIX}/",
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
rf"""(?P<prefix>\b(?:fetch|EventSource)\(["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
|
||||
rf"\g<prefix>{_PROXY_PREFIX}/",
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
rf"""(?P<prefix>\burl\(["']?)/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
|
||||
rf"\g<prefix>{_PROXY_PREFIX}/",
|
||||
text,
|
||||
)
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
def _response_headers(upstream: requests.Response, settings: dict) -> dict[str, str]:
|
||||
headers = {}
|
||||
for key, value in upstream.headers.items():
|
||||
lower = key.lower()
|
||||
if lower in _HOP_BY_HOP_HEADERS or lower in {
|
||||
"content-length",
|
||||
"content-encoding",
|
||||
"x-frame-options",
|
||||
}:
|
||||
continue
|
||||
if lower == "location":
|
||||
if value.startswith(settings["origin"]):
|
||||
value = value.replace(settings["origin"], _PROXY_PREFIX, 1)
|
||||
elif value.startswith("/"):
|
||||
value = f"{_PROXY_PREFIX}{value}"
|
||||
headers[key] = value
|
||||
return headers
|
||||
def agent_view(request):
|
||||
return _dispatch(request)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def opencode_proxy_view(request: HttpRequest, path: str | None = None):
|
||||
config = _machine_config()
|
||||
_require_enabled(config)
|
||||
auth_response = _require_superuser(request)
|
||||
if auth_response:
|
||||
return auth_response
|
||||
if not _origin_allowed(request, path):
|
||||
return HttpResponseForbidden(
|
||||
b"Cross-origin OpenCode agent requests are blocked.",
|
||||
)
|
||||
def opencode_proxy_view(request, path=None):
|
||||
return _dispatch(request, path=path or "")
|
||||
|
||||
settings = _settings(config)
|
||||
route_config = request.__dict__.get("archivebox_config")
|
||||
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
|
||||
settings["archivebox_base_url"] = base_url
|
||||
settings["archivebox_admin_url"] = admin_url
|
||||
settings["archivebox_api_url"] = api_url
|
||||
ok, error = _ensure_opencode(settings)
|
||||
if not ok:
|
||||
return HttpResponse(
|
||||
error.encode(),
|
||||
status=502,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
|
||||
if request.method == "GET" and (path or "").endswith("/event"):
|
||||
response = StreamingHttpResponse(
|
||||
_event_chunks(request, settings, path),
|
||||
content_type="text/event-stream",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
|
||||
def _websocket_context(scope):
|
||||
close_old_connections()
|
||||
try:
|
||||
method = request.method or "GET"
|
||||
upstream = requests.request(
|
||||
method,
|
||||
_proxy_url(settings, path),
|
||||
params=_request_params(request),
|
||||
data=request.body if method not in {"GET", "HEAD"} else None,
|
||||
headers=_request_headers(request, settings),
|
||||
stream=True,
|
||||
timeout=(settings["timeout"], None),
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as err:
|
||||
return HttpResponse(
|
||||
str(err).encode(),
|
||||
status=502,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
match = resolve(scope["path"])
|
||||
if match.url_name != "opencode-proxy":
|
||||
raise PermissionDenied
|
||||
request = ASGIRequest(
|
||||
{**scope, "type": "http", "method": "GET", "scheme": "https" if scope.get("scheme") == "wss" else "http"},
|
||||
BytesIO(),
|
||||
)
|
||||
route_config = get_request_config(request, resolve_plugins=False)
|
||||
request.archivebox_config = route_config
|
||||
if not route_config.CONTROL_PLANE_ENABLED:
|
||||
raise PermissionDenied
|
||||
if (
|
||||
route_config.USES_SUBDOMAIN_ROUTING
|
||||
and route_config.BASE_URL
|
||||
and not host_matches(request.get_host(), get_admin_host(config=route_config, request=request))
|
||||
):
|
||||
raise PermissionDenied
|
||||
SessionMiddleware(_dispatch).process_request(request)
|
||||
AuthenticationMiddleware(_dispatch).process_request(request)
|
||||
ReverseProxyAuthMiddleware(_dispatch).process_request(request)
|
||||
config = route_config.model_dump(mode="json")
|
||||
if not config.get("OPENCODE_ENABLED") or not request.user.is_active or not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
runtime, settings = _runtime_settings(request, config)
|
||||
if not request.headers.get("Origin") or not runtime._origin_allowed("POST", request.get_host(), request.headers):
|
||||
raise PermissionDenied
|
||||
return runtime, settings, match.kwargs.get("path", "")
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
content_type = upstream.headers.get("Content-Type", "")
|
||||
is_event_stream = content_type.startswith("text/event-stream")
|
||||
is_text = not is_event_stream and any(content_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES)
|
||||
headers = _response_headers(upstream, settings)
|
||||
if is_text:
|
||||
body = _rewrite_text(upstream.content, settings)
|
||||
response = HttpResponse(
|
||||
body,
|
||||
status=upstream.status_code,
|
||||
content_type=content_type or "text/plain; charset=utf-8",
|
||||
)
|
||||
elif is_event_stream:
|
||||
response = StreamingHttpResponse(
|
||||
upstream.iter_lines(chunk_size=1),
|
||||
status=upstream.status_code,
|
||||
content_type=content_type or "text/event-stream",
|
||||
)
|
||||
else:
|
||||
response = StreamingHttpResponse(
|
||||
upstream.iter_content(chunk_size=64 * 1024),
|
||||
status=upstream.status_code,
|
||||
content_type=content_type or "application/octet-stream",
|
||||
)
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = value
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
async def websocket_view(scope, receive, send):
|
||||
if (await receive())["type"] != "websocket.connect":
|
||||
return
|
||||
try:
|
||||
runtime, settings, path = await sync_to_async(_websocket_context)(scope)
|
||||
await runtime.websocket_proxy(settings, path, scope.get("query_string", b""), scope.get("subprotocols", []), receive, send)
|
||||
except (PermissionDenied, Resolver404, Http404):
|
||||
await send({"type": "websocket.close", "code": 1008})
|
||||
except Exception:
|
||||
_LOGGER.exception("Optional AI WebSocket failed")
|
||||
await send({"type": "websocket.close", "code": 1011})
|
||||
@@ -27,7 +27,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
"Persona",
|
||||
{
|
||||
"fields": ("name", "created_by", "permissions"),
|
||||
"classes": ("card", "persona-card-primary"),
|
||||
"classes": ("card", "wide", "persona-card-primary"),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -42,7 +42,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
"import_extract_cookies",
|
||||
"import_capture_storage",
|
||||
),
|
||||
"classes": ("card", "wide"),
|
||||
"classes": ("card", "wide", "persona-card-import"),
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -63,7 +63,6 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
"classes": ("card", "persona-card-timestamps"),
|
||||
},
|
||||
),
|
||||
add_fieldsets[1],
|
||||
add_fieldsets[2],
|
||||
(
|
||||
"Artifacts",
|
||||
@@ -136,7 +135,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
return super().get_form(request, obj=obj, change=change, **kwargs)
|
||||
|
||||
def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
|
||||
context["detected_profile_count"] = len(discover_local_browser_profiles())
|
||||
context["detected_profile_count"] = len(discover_local_browser_profiles()) if add else 0
|
||||
return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
* COOKIES_OUTPUT_FILE Optional output path for Netscape cookies.txt
|
||||
* AUTH_STORAGE_OUTPUT_FILE Optional output path for auth.json
|
||||
* CHROME_BINARY Absolute browser path resolved by abxpkg
|
||||
* CHROME_SANDBOX Set to false when the Linux sandbox is unavailable
|
||||
* NODE_MODULES_DIR node_modules path resolved by abxpkg
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const pluginsDir = process.env.ARCHIVEBOX_ABX_PLUGINS_DIR || process.env.ABX_PLUGINS_DIR;
|
||||
@@ -26,7 +26,7 @@ const baseUtils = require(path.join(pluginsDir, 'base', 'utils.js'));
|
||||
baseUtils.ensureNodeModuleResolution(module);
|
||||
|
||||
const chromeUtils = require(path.join(pluginsDir, 'chrome', 'chrome_utils.js'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
function cookieToNetscape(cookie) {
|
||||
let domain = cookie.domain;
|
||||
@@ -115,38 +115,28 @@ async function openBrowser() {
|
||||
throw new Error(`User data directory does not exist: ${userDataDir}`);
|
||||
}
|
||||
|
||||
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abx-browser-state-'));
|
||||
const binary = process.env.CHROME_BINARY;
|
||||
if (!binary || !path.isAbsolute(binary) || !fs.existsSync(binary)) {
|
||||
throw new Error('CHROME_BINARY must be an absolute executable path resolved by abxpkg');
|
||||
}
|
||||
|
||||
const launched = await chromeUtils.launchChromium({
|
||||
binary,
|
||||
outputDir,
|
||||
// The ordinary archiving launcher uses a mock keychain. An import must
|
||||
// instead let the original signed browser decrypt its own profile cookies.
|
||||
const sandboxDisabled = ['false', '0', 'no', 'off'].includes(
|
||||
String(process.env.CHROME_SANDBOX || '').trim().toLowerCase(),
|
||||
);
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: binary,
|
||||
userDataDir,
|
||||
headless: true,
|
||||
killZombies: false,
|
||||
ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'],
|
||||
args: ['--no-first-run', '--disable-sync', '--disable-background-networking',
|
||||
'--profile-directory=Default', ...(process.platform === 'linux' && (process.getuid?.() === 0 || sandboxDisabled) ? ['--no-sandbox'] : [])],
|
||||
});
|
||||
|
||||
if (!launched.success) {
|
||||
throw new Error(launched.error || 'Chrome launch failed');
|
||||
}
|
||||
|
||||
const browser = await chromeUtils.connectToBrowserEndpoint(puppeteer, launched.cdpUrl, { defaultViewport: null });
|
||||
|
||||
return {
|
||||
browser,
|
||||
async cleanup() {
|
||||
try {
|
||||
await browser.disconnect();
|
||||
} catch (error) {}
|
||||
try {
|
||||
await chromeUtils.killChrome(launched.pid, outputDir);
|
||||
} catch (error) {}
|
||||
try {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
} catch (error) {}
|
||||
await browser.close();
|
||||
},
|
||||
sourceDescription: userDataDir,
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
|
||||
label="Autodiscovered profiles",
|
||||
widget=forms.RadioSelect,
|
||||
choices=(),
|
||||
help_text="Detected from local Chrome, Chromium, Brave, and Edge profile roots.",
|
||||
help_text="Detected from local Chrome, Chrome Beta, Chromium, Brave, and Edge profile roots.",
|
||||
)
|
||||
import_source = forms.CharField(
|
||||
required=False,
|
||||
|
||||
@@ -11,10 +11,14 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
@@ -28,6 +32,7 @@ if TYPE_CHECKING:
|
||||
|
||||
BROWSER_LABELS = {
|
||||
"chrome": "Google Chrome",
|
||||
"chrome-beta": "Google Chrome Beta",
|
||||
"chromium": "Chromium",
|
||||
"brave": "Brave",
|
||||
"edge": "Microsoft Edge",
|
||||
@@ -42,25 +47,27 @@ BROWSER_PROFILE_DIR_NAMES = (
|
||||
)
|
||||
|
||||
VOLATILE_PROFILE_COPY_PATTERNS = (
|
||||
# Chromium/macOS atomic-write staging files (e.g.
|
||||
# .com.brave.Browser.TransportSecurity.Ze7UBU) disappear after rename.
|
||||
".*.??????",
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"ShaderCache",
|
||||
"Service Worker",
|
||||
"GCM Store",
|
||||
"chrome-extension_*",
|
||||
"DNR Extension Rules",
|
||||
"Extension Rules",
|
||||
"Extension Scripts",
|
||||
"Extension State",
|
||||
"Local Extension Settings",
|
||||
"*.log",
|
||||
"Crashpad",
|
||||
"BrowserMetrics",
|
||||
"BrowserMetrics-spare.pma",
|
||||
"RunningChromeVersion",
|
||||
"DevToolsActivePort",
|
||||
"SingletonLock",
|
||||
"SingletonSocket",
|
||||
"SingletonCookie",
|
||||
"Sessions",
|
||||
"Sessions_Encrypted",
|
||||
"Current Session",
|
||||
"Current Tabs",
|
||||
"Last Session",
|
||||
"Last Tabs",
|
||||
)
|
||||
|
||||
PERSONA_PROFILE_DIR_CANDIDATES = (
|
||||
@@ -201,6 +208,7 @@ class PersonaImportResult:
|
||||
source: PersonaImportSource
|
||||
profile_copied: bool = False
|
||||
cookies_imported: bool = False
|
||||
cookie_count: int = 0
|
||||
storage_captured: bool = False
|
||||
user_agent_imported: bool = False
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
@@ -210,28 +218,29 @@ class PersonaImportResult:
|
||||
return self.profile_copied or self.cookies_imported or self.storage_captured or self.user_agent_imported
|
||||
|
||||
|
||||
def get_chrome_user_data_dir() -> Path | None:
|
||||
def get_chrome_user_data_dir(*, beta: bool = False) -> Path | None:
|
||||
"""Get the default Chrome user data directory for the current platform."""
|
||||
system = platform.system()
|
||||
home = Path.home()
|
||||
app_name = "Chrome Beta" if beta else "Chrome"
|
||||
|
||||
if system == "Darwin":
|
||||
candidates = [
|
||||
home / "Library" / "Application Support" / "Google" / "Chrome",
|
||||
home / "Library" / "Application Support" / "Chromium",
|
||||
home / "Library" / "Application Support" / "Google" / app_name,
|
||||
]
|
||||
elif system == "Linux":
|
||||
candidates = [
|
||||
home / ".config" / "google-chrome",
|
||||
home / ".config" / "chromium",
|
||||
home / ".config" / "chrome",
|
||||
home / "snap" / "chromium" / "common" / "chromium",
|
||||
]
|
||||
candidates = (
|
||||
[home / ".config" / "google-chrome-beta"]
|
||||
if beta
|
||||
else [
|
||||
home / ".config" / "google-chrome",
|
||||
home / ".config" / "chrome",
|
||||
]
|
||||
)
|
||||
elif system == "Windows":
|
||||
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
|
||||
candidates = [
|
||||
local_app_data / "Google" / "Chrome" / "User Data",
|
||||
local_app_data / "Chromium" / "User Data",
|
||||
local_app_data / "Google" / app_name / "User Data",
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
@@ -243,6 +252,21 @@ def get_chrome_user_data_dir() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_chromium_user_data_dir() -> Path | None:
|
||||
"""Select Chromium's own profile even when Google Chrome is installed."""
|
||||
home = Path.home()
|
||||
if platform.system() == "Darwin":
|
||||
candidates = [home / "Library/Application Support/Chromium"]
|
||||
elif platform.system() == "Windows":
|
||||
candidates = [Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local")) / "Chromium/User Data"]
|
||||
else:
|
||||
candidates = [
|
||||
Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "chromium",
|
||||
home / "snap/chromium/common/chromium",
|
||||
]
|
||||
return next((path for path in candidates if path.is_dir() and _list_profile_names(path)), None)
|
||||
|
||||
|
||||
def get_brave_user_data_dir() -> Path | None:
|
||||
"""Get the default Brave user data directory for the current platform."""
|
||||
system = platform.system()
|
||||
@@ -303,7 +327,8 @@ def get_edge_user_data_dir() -> Path | None:
|
||||
|
||||
BROWSER_PROFILE_FINDERS = {
|
||||
"chrome": get_chrome_user_data_dir,
|
||||
"chromium": get_chrome_user_data_dir,
|
||||
"chrome-beta": partial(get_chrome_user_data_dir, beta=True),
|
||||
"chromium": get_chromium_user_data_dir,
|
||||
"brave": get_brave_user_data_dir,
|
||||
"edge": get_edge_user_data_dir,
|
||||
}
|
||||
@@ -337,6 +362,12 @@ def validate_persona_name(name: str) -> tuple[bool, str]:
|
||||
|
||||
|
||||
def discover_local_browser_profiles() -> list[PersonaImportSource]:
|
||||
# The macOS companion exports with the host Keychain before creating personas.
|
||||
# Raw mounts alone cannot decrypt macOS cookies in the Linux container.
|
||||
if host_profiles := os.environ.get("ARCHIVEBOX_HOST_BROWSER_PROFILES"):
|
||||
host_templates = discover_persona_template_profiles(Path(host_profiles))
|
||||
host_names = {source.source_name for source in host_templates}
|
||||
return host_templates + [source for source in discover_persona_template_profiles() if source.source_name not in host_names]
|
||||
discovered: list[PersonaImportSource] = []
|
||||
|
||||
for browser, finder in BROWSER_PROFILE_FINDERS.items():
|
||||
@@ -413,13 +444,21 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
|
||||
|
||||
def resolve_browser_import_source(browser: str, profile_dir: str | None = None) -> PersonaImportSource:
|
||||
browser = browser.lower().strip()
|
||||
if host_profiles := os.environ.get("ARCHIVEBOX_HOST_BROWSER_PROFILES"):
|
||||
label = BROWSER_LABELS.get(browser, browser)
|
||||
exported = Path(host_profiles) / f"{label} - {profile_dir or 'Default'}"
|
||||
if exported.is_dir():
|
||||
return resolve_custom_import_source(str(exported))
|
||||
if browser not in BROWSER_PROFILE_FINDERS:
|
||||
supported = ", ".join(BROWSER_PROFILE_FINDERS)
|
||||
raise ValueError(f"Unknown browser: {browser}. Supported browsers: {supported}")
|
||||
|
||||
user_data_dir = BROWSER_PROFILE_FINDERS[browser]()
|
||||
if not user_data_dir:
|
||||
raise ValueError(f"Could not find {browser} profile directory")
|
||||
raise ValueError(
|
||||
f"Could not find {browser} profile directory. Use --source /path/to/browser-data. "
|
||||
"For Docker on macOS/Windows, import on the host into the shared data directory first.",
|
||||
)
|
||||
|
||||
chosen_profile = profile_dir or pick_default_profile_dir(user_data_dir)
|
||||
if not chosen_profile:
|
||||
@@ -444,6 +483,8 @@ def resolve_browser_profile_source(
|
||||
resolved_root = resolved_root.resolve()
|
||||
if not resolved_root.exists():
|
||||
raise ValueError(f"Profile root does not exist: {resolved_root}")
|
||||
if Path(profile_dir).name != profile_dir or profile_dir in {".", ".."}:
|
||||
raise ValueError("Profile must be a directory name within the source browser root.")
|
||||
if not profile_dir.strip():
|
||||
raise ValueError("Profile directory name cannot be empty.")
|
||||
|
||||
@@ -476,6 +517,17 @@ def resolve_custom_import_source(raw_value: str, profile_dir: str | None = None)
|
||||
raise ValueError(f"Custom browser path does not exist: {source_path}")
|
||||
|
||||
explicit_profile = profile_dir.strip() if profile_dir else ""
|
||||
for profile_root in PERSONA_PROFILE_DIR_CANDIDATES:
|
||||
root = source_path / profile_root
|
||||
if root.is_dir():
|
||||
selected = explicit_profile or pick_default_profile_dir(root)
|
||||
if selected:
|
||||
return resolve_browser_profile_source(
|
||||
browser="persona",
|
||||
source_name=source_path.name,
|
||||
user_data_dir=root.resolve(),
|
||||
profile_dir=selected,
|
||||
)
|
||||
if _looks_like_profile_dir(source_path):
|
||||
if explicit_profile and explicit_profile != source_path.name:
|
||||
raise ValueError("Profile name does not match the provided profile directory path.")
|
||||
@@ -508,6 +560,48 @@ def pick_default_profile_dir(user_data_dir: Path) -> str | None:
|
||||
return profiles[0]
|
||||
|
||||
|
||||
def resolve_source_browser_binary(source: PersonaImportSource) -> str:
|
||||
"""Use the originating browser so OS-protected cookies use the correct keychain."""
|
||||
if source.browser_binary:
|
||||
return str(Path(source.browser_binary).expanduser().resolve())
|
||||
names = {
|
||||
"chrome": ("Google Chrome", "google-chrome"),
|
||||
"chrome-beta": ("Google Chrome Beta", "google-chrome-beta"),
|
||||
"chromium": ("Chromium", "chromium"),
|
||||
"brave": ("Brave Browser", "brave-browser"),
|
||||
"edge": ("Microsoft Edge", "microsoft-edge"),
|
||||
}
|
||||
app, executable = names.get(source.browser, ("", ""))
|
||||
candidates = []
|
||||
if platform.system() == "Darwin" and app:
|
||||
candidates = [
|
||||
Path("/Applications") / f"{app}.app/Contents/MacOS/{app}",
|
||||
Path.home() / f"Applications/{app}.app/Contents/MacOS/{app}",
|
||||
]
|
||||
elif platform.system() == "Windows":
|
||||
relative = {
|
||||
"chrome": "Google/Chrome/Application/chrome.exe",
|
||||
"chrome-beta": "Google/Chrome Beta/Application/chrome.exe",
|
||||
"edge": "Microsoft/Edge/Application/msedge.exe",
|
||||
"brave": "BraveSoftware/Brave-Browser/Application/brave.exe",
|
||||
"chromium": "Chromium/Application/chrome.exe",
|
||||
}.get(source.browser)
|
||||
if relative:
|
||||
candidates = [
|
||||
Path(os.environ[root]) / relative for root in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)") if os.environ.get(root)
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
if executable and (found := shutil.which(executable)):
|
||||
return found
|
||||
raise ValueError(
|
||||
f"Source {source.browser} browser executable not found. Use --browser-binary with the originating Chromium browser. "
|
||||
"For Docker, run persona create --import on the browser's host into the shared data directory first: "
|
||||
"the container cannot decrypt cookies using the host's macOS Keychain or Windows credentials.",
|
||||
)
|
||||
|
||||
|
||||
def import_persona_from_source(
|
||||
persona: Persona,
|
||||
source: PersonaImportSource,
|
||||
@@ -516,67 +610,182 @@ def import_persona_from_source(
|
||||
import_cookies: bool = True,
|
||||
capture_storage: bool = False,
|
||||
) -> PersonaImportResult:
|
||||
# Stage all work before replacing an existing, working identity.
|
||||
persona.ensure_dirs()
|
||||
result = PersonaImportResult(source=source)
|
||||
expected_cookies = 0
|
||||
browser_binary = None
|
||||
native_cookie_browsers = {"chrome", "chromium", "brave", "edge", "vivaldi", "opera", "opera_gx"}
|
||||
# browser-cookie3 needs a desktop D-Bus session on Linux. In a headless
|
||||
# environment, let the originating browser decode its own profile instead.
|
||||
has_native_cookie_decoder = platform.system() != "Linux" or bool(os.environ.get("DBUS_SESSION_BUS_ADDRESS"))
|
||||
native_cookie_import = source.kind == "browser-profile" and source.browser in native_cookie_browsers and has_native_cookie_decoder
|
||||
portable_import = source.kind == "browser-profile" and source.browser == "persona"
|
||||
if source.kind == "browser-profile" and not portable_import and (import_cookies or capture_storage):
|
||||
if not native_cookie_import:
|
||||
browser_binary = resolve_source_browser_binary(source)
|
||||
for cookie_db in (source.profile_path / "Network" / "Cookies", source.profile_path / "Cookies"):
|
||||
if cookie_db.is_file():
|
||||
with sqlite3.connect(f"{cookie_db.as_uri()}?mode=ro", uri=True) as conn:
|
||||
expected_cookies = conn.execute(
|
||||
"SELECT COUNT(*) FROM cookies WHERE expires_utc = 0 OR expires_utc > ?",
|
||||
(int((time.time() + 11644473600) * 1_000_000),),
|
||||
).fetchone()[0]
|
||||
break
|
||||
with tempfile.TemporaryDirectory(prefix=".import-", dir=persona.path.parent) as tmp:
|
||||
stage = Path(tmp)
|
||||
staged_profile = stage / "chrome_profile"
|
||||
if source.kind == "browser-profile":
|
||||
assert source.user_data_dir and source.profile_path
|
||||
if copy_profile or import_cookies or capture_storage:
|
||||
staged_profile.mkdir()
|
||||
# Only the selected profile belongs to this persona. Normalize its
|
||||
# name so downstream Chromium launches always select it.
|
||||
copy_browser_user_data_dir(source.profile_path, staged_profile / "Default")
|
||||
local_state = source.user_data_dir / "Local State"
|
||||
if local_state.exists():
|
||||
state = json.loads(local_state.read_text())
|
||||
profile_state = state.setdefault("profile", {})
|
||||
profile_state["last_used"] = "Default"
|
||||
profile_state["last_active_profiles"] = ["Default"]
|
||||
info = profile_state.get("info_cache", {}).get(source.profile_dir)
|
||||
profile_state["info_cache"] = {"Default": info} if info else {}
|
||||
(staged_profile / "Local State").write_text(json.dumps(state))
|
||||
persona.cleanup_chrome_profile(staged_profile)
|
||||
result.profile_copied = copy_profile
|
||||
elif copy_profile:
|
||||
result.warnings.append(
|
||||
"CDP imports capture cookies and open-tab storage, not browser settings. Use --source for a full profile import.",
|
||||
)
|
||||
|
||||
persona_chrome_dir = Path(persona.CHROME_USER_DATA_DIR)
|
||||
cookies_file = persona.path / "cookies.txt"
|
||||
auth_file = persona.path / "auth.json"
|
||||
|
||||
launch_user_data_dir: Path | None = None
|
||||
|
||||
if source.kind == "browser-profile":
|
||||
if copy_profile and source.user_data_dir:
|
||||
resolved_source_root = source.user_data_dir.resolve()
|
||||
resolved_persona_root = persona_chrome_dir.resolve()
|
||||
if resolved_source_root == resolved_persona_root:
|
||||
result.warnings.append(
|
||||
"Skipped profile copy because the selected source is already this persona's chrome_profile directory.",
|
||||
)
|
||||
if import_cookies or capture_storage:
|
||||
if portable_import:
|
||||
export_dir = source.user_data_dir.parent
|
||||
if not all((export_dir / name).is_file() for name in ("auth.json", "cookies.txt")):
|
||||
raise ValueError("This persona has no portable cookie export. Re-import it on the original browser host first.")
|
||||
auth_payload = json.loads((export_dir / "auth.json").read_text())
|
||||
if not isinstance(auth_payload, dict) or not isinstance(auth_payload.get("cookies"), list):
|
||||
raise ValueError("Invalid persona auth.json: expected a cookies list.")
|
||||
for name in ("auth.json", "cookies.txt"):
|
||||
shutil.copy2(export_dir / name, stage / name)
|
||||
success, message = True, ""
|
||||
elif native_cookie_import:
|
||||
auth_payload = export_profile_cookies(source, staged_profile, stage)
|
||||
success, message = True, ""
|
||||
else:
|
||||
copy_browser_user_data_dir(resolved_source_root, resolved_persona_root)
|
||||
persona.cleanup_chrome_profile(resolved_persona_root)
|
||||
result.profile_copied = True
|
||||
launch_user_data_dir = resolved_persona_root
|
||||
else:
|
||||
launch_user_data_dir = source.user_data_dir
|
||||
elif copy_profile:
|
||||
result.warnings.append(
|
||||
"Profile copying is only available for local Chromium profile paths. CDP imports can only pull cookies and open-tab storage.",
|
||||
)
|
||||
|
||||
if not import_cookies and not capture_storage:
|
||||
return result
|
||||
|
||||
if source.kind == "cdp":
|
||||
export_success, auth_payload, export_message = export_browser_state(
|
||||
cdp_url=source.cdp_url,
|
||||
cookies_output_file=cookies_file if import_cookies else None,
|
||||
auth_output_file=auth_file if capture_storage else None,
|
||||
)
|
||||
else:
|
||||
export_success, auth_payload, export_message = export_browser_state(
|
||||
user_data_dir=launch_user_data_dir,
|
||||
profile_dir=source.profile_dir,
|
||||
chrome_binary=source.browser_binary,
|
||||
cookies_output_file=cookies_file if import_cookies else None,
|
||||
auth_output_file=auth_file if capture_storage else None,
|
||||
)
|
||||
|
||||
if not export_success:
|
||||
result.warnings.append(export_message or "Browser import failed.")
|
||||
return result
|
||||
|
||||
if import_cookies and cookies_file.exists():
|
||||
result.cookies_imported = True
|
||||
if capture_storage and auth_file.exists():
|
||||
result.storage_captured = True
|
||||
if _apply_imported_user_agent(persona, auth_payload):
|
||||
result.user_agent_imported = True
|
||||
# Custom browsers and headless Linux use the originating browser.
|
||||
# Desktop imports use their OS decoder without launching or probing.
|
||||
launch_profile = stage / "export_profile"
|
||||
if source.kind == "browser-profile":
|
||||
copy_browser_user_data_dir(staged_profile, launch_profile)
|
||||
success, auth_payload, message = export_browser_state(
|
||||
user_data_dir=launch_profile if source.kind == "browser-profile" else None,
|
||||
cdp_url=source.cdp_url,
|
||||
profile_dir="Default" if source.kind == "browser-profile" else None,
|
||||
chrome_binary=browser_binary,
|
||||
cookies_output_file=stage / "cookies.txt" if import_cookies else None,
|
||||
auth_output_file=stage / "auth.json",
|
||||
)
|
||||
if not success:
|
||||
raise ValueError(message or "Browser state export failed; persona was not replaced.")
|
||||
if expected_cookies and not (auth_payload or {}).get("cookies"):
|
||||
raise ValueError(
|
||||
"The source has cookies but the browser exported none. Run the import on the source host "
|
||||
"with the original browser and unlock its OS keychain; the existing persona was not replaced.",
|
||||
)
|
||||
if import_cookies:
|
||||
result.cookies_imported = True
|
||||
result.cookie_count = len((auth_payload or {}).get("cookies", []))
|
||||
if capture_storage:
|
||||
result.storage_captured = True
|
||||
# Keep full CDP cookie attributes (sameSite, httpOnly, partition keys)
|
||||
# as well as cookies.txt for non-browser extractors.
|
||||
result.user_agent_imported = _apply_imported_user_agent(persona, auth_payload)
|
||||
|
||||
if result.profile_copied:
|
||||
target = Path(persona.CHROME_USER_DATA_DIR)
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
staged_profile.rename(target)
|
||||
for filename in ("cookies.txt", "auth.json"):
|
||||
exported = stage / filename
|
||||
if exported.exists():
|
||||
exported.chmod(0o600)
|
||||
exported.replace(persona.path / filename)
|
||||
return result
|
||||
|
||||
|
||||
def export_profile_cookies(source: PersonaImportSource, staged_profile: Path, output: Path) -> dict:
|
||||
"""Decode with the host keychain, never by starting the user's browser."""
|
||||
import browser_cookie3
|
||||
|
||||
payload = {"TYPE": "auth", "cookies": [], "localStorage": {}, "sessionStorage": {}}
|
||||
jar = MozillaCookieJar(str(output / "cookies.txt"))
|
||||
assert source.profile_path
|
||||
for relative in (Path("Network/Cookies"), Path("Cookies")):
|
||||
original = source.profile_path / relative
|
||||
if not original.is_file():
|
||||
continue
|
||||
database = staged_profile / "Default" / relative
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
database.with_name(database.name + suffix).unlink(missing_ok=True)
|
||||
# SQLite backup includes committed WAL data even while the browser is open.
|
||||
with sqlite3.connect(f"{original.as_uri()}?mode=ro", uri=True) as reader:
|
||||
with sqlite3.connect(database) as writer:
|
||||
reader.backup(writer)
|
||||
try:
|
||||
cookies = getattr(browser_cookie3, source.browser)(
|
||||
cookie_file=str(database),
|
||||
key_file=str(staged_profile / "Local State"),
|
||||
)
|
||||
except Exception as err:
|
||||
raise ValueError(
|
||||
"Could not decrypt browser cookies. Run the import as your desktop user on the browser host "
|
||||
"with its OS keychain unlocked. Mounting macOS/Windows browser files into Linux does not provide "
|
||||
"the decryption keys. The existing persona was not replaced.",
|
||||
) from err
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(cookies)")}
|
||||
metadata_columns = [
|
||||
name
|
||||
for name in ("host_key", "path", "name", "samesite", "top_frame_site_key", "has_cross_site_ancestor")
|
||||
if name in columns
|
||||
]
|
||||
rows = {
|
||||
(row["host_key"], row["path"], row["name"]): dict(row)
|
||||
for row in connection.execute("SELECT " + ", ".join(metadata_columns) + " FROM cookies")
|
||||
}
|
||||
for cookie in cookies:
|
||||
if cookie.is_expired():
|
||||
continue
|
||||
jar.set_cookie(cookie)
|
||||
item = {
|
||||
"name": cookie.name,
|
||||
"value": cookie.value,
|
||||
"domain": cookie.domain,
|
||||
"path": cookie.path,
|
||||
"secure": bool(cookie.secure),
|
||||
"httpOnly": cookie.has_nonstandard_attr("HTTPOnly"),
|
||||
}
|
||||
if cookie.expires:
|
||||
item["expires"] = cookie.expires
|
||||
row = rows.get((cookie.domain, cookie.path, cookie.name), {})
|
||||
same_site = {0: "None", 1: "Lax", 2: "Strict"}.get(row.get("samesite"))
|
||||
if same_site:
|
||||
item["sameSite"] = same_site
|
||||
if row.get("top_frame_site_key"):
|
||||
item["partitionKey"] = {
|
||||
"topLevelSite": row["top_frame_site_key"],
|
||||
"hasCrossSiteAncestor": bool(row.get("has_cross_site_ancestor", False)),
|
||||
}
|
||||
payload["cookies"].append(item)
|
||||
break
|
||||
jar.save(ignore_discard=True, ignore_expires=False)
|
||||
(output / "auth.json").write_text(json.dumps(payload) + "\n")
|
||||
return payload
|
||||
|
||||
|
||||
def copy_browser_user_data_dir(source_dir: Path, destination_dir: Path) -> None:
|
||||
destination_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(destination_dir, ignore_errors=True)
|
||||
@@ -611,21 +820,27 @@ def export_browser_state(
|
||||
chrome_config = chrome_plugin_dir / "chrome" / "config.json"
|
||||
|
||||
env = os.environ.copy()
|
||||
if chrome_binary:
|
||||
env["CHROME_BINARY"] = str(chrome_binary)
|
||||
dependency_env = subprocess.run(
|
||||
[
|
||||
str(Path(sys.executable).with_name("abxpkg")),
|
||||
"env",
|
||||
"--install",
|
||||
"--json",
|
||||
f"--lib={get_config().ABXPKG_LIB_DIR}",
|
||||
f"--deps-from={chrome_config}:required_binaries",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
dependency_config = json.loads(chrome_config.read_text())
|
||||
dependency_config["required_binaries"] = [dep for dep in dependency_config["required_binaries"] if dep["name"] != "{CHROME_BINARY}"]
|
||||
dependency_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
with dependency_file:
|
||||
json.dump(dependency_config, dependency_file)
|
||||
try:
|
||||
dependency_env = subprocess.run(
|
||||
[
|
||||
str(Path(sys.executable).with_name("abxpkg")),
|
||||
"env",
|
||||
"--install",
|
||||
"--json",
|
||||
f"--lib={get_config().ABXPKG_LIB_DIR}",
|
||||
f"--deps-from={dependency_file.name}:required_binaries",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
finally:
|
||||
Path(dependency_file.name).unlink()
|
||||
if dependency_env.returncode != 0:
|
||||
return False, None, dependency_env.stderr.strip() or "abxpkg could not resolve browser export dependencies."
|
||||
try:
|
||||
@@ -639,6 +854,9 @@ def export_browser_state(
|
||||
if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK):
|
||||
return False, None, f"abxpkg did not resolve Node.js into {node_projection}."
|
||||
env.update({str(key): str(value) for key, value in resolved_env.items()})
|
||||
if chrome_binary:
|
||||
env["CHROME_BINARY"] = chrome_binary
|
||||
env["NODE_MODULES_DIR"] = str(abxpkg_lib_dir / "pnpm" / "packages" / "chrome" / "node_modules")
|
||||
env["NODE_BINARY"] = str(node_projection)
|
||||
env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(chrome_plugin_dir)
|
||||
|
||||
@@ -865,7 +1083,7 @@ def _apply_imported_user_agent(persona: Persona, auth_payload: dict | None) -> b
|
||||
if not auth_payload:
|
||||
return False
|
||||
|
||||
user_agent = str(auth_payload.get("user_agent") or "").strip()
|
||||
user_agent = str(auth_payload.get("user_agent") or "").strip().replace("HeadlessChrome/", "Chrome/")
|
||||
if not user_agent:
|
||||
return False
|
||||
|
||||
|
||||
@@ -45,17 +45,23 @@ VOLATILE_PROFILE_DIR_NAMES = {
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"ShaderCache",
|
||||
"Service Worker",
|
||||
"GCM Store",
|
||||
"Crashpad",
|
||||
"BrowserMetrics",
|
||||
"Sessions",
|
||||
"Sessions_Encrypted",
|
||||
}
|
||||
|
||||
VOLATILE_PROFILE_FILE_NAMES = {
|
||||
"BrowserMetrics-spare.pma",
|
||||
"RunningChromeVersion",
|
||||
"DevToolsActivePort",
|
||||
"SingletonCookie",
|
||||
"SingletonLock",
|
||||
"SingletonSocket",
|
||||
"Current Session",
|
||||
"Current Tabs",
|
||||
"Last Session",
|
||||
"Last Tabs",
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +217,7 @@ class Persona(ModelWithConfig):
|
||||
cleaned = True
|
||||
|
||||
for path in profile_paths():
|
||||
if not path.match("*.log"):
|
||||
if path.name not in {"chrome.log", "chrome_debug.log"}:
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
@@ -297,6 +303,10 @@ class Persona(ModelWithConfig):
|
||||
else:
|
||||
runtime_profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for filename in ("cookies.txt", "auth.json"):
|
||||
source = self.path / filename
|
||||
if source.is_file():
|
||||
shutil.copy2(source, runtime_root / filename)
|
||||
runtime_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cleanup_chrome_profile(runtime_profile_dir)
|
||||
|
||||
@@ -313,6 +323,11 @@ class Persona(ModelWithConfig):
|
||||
# derivation centralized in the Chrome plugin helpers.
|
||||
"PERSONAS_DIR": str(runtime_root.parent),
|
||||
"ACTIVE_PERSONA": self.name,
|
||||
**{
|
||||
key: str(runtime_root / filename)
|
||||
for key, filename in (("COOKIES_FILE", "cookies.txt"), ("AUTH_STORAGE_FILE", "auth.json"))
|
||||
if (runtime_root / filename).is_file()
|
||||
},
|
||||
}
|
||||
|
||||
def prepare_runtime_for_snapshot(self, snapshot, chrome_binary: str = "") -> dict[str, str]:
|
||||
@@ -329,6 +344,12 @@ class Persona(ModelWithConfig):
|
||||
else:
|
||||
runtime_profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for filename in ("cookies.txt", "auth.json"):
|
||||
source = self.runtime_root_for_crawl(snapshot.crawl) / filename
|
||||
if not source.is_file():
|
||||
source = self.path / filename
|
||||
if source.is_file():
|
||||
shutil.copy2(source, runtime_root / filename)
|
||||
runtime_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cleanup_chrome_profile(runtime_profile_dir)
|
||||
|
||||
@@ -343,6 +364,11 @@ class Persona(ModelWithConfig):
|
||||
# Chrome hooks and ArchiveBox-driven hooks resolve paths the same way.
|
||||
"PERSONAS_DIR": str(runtime_root.parent),
|
||||
"ACTIVE_PERSONA": self.name,
|
||||
**{
|
||||
key: str(runtime_root / filename)
|
||||
for key, filename in (("COOKIES_FILE", "cookies.txt"), ("AUTH_STORAGE_FILE", "auth.json"))
|
||||
if (runtime_root / filename).is_file()
|
||||
},
|
||||
}
|
||||
|
||||
def cleanup_runtime_for_crawl(self, crawl) -> None:
|
||||
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pluginsDir = process.env.ARCHIVEBOX_ABX_PLUGINS_DIR;
|
||||
const {ensureNodeModuleResolution} = require(path.join(pluginsDir, 'base', 'utils.js'));
|
||||
ensureNodeModuleResolution(module);
|
||||
const installShutdownHandler = require(path.join(pluginsDir, 'base', 'daemon_lifecycle.js')).captureShutdownSignals();
|
||||
const {
|
||||
acquireSessionLock,
|
||||
ensureChromeSession,
|
||||
getChromeSessionOptionsFromConfig,
|
||||
getChromeLaunchPrerequisites,
|
||||
connectToBrowserEndpoint,
|
||||
closeBrowserInChromeSession,
|
||||
} = require(path.join(pluginsDir, 'chrome', 'chrome_utils.js'));
|
||||
|
||||
async function main() {
|
||||
const config = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const outputDir = path.join(path.dirname(config.CHROME_USER_DATA_DIR), '.browser');
|
||||
fs.mkdirSync(outputDir, {recursive: true});
|
||||
const release = await acquireSessionLock(path.join(outputDir, 'launch.lock'));
|
||||
let session;
|
||||
const {puppeteer, binary} = getChromeLaunchPrerequisites();
|
||||
try {
|
||||
session = await ensureChromeSession({
|
||||
...getChromeSessionOptionsFromConfig(config),
|
||||
outputDir, puppeteer, binary,
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
const browser = await connectToBrowserEndpoint(puppeteer, session.cdpUrl, {defaultViewport: null});
|
||||
installShutdownHandler(async () => {
|
||||
if (!session.reusedExisting) {
|
||||
await closeBrowserInChromeSession({
|
||||
outputDir, puppeteer, cdpUrl: session.cdpUrl, pid: session.pid, processIsLocal: true,
|
||||
});
|
||||
}
|
||||
await browser.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
const pages = await browser.pages();
|
||||
const page = pages[0] || await browser.newPage();
|
||||
const windowSession = await page.createCDPSession();
|
||||
const {windowId} = await windowSession.send('Browser.getWindowForTarget');
|
||||
await windowSession.send('Browser.setWindowBounds', {windowId, bounds: {windowState: 'maximized'}});
|
||||
await windowSession.detach();
|
||||
await page.bringToFront();
|
||||
console.log(`Browser ready for persona ${config.ACTIVE_PERSONA || 'Default'}`);
|
||||
if (session.reusedExisting) {
|
||||
await browser.disconnect();
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => browser.once('disconnected', resolve));
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+166
-170
@@ -1,12 +1,14 @@
|
||||
__package__ = "archivebox.plugins"
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from importlib import import_module
|
||||
from pathlib import Path, PurePosixPath
|
||||
from types import ModuleType
|
||||
from typing import Any, Protocol, TypedDict
|
||||
|
||||
from abx_plugins import get_plugins_dir
|
||||
from abx_dl.catalog import PluginCatalog, PluginConfigResolver
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from archivebox.config.constants import CONSTANTS
|
||||
@@ -29,18 +31,18 @@ USER_PLUGINS_DIR = CONSTANTS.USER_PLUGINS_DIR
|
||||
|
||||
|
||||
def iter_plugin_dirs() -> list[Path]:
|
||||
"""Iterate over all built-in and user plugin directories."""
|
||||
plugin_dirs: list[Path] = []
|
||||
"""Return the exact plugin directories exposed by the shared catalog."""
|
||||
return [plugin.path for plugin in get_plugin_catalog().values()]
|
||||
|
||||
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
|
||||
if not base_dir.exists():
|
||||
continue
|
||||
|
||||
for plugin_dir in base_dir.iterdir():
|
||||
if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"):
|
||||
plugin_dirs.append(plugin_dir)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_plugin_catalog() -> PluginCatalog:
|
||||
return PluginCatalog.discover(extra_plugin_dirs=[USER_PLUGINS_DIR], runtime="archivebox")
|
||||
|
||||
return plugin_dirs
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_plugin_config_resolver() -> PluginConfigResolver:
|
||||
return PluginConfigResolver(get_plugin_catalog())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -52,25 +54,7 @@ def get_plugins() -> list[str]:
|
||||
or a standardized templates/icon.html asset. This includes non-extractor
|
||||
plugins such as binary providers and shared base plugins.
|
||||
"""
|
||||
plugins = []
|
||||
|
||||
for plugin_dir in iter_plugin_dirs():
|
||||
has_hooks = any(plugin_dir.glob("on_*__*.*"))
|
||||
has_config = (plugin_dir / "config.json").exists()
|
||||
has_icon = (plugin_dir / "templates" / "icon.html").exists()
|
||||
if has_hooks or has_config or has_icon:
|
||||
plugins.append(plugin_dir.name)
|
||||
|
||||
return sorted(set(plugins))
|
||||
|
||||
|
||||
def get_plugin_models():
|
||||
from abx_dl.models import discover_plugins
|
||||
|
||||
plugins = {}
|
||||
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
|
||||
plugins.update(discover_plugins(plugins_dir=base_dir, runtime="archivebox"))
|
||||
return plugins
|
||||
return sorted(get_plugin_catalog())
|
||||
|
||||
|
||||
def get_plugin_name(plugin: str) -> str:
|
||||
@@ -84,8 +68,141 @@ def get_plugin_name(plugin: str) -> str:
|
||||
"""
|
||||
parts = plugin.split("_", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit():
|
||||
return parts[1]
|
||||
return plugin
|
||||
plugin = parts[1]
|
||||
return get_archive_result_aliases().get(plugin, plugin)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_archive_result_aliases() -> dict[str, str]:
|
||||
aliases: dict[str, str] = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
for alias in plugin.manifest.get("archive_result_aliases") or []:
|
||||
alias = str(alias or "").strip()
|
||||
if alias:
|
||||
aliases[alias] = plugin.name
|
||||
return aliases
|
||||
|
||||
|
||||
def get_archive_result_names(plugin_name: str) -> tuple[str, ...]:
|
||||
"""Return the canonical ArchiveResult name and its declared historic aliases."""
|
||||
canonical = get_plugin_name(plugin_name)
|
||||
aliases = get_archive_result_aliases()
|
||||
return tuple(dict.fromkeys((canonical, plugin_name, *(alias for alias, target in aliases.items() if target == canonical))))
|
||||
|
||||
|
||||
def plugin_card_is_interactive(plugin_name: str) -> bool:
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
return bool(plugin and plugin.manifest.get("card_interactive"))
|
||||
|
||||
|
||||
def get_plugin_output_extension_preference(plugin_name: str) -> tuple[tuple[str, ...], ...] | None:
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
raw_groups = plugin.manifest.get("output_extension_preference") if plugin else None
|
||||
if not isinstance(raw_groups, list):
|
||||
return None
|
||||
groups = []
|
||||
for raw_group in raw_groups:
|
||||
if not isinstance(raw_group, list):
|
||||
continue
|
||||
group = tuple(str(extension).lower() for extension in raw_group if str(extension).startswith("."))
|
||||
if group:
|
||||
groups.append(group)
|
||||
return tuple(groups) or None
|
||||
|
||||
|
||||
def get_plugin_default_output_path(plugin_name: str) -> str | None:
|
||||
prefix, separator, base_name = plugin_name.partition("_")
|
||||
archive_result_name = base_name if separator and prefix.isdigit() else plugin_name
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
raw_path = str(plugin.manifest.get("default_output_path") or "").strip() if plugin else ""
|
||||
path = PurePosixPath(raw_path)
|
||||
if not raw_path or raw_path.startswith("/") or ".." in path.parts or len(path.parts) != 1:
|
||||
return None
|
||||
return f"{archive_result_name}/{raw_path}"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_snapshot_thumbnail_card_order() -> dict[str, int]:
|
||||
"""Return plugin-owned ArchiveResult names eligible for snapshot thumbnails."""
|
||||
ordering: dict[str, int] = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
declarations = plugin.manifest.get("snapshot_thumbnail_cards") or []
|
||||
if not isinstance(declarations, list):
|
||||
continue
|
||||
for declaration in declarations:
|
||||
if not isinstance(declaration, dict):
|
||||
continue
|
||||
result_name = str(declaration.get("archive_result_name") or "").strip()
|
||||
order = declaration.get("order")
|
||||
if not result_name or isinstance(order, bool) or not isinstance(order, int):
|
||||
continue
|
||||
ordering[result_name] = order
|
||||
return ordering
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_snapshot_role_names(role: str) -> tuple[str, ...]:
|
||||
"""Return ArchiveResult names for a plugin-owned snapshot presentation role."""
|
||||
manifest_key = f"snapshot_{role}"
|
||||
names = []
|
||||
for plugin in get_plugin_catalog().values():
|
||||
if plugin.manifest.get(manifest_key):
|
||||
names.extend(get_archive_result_names(plugin.name))
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def get_snapshot_role_result(results, role: str):
|
||||
"""Select the first successful result declared for a presentation role."""
|
||||
results_by_name = {result.plugin: result for result in results if result.status == "succeeded"}
|
||||
return next((results_by_name[name] for name in get_snapshot_role_names(role) if name in results_by_name), None)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_plugin_presentation_modules() -> tuple[ModuleType, ...]:
|
||||
"""Load optional plugin-owned presentation hooks declared in manifests."""
|
||||
return tuple(module for plugin in get_plugin_catalog().values() if (module := get_plugin_presentation_module(plugin.name)) is not None)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_plugin_presentation_module(plugin_name: str) -> ModuleType | None:
|
||||
"""Load one plugin's optional presentation hooks module."""
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
module_name = str(plugin.manifest.get("presentation_module") or "").strip() if plugin else ""
|
||||
return import_module(module_name) if module_name else None
|
||||
|
||||
|
||||
def plugin_has_custom_full_response(plugin_name: str) -> bool:
|
||||
"""Return whether a plugin renders its full template through a response hook."""
|
||||
module = get_plugin_presentation_module(plugin_name)
|
||||
return bool(module and getattr(module, "render_full_response", None))
|
||||
|
||||
|
||||
def get_extra_snapshot_output_cards(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Collect additional cards from plugin-owned presentation hooks."""
|
||||
cards = []
|
||||
for module in get_plugin_presentation_modules():
|
||||
hook = getattr(module, "extra_snapshot_output_cards", None)
|
||||
if hook is not None:
|
||||
cards.extend(hook(outputs) or ())
|
||||
return cards
|
||||
|
||||
|
||||
def serve_plugin_replay_asset(path: str, config, response_class):
|
||||
"""Give plugin presentation hooks the first chance to serve replay assets."""
|
||||
for module in get_plugin_presentation_modules():
|
||||
hook = getattr(module, "serve_replay_asset_response", None)
|
||||
if hook is not None and (response := hook(path, config, response_class)) is not None:
|
||||
return response
|
||||
return None
|
||||
|
||||
|
||||
def render_plugin_full_response(path: str, output_url: str, **kwargs):
|
||||
"""Let plugin presentation hooks render a saved output's full template."""
|
||||
for module in get_plugin_presentation_modules():
|
||||
render = getattr(module, "render_full_response", None)
|
||||
if render is not None and (response := render(path, output_url, **kwargs)) is not None:
|
||||
return response
|
||||
return None
|
||||
|
||||
|
||||
def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any) -> list[str]:
|
||||
@@ -99,91 +216,17 @@ def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any
|
||||
|
||||
config = get_config(**config_kwargs)
|
||||
|
||||
enabled = []
|
||||
disabled = []
|
||||
for plugin in get_plugins():
|
||||
plugin_config = get_plugin_special_config(plugin, config)
|
||||
if plugin_config["enabled"]:
|
||||
enabled.append(plugin)
|
||||
else:
|
||||
disabled.append(plugin)
|
||||
|
||||
from abx_dl.models import filter_plugins
|
||||
|
||||
return list(filter_plugins(get_plugin_models(), enabled, include_providers=True, disabled_names=disabled))
|
||||
return get_plugin_config_resolver().enabled_plugin_names_from_flat(dict(config.items()))
|
||||
|
||||
|
||||
def discover_plugins_that_provide_interface(
|
||||
module_name: str,
|
||||
required_attrs: list[str],
|
||||
plugin_prefix: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Discover plugins that provide a specific Python module with required interface.
|
||||
|
||||
This enables dynamic plugin discovery for features like search backends,
|
||||
storage backends, etc. without hardcoding imports.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
backends = {}
|
||||
|
||||
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
|
||||
if not base_dir.exists():
|
||||
continue
|
||||
|
||||
for plugin_dir in base_dir.iterdir():
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
|
||||
plugin_name = plugin_dir.name
|
||||
if plugin_prefix and not plugin_name.startswith(plugin_prefix):
|
||||
continue
|
||||
|
||||
module_path = plugin_dir / f"{module_name}.py"
|
||||
if not module_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"archivebox.dynamic_plugins.{plugin_name}.{module_name}",
|
||||
module_path,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not all(attr in vars(module) for attr in required_attrs):
|
||||
continue
|
||||
|
||||
if plugin_prefix:
|
||||
backend_name = plugin_name[len(plugin_prefix) :]
|
||||
else:
|
||||
backend_name = plugin_name
|
||||
|
||||
backends[backend_name] = module
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return backends
|
||||
|
||||
|
||||
def get_search_backends() -> dict[str, Any]:
|
||||
"""
|
||||
Discover all available search backend plugins.
|
||||
|
||||
Search backends must provide a search.py module with:
|
||||
- search(query: str) -> List[str] (returns snapshot IDs)
|
||||
- flush(snapshot_ids: Iterable[str]) -> None
|
||||
"""
|
||||
return discover_plugins_that_provide_interface(
|
||||
module_name="search",
|
||||
required_attrs=["search", "flush"],
|
||||
plugin_prefix="search_backend_",
|
||||
)
|
||||
def get_search_backends():
|
||||
"""Return plugins that declare both standalone search commands."""
|
||||
catalog = get_plugin_catalog()
|
||||
return {
|
||||
plugin.name.removeprefix("search_backend_"): plugin
|
||||
for plugin in catalog.values()
|
||||
if catalog.command(plugin.name, "search") is not None and catalog.command(plugin.name, "flush") is not None
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -196,33 +239,7 @@ def discover_plugin_configs() -> dict[str, dict[str, Any]]:
|
||||
schemas are plugin package metadata, not live user config; runtime values
|
||||
still come from env/db config at each callsite.
|
||||
"""
|
||||
configs = {}
|
||||
|
||||
for plugin_dir in iter_plugin_dirs():
|
||||
config_path = plugin_dir / "config.json"
|
||||
if not config_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
schema = json.load(f)
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
if schema.get("type") != "object":
|
||||
continue
|
||||
if "properties" not in schema:
|
||||
continue
|
||||
|
||||
configs[plugin_dir.name] = schema
|
||||
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
import sys
|
||||
|
||||
print(f"Warning: Failed to load config.json from {plugin_dir.name}: {e}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
return configs
|
||||
return get_plugin_config_resolver().schemas
|
||||
|
||||
|
||||
def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited: set[str] | None = None) -> PluginSpecialConfig:
|
||||
@@ -234,26 +251,7 @@ def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited:
|
||||
- {PLUGIN}_TIMEOUT: Plugin-specific timeout (fallback to TIMEOUT, default 300)
|
||||
- {PLUGIN}_BINARY: Primary binary path (default to plugin_name)
|
||||
"""
|
||||
plugin_upper = plugin_name.upper()
|
||||
|
||||
enabled_key = f"{plugin_upper}_ENABLED"
|
||||
enabled = config.get(enabled_key)
|
||||
if enabled is None:
|
||||
enabled = True
|
||||
elif isinstance(enabled, str):
|
||||
enabled = enabled.lower() not in ("false", "0", "no", "")
|
||||
|
||||
timeout_key = f"{plugin_upper}_TIMEOUT"
|
||||
timeout = config.get(timeout_key) or config.get("TIMEOUT", 300)
|
||||
|
||||
binary_key = f"{plugin_upper}_BINARY"
|
||||
binary = config.get(binary_key, plugin_name)
|
||||
|
||||
return {
|
||||
"enabled": bool(enabled),
|
||||
"timeout": int(timeout),
|
||||
"binary": str(binary),
|
||||
}
|
||||
return get_plugin_config_resolver().runtime_settings(plugin_name, dict(config.items()))
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES = {
|
||||
@@ -288,18 +286,16 @@ def get_plugin_template(plugin: str, template_name: str, fallback: bool = True)
|
||||
|
||||
Args:
|
||||
plugin: Plugin name (e.g., 'screenshot', '15_singlefile')
|
||||
template_name: One of 'icon', 'card', 'full'
|
||||
template_name: A template slot such as 'icon', 'card', or 'full'.
|
||||
fallback: If True, return default template if plugin template not found
|
||||
"""
|
||||
base_name = get_plugin_name(plugin)
|
||||
if base_name in ("yt-dlp", "youtube-dl"):
|
||||
base_name = "ytdlp"
|
||||
|
||||
for plugin_dir in iter_plugin_dirs():
|
||||
if plugin_dir.name == base_name or plugin_dir.name.endswith(f"_{base_name}"):
|
||||
template_path = plugin_dir / "templates" / f"{template_name}.html"
|
||||
if template_path.exists():
|
||||
return template_path.read_text()
|
||||
catalog = get_plugin_catalog()
|
||||
if base_name in catalog:
|
||||
template_path = catalog.template_path(base_name, template_name)
|
||||
if template_path is not None:
|
||||
return template_path.read_text()
|
||||
|
||||
if fallback:
|
||||
return DEFAULT_TEMPLATES.get(template_name, "")
|
||||
|
||||
+21
-138
@@ -11,133 +11,19 @@ from django.utils.html import format_html
|
||||
|
||||
from archivebox.config import CONSTANTS_CONFIG
|
||||
from archivebox.config.common import ArchiveBoxConfig, get_config
|
||||
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins
|
||||
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_catalog, get_plugin_icon, get_plugins
|
||||
|
||||
|
||||
PLUGIN_CONFIG_FIELD_PREFIX = "plugin_config__"
|
||||
PLUGIN_GROUP_DEFINITIONS = (
|
||||
(
|
||||
"main_plugins",
|
||||
"Main",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"dom",
|
||||
"screenshot",
|
||||
"pdf",
|
||||
"singlefile",
|
||||
"wget",
|
||||
"archivedotorg",
|
||||
"chrome_mhtml",
|
||||
"archivewebpage",
|
||||
),
|
||||
),
|
||||
(
|
||||
"page_setup_plugins",
|
||||
"Page Setup",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"chrome",
|
||||
"infiniscroll",
|
||||
"modalcloser",
|
||||
"ublock",
|
||||
"istilldontcareaboutcookies",
|
||||
"twocaptcha",
|
||||
"claudechrome",
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_plugins",
|
||||
"Media",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"staticfile",
|
||||
"responses",
|
||||
"chrome_screencast",
|
||||
"ytdlp",
|
||||
"gallerydl",
|
||||
"git",
|
||||
),
|
||||
),
|
||||
(
|
||||
"text_plugins",
|
||||
"Text",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"readability",
|
||||
"htmltotext",
|
||||
"defuddle",
|
||||
"forumdl",
|
||||
"mercury",
|
||||
"trafilatura",
|
||||
"liteparse",
|
||||
"opendataloader",
|
||||
"papersdl",
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata_plugins",
|
||||
"Metadata",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"title",
|
||||
"favicon",
|
||||
"headers",
|
||||
"redirects",
|
||||
"accessibility",
|
||||
"consolelog",
|
||||
"sslcerts",
|
||||
"dns",
|
||||
"seo",
|
||||
"hashes",
|
||||
),
|
||||
),
|
||||
(
|
||||
"postprocessing_plugins",
|
||||
"Postprocessing",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
(
|
||||
"parse_dom_outlinks",
|
||||
"parse_html_urls",
|
||||
"parse_jsonl_urls",
|
||||
"parse_netscape_urls",
|
||||
"parse_rss_urls",
|
||||
"parse_txt_urls",
|
||||
"claudecode",
|
||||
"claudecodecleanup",
|
||||
"claudecodeextract",
|
||||
),
|
||||
),
|
||||
PLUGIN_GROUPS = (
|
||||
("main", "main_plugins", "Main"),
|
||||
("page_setup", "page_setup_plugins", "Page Setup"),
|
||||
("media", "media_plugins", "Media"),
|
||||
("text", "text_plugins", "Text"),
|
||||
("metadata", "metadata_plugins", "Metadata"),
|
||||
("postprocessing", "postprocessing_plugins", "Postprocessing"),
|
||||
("other", "other_plugins", "Other"),
|
||||
)
|
||||
HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = {
|
||||
"apt",
|
||||
"base",
|
||||
"bash",
|
||||
"brew",
|
||||
"cargo",
|
||||
"chromewebstore",
|
||||
"env",
|
||||
"media",
|
||||
"npm",
|
||||
"opencode",
|
||||
"pip",
|
||||
"puppeteer",
|
||||
"search_backend_ripgrep",
|
||||
"search_backend_sonic",
|
||||
"search_backend_sqlite",
|
||||
"ssl",
|
||||
}
|
||||
TIMEOUT_INPUT_PATTERN = r"(0|[1-9][0-9]*|[0-9]+(?:\.[0-9]+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))"
|
||||
|
||||
|
||||
@@ -255,26 +141,21 @@ class PluginConfigFormMixin:
|
||||
allow_crawl_execution_config_fields = True
|
||||
|
||||
def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None:
|
||||
all_plugins = get_plugins()
|
||||
catalog = get_plugin_catalog()
|
||||
all_plugins = set(catalog)
|
||||
plugin_configs = discover_plugin_configs()
|
||||
runtime_config = runtime_config or get_config()
|
||||
self.plugin_config_binary_urls = get_plugin_config_binary_urls(runtime_config)
|
||||
grouped_plugins = set().union(*(group[-1] for group in PLUGIN_GROUP_DEFINITIONS))
|
||||
other_plugins = tuple(sorted(set(all_plugins) - grouped_plugins - HIDDEN_PLUGIN_CONFIG_UI_PLUGINS))
|
||||
grouped_plugins = catalog.groups()
|
||||
|
||||
for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS:
|
||||
group_specs = []
|
||||
for category, field_name, title in PLUGIN_GROUPS:
|
||||
plugin_names = tuple(plugin.name for plugin in grouped_plugins.get(category, []))
|
||||
group_specs.append((field_name, title, "", "", "", plugin_names))
|
||||
if field_name in self.fields:
|
||||
get_choice_field(self, field_name).choices = [
|
||||
(p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins
|
||||
]
|
||||
|
||||
if "other_plugins" in self.fields:
|
||||
get_choice_field(self, "other_plugins").choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in other_plugins]
|
||||
|
||||
group_specs = (
|
||||
*PLUGIN_GROUP_DEFINITIONS,
|
||||
("other_plugins", "Other", "", "", "", other_plugins),
|
||||
)
|
||||
binary_url_lookup = _build_required_binary_url_lookup(plugin_configs, runtime_config)
|
||||
self.plugin_groups = [
|
||||
{
|
||||
@@ -332,7 +213,7 @@ class PluginConfigFormMixin:
|
||||
"enabled_config_key": enabled_config_key,
|
||||
"description": str(schema.get("description") or "").strip(),
|
||||
"source_url": f"https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/{plugin_name}",
|
||||
"docs_url": f"https://archivebox.github.io/abx-plugins/#{plugin_name}",
|
||||
"docs_url": f"https://plugins.archivebox.io/#{plugin_name}",
|
||||
"required_plugins": [str(item) for item in schema.get("required_plugins") or []],
|
||||
"required_binary_links": _build_required_binary_links(
|
||||
schema.get("required_binaries") or [],
|
||||
@@ -395,7 +276,7 @@ class PluginConfigFormMixin:
|
||||
elif is_sensitive:
|
||||
input_type = "password"
|
||||
else:
|
||||
input_value = "" if is_sensitive else str(current_value)
|
||||
input_value = "" if current_value is None else str(current_value)
|
||||
|
||||
return {
|
||||
"key": config_key,
|
||||
@@ -409,7 +290,7 @@ class PluginConfigFormMixin:
|
||||
"default": _jsonish(default_display),
|
||||
"current": "configured"
|
||||
if is_sensitive and current_value
|
||||
else (str(current_value) if "string" in schema_types else _jsonish(current_value)),
|
||||
else (("" if current_value is None else str(current_value)) if "string" in schema_types else _jsonish(current_value)),
|
||||
"current_url": self.plugin_config_binary_urls.get(config_key, "") if str(config_key).endswith("_BINARY") else "",
|
||||
"is_sensitive": is_sensitive,
|
||||
"minimum": prop_schema.get("minimum"),
|
||||
@@ -461,6 +342,8 @@ class PluginConfigFormMixin:
|
||||
continue
|
||||
|
||||
base_value = effective_config.get(config_key, prop_schema.get("default", ""))
|
||||
if base_value is None and coerced_value == "":
|
||||
continue
|
||||
if _same_config_value(coerced_value, base_value):
|
||||
continue
|
||||
|
||||
|
||||
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user