Compare commits
641
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6360c253f | ||
|
|
eaacfa697c | ||
|
|
3b99be7fcf | ||
|
|
07d0280307 | ||
|
|
4fe2b2b7ab | ||
|
|
e5412bfe5a | ||
|
|
916c57b42e | ||
|
|
2e6794dd9c | ||
|
|
6c67977ad6 | ||
|
|
03c6f6a311 | ||
|
|
1f710263c5 | ||
|
|
cab03ba544 | ||
|
|
2d128fdf4b | ||
|
|
dc1455ccf3 | ||
|
|
976b6e5b7d | ||
|
|
709cd07eab | ||
|
|
75dcce89dd | ||
|
|
ae82f09d1a | ||
|
|
8270556ccb | ||
|
|
3502cf4312 | ||
|
|
bd11f9541f | ||
|
|
7f326aa57d | ||
|
|
1ed926bcd8 | ||
|
|
f4c30f5b74 | ||
|
|
8362d35549 | ||
|
|
a786e79fcd | ||
|
|
29fbc9c4df | ||
|
|
d8e77d5097 | ||
|
|
4b301730cd | ||
|
|
97061fc849 | ||
|
|
1ea9315492 | ||
|
|
22c30a4f31 | ||
|
|
da0a6d2221 | ||
|
|
9315887f1d | ||
|
|
d4faa4a034 | ||
|
|
dc4d75ff2f | ||
|
|
00de9bbcbb | ||
|
|
5b25b39f7a | ||
|
|
5e069e6e1f | ||
|
|
acabe3fbd6 | ||
|
|
d0eb7c60e5 | ||
|
|
7f0298e1f4 | ||
|
|
4ee4613627 | ||
|
|
489f0a1b64 | ||
|
|
1045a0e7e8 | ||
|
|
93b70398e3 | ||
|
|
5992353dca | ||
|
|
fa3af93cf0 | ||
|
|
05a43c2c9d | ||
|
|
33797e3d6a | ||
|
|
cc6e208171 | ||
|
|
b6cfe542a5 | ||
|
|
ac6ce915b1 | ||
|
|
c7a766fb98 | ||
|
|
cdb0a3b582 | ||
|
|
9a85d8dfa2 | ||
|
|
34e4211383 | ||
|
|
43edc66452 | ||
|
|
5f44ca29a9 | ||
|
|
048fd4113a | ||
|
|
e7da938f0b | ||
|
|
f4123e80c9 | ||
|
|
b035561b85 | ||
|
|
86c30d955c | ||
|
|
f9a0cab649 | ||
|
|
813a93109b | ||
|
|
19c3239bad | ||
|
|
1b795c6c07 | ||
|
|
b9bad0abae | ||
|
|
1c0335c131 | ||
|
|
861eb0f607 | ||
|
|
5f8bd00a69 | ||
|
|
c20b875736 | ||
|
|
5e8bea8d6a | ||
|
|
b167d889b5 | ||
|
|
5a6d78566d | ||
|
|
25cb823a43 | ||
|
|
c7402195e4 | ||
|
|
29a941bd9f | ||
|
|
224da46da5 | ||
|
|
396ba92672 | ||
|
|
4ff3aa3061 | ||
|
|
62b6cf80da | ||
|
|
5fe08ce366 | ||
|
|
b2d6c5a99a | ||
|
|
bfb36aee49 | ||
|
|
04a9c98e6a | ||
|
|
815bf05e07 | ||
|
|
1e3623fee3 | ||
|
|
6c83d7e23c | ||
|
|
c1af5599a5 | ||
|
|
c771cc692b | ||
|
|
8248b786f0 | ||
|
|
2b970f6538 | ||
|
|
81f1666cea | ||
|
|
f428401e80 | ||
|
|
e848dc9e20 | ||
|
|
4b10536869 | ||
|
|
9f0c584518 | ||
|
|
8819b41aed | ||
|
|
34c411344b | ||
|
|
4cffbaea46 | ||
|
|
55dede4cd8 | ||
|
|
f27cf9d5a5 | ||
|
|
c50d974be5 | ||
|
|
3f169fe098 | ||
|
|
3204eb45ae | ||
|
|
a48b6e955a | ||
|
|
502d11a519 | ||
|
|
afa3f7f5a1 | ||
|
|
d1338cc421 | ||
|
|
6cea29d124 | ||
|
|
090dc01d3c | ||
|
|
1c11fe234f | ||
|
|
fdc77a1329 | ||
|
|
869986834b | ||
|
|
ac0f61b372 | ||
|
|
555e0b42d3 | ||
|
|
148713fb19 | ||
|
|
6750823d4d | ||
|
|
c17fe1db06 | ||
|
|
8bbe464028 | ||
|
|
a67071af2b | ||
|
|
b39ce2a315 | ||
|
|
b25c6fa093 | ||
|
|
c965f790f9 | ||
|
|
d276887f00 | ||
|
|
976156141a | ||
|
|
6443f33d03 | ||
|
|
ef2745122a | ||
|
|
5e6adafc0e | ||
|
|
bf22d15de2 | ||
|
|
2bb48cbef0 | ||
|
|
28c210a829 | ||
|
|
2bd63b91af | ||
|
|
4198ca64f6 | ||
|
|
a44e2fa36d | ||
|
|
89c58d4bbb | ||
|
|
3c02fa9b13 | ||
|
|
0e21ec21bc | ||
|
|
ce7b437ee8 | ||
|
|
77b9db47b3 | ||
|
|
dd83efa085 | ||
|
|
ebf7ff428a | ||
|
|
04af3ad85d | ||
|
|
ba50085fa0 | ||
|
|
9a8490aee8 | ||
|
|
6480f9685c | ||
|
|
6b34cc2af4 | ||
|
|
acfa68dc47 | ||
|
|
3bb8a07606 | ||
|
|
98e881f748 | ||
|
|
da36e63449 | ||
|
|
b1144006f6 | ||
|
|
ade28c1ae4 | ||
|
|
dd75dd928a | ||
|
|
89a85e5780 | ||
|
|
9fcc433e2e | ||
|
|
20a100637b | ||
|
|
33504e7256 | ||
|
|
d26fc3f7f1 | ||
|
|
faed46986b | ||
|
|
138ae70667 | ||
|
|
6f83a86bcc | ||
|
|
5f43ec34c8 | ||
|
|
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 | ||
|
|
3830544e13 | ||
|
|
913542c19f | ||
|
|
f603a0dfff | ||
|
|
5e47e055a2 | ||
|
|
d1136d74cb | ||
|
|
54f598789e | ||
|
|
5171eaf31e |
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Stable releases require live dev acceptance
|
||||
|
||||
The order is **dev CI → dev image publication → automatic Cabbage deployment →
|
||||
real capture and browser acceptance → stable publication**. Never
|
||||
substitute a published PyPI wheel, healthy container, or local build for this
|
||||
acceptance. The release workflow blocks *before* a stable upload unless Cabbage
|
||||
has successful evidence from `staging-acceptance.yml` matching all tracked
|
||||
source and dependency pins. Only ArchiveBox's own version fields may change
|
||||
between dev and stable.
|
||||
|
||||
The staging workflow observes the existing image watcher; it cannot deploy or
|
||||
restart the application. On Cabbage, install `bin/staging-acceptance-host.sh`
|
||||
as root-owned mode 0755 `/usr/local/sbin/archivebox-staging-acceptance`. Its hash
|
||||
is checked against the candidate. A dedicated SSH key must use
|
||||
`restrict,command="/usr/local/sbin/archivebox-staging-acceptance"` in authorized
|
||||
keys. It accepts only `inspect <source-sha>` and `capture <source-sha>`; it cannot
|
||||
run caller-provided commands or archive arbitrary URLs.
|
||||
|
||||
The `staging` GitHub environment is restricted to dev/main branches. It contains
|
||||
`STAGING_SSH_KEY` and `STAGING_KNOWN_HOSTS`, with independently verified host keys.
|
||||
Only trusted release events use the private ugNAS runner route. Never expose
|
||||
these credentials to PR jobs or copy an operator's existing root key.
|
||||
|
||||
Acceptance creates one tagged background crawl per source through the normal
|
||||
add/runner path, enabling every snapshot extractor, including TLSNotary. It
|
||||
checks installed pins, completed outputs and their files, unexpected/missing
|
||||
plugin results, host OOM kills, and real rendered snapshot pages. For this
|
||||
release, the explicitly accepted TLSNotary failures and exact missing-Claude-auth
|
||||
diagnostics are retained in the report's `limitations` list; they do not disable
|
||||
those plugins or excuse missing/truncated successful outputs. Remove the
|
||||
TLSNotary exception when its follow-up fix ships. Logs, result inventories and
|
||||
screenshots are retained as Actions artifacts. Other failures remain
|
||||
failures; do not publish a success deployment record to bypass them. No code
|
||||
mounts are allowed. The Cabbage job must pass before its record authorizes a
|
||||
stable upload. Queued capture progress is polled; failed tests are not retried.
|
||||
@@ -0,0 +1,3 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- ugnas-linux
|
||||
@@ -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()
|
||||
@@ -1,8 +1,54 @@
|
||||
/* Shared ArchiveBox site chrome. Source: ArchiveBox/monorepo/site-chrome. */
|
||||
/*
|
||||
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: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; letter-spacing: normal; text-align: left; }
|
||||
.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; }
|
||||
@@ -63,7 +109,7 @@ html { scroll-behavior: smooth; scroll-padding-top: clamp(96px, 25vh, 240px); }
|
||||
.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 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.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; }
|
||||
@@ -107,4 +153,4 @@ html { scroll-behavior: smooth; scroll-padding-top: clamp(96px, 25vh, 240px); }
|
||||
|
||||
.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: translateY(-7px) rotate(0deg) scale(1.12); } }
|
||||
@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)
|
||||
@@ -1,30 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover every ArchiveBox test file and expose it as one CI job."""
|
||||
"""Discover every test once, batching measured short files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path.cwd().resolve()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
archivebox_tests = sorted((root / "archivebox/tests").glob("test_*.py"))
|
||||
|
||||
if not archivebox_tests:
|
||||
raise SystemExit("No ArchiveBox tests discovered")
|
||||
|
||||
matrix: list[dict[str, object]] = []
|
||||
# These are observed whole-job seconds, including setup. Unknown/long files,
|
||||
# optional dependencies and hosted-only requirements keep independent jobs.
|
||||
durations = json.loads((root / ".github/test-durations.json").read_text())["seconds"]
|
||||
hosted = {path for path in archivebox_tests if "# ci-runner: hosted" in path.read_text().splitlines()[:5]}
|
||||
requested = os.environ.get("TEST_FILE", "")
|
||||
batches: list[list[str]] = []
|
||||
batch: list[str] = []
|
||||
batch_seconds = 0
|
||||
for path in archivebox_tests:
|
||||
test_path = path.relative_to(root).as_posix()
|
||||
duration = durations.get(test_path, 60)
|
||||
if duration >= 60 or path in hosted or path.stem == "test_auth_ldap" or test_path == requested:
|
||||
batches.append([test_path])
|
||||
continue
|
||||
if batch and (len(batch) == 8 or batch_seconds + duration > 180):
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
batch_seconds = 0
|
||||
batch.append(test_path)
|
||||
batch_seconds += duration
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
|
||||
matrix: list[dict[str, object]] = []
|
||||
for paths in batches:
|
||||
matrix.append(
|
||||
{
|
||||
"name": f"main/{path.stem}",
|
||||
"paths": [test_path],
|
||||
"paths_arg": test_path,
|
||||
"extra": "ldap" if test_path.endswith("/test_auth_ldap.py") else "",
|
||||
"count": 1,
|
||||
"name": f"main/{Path(paths[0]).stem}" if len(paths) == 1 else f"batch/{Path(paths[0]).stem}",
|
||||
"paths": paths,
|
||||
"paths_arg": " ".join(paths),
|
||||
"extra": "ldap" if paths == ["archivebox/tests/test_auth_ldap.py"] else "",
|
||||
"count": len(paths),
|
||||
"ugnas": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Ordinary Linux tests can use either pool. Only genuine hosted-only
|
||||
# requirements need a file header; filenames never enter runner policy.
|
||||
# Keep a bounded NAS share so hosted capacity continues working in parallel.
|
||||
capacity = int(os.environ.get("UGNAS_CI_MAX_JOBS", "3"))
|
||||
# Start long jobs first in both pools instead of leaving several minutes of
|
||||
# work behind every short job in an alphabetically ordered hosted queue.
|
||||
matrix.sort(key=lambda item: sum(durations.get(path, 60) for path in item["paths"]), reverse=True)
|
||||
eligible = [item for item in matrix if not any(root / path in hosted for path in item["paths"])]
|
||||
for item in eligible[:capacity]:
|
||||
item["ugnas"] = True
|
||||
|
||||
discovered_paths = [path for entry in matrix for path in entry["paths"]]
|
||||
if len(discovered_paths) != len(set(discovered_paths)):
|
||||
raise SystemExit("Tests were not discovered exactly once")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"source_runs": [
|
||||
"https://github.com/ArchiveBox/ArchiveBox/actions/runs/36302511923",
|
||||
"https://github.com/ArchiveBox/ArchiveBox/actions/runs/36353196552"
|
||||
],
|
||||
"measurement": "Whole job seconds, including setup and excluding queue time. Unbatched files use the latest complete successful run; batched files retain their individual baseline measurement. Unknown files stay independent.",
|
||||
"seconds": {
|
||||
"archivebox/tests/test_api_v1_auth_browser_session.py": 29,
|
||||
"archivebox/tests/test_api_v1_auth_check_api_token.py": 27,
|
||||
"archivebox/tests/test_api_v1_auth_get_api_token.py": 24,
|
||||
"archivebox/tests/test_api_v1_cli_add.py": 96,
|
||||
"archivebox/tests/test_api_v1_cli_remove.py": 50,
|
||||
"archivebox/tests/test_api_v1_cli_schedule.py": 41,
|
||||
"archivebox/tests/test_api_v1_cli_search.py": 26,
|
||||
"archivebox/tests/test_api_v1_cli_update.py": 100,
|
||||
"archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py": 64,
|
||||
"archivebox/tests/test_api_v1_core_any_id.py": 21,
|
||||
"archivebox/tests/test_api_v1_core_archiveresult_archiveresult_id.py": 55,
|
||||
"archivebox/tests/test_api_v1_core_archiveresults.py": 32,
|
||||
"archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py": 203,
|
||||
"archivebox/tests/test_api_v1_core_snapshots_rss.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_snapshots.py": 28,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_rename.py": 23,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_snapshots_jsonl.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_urls_txt.py": 28,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tags_add_to_snapshot.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tags_autocomplete.py": 26,
|
||||
"archivebox/tests/test_api_v1_core_tags_create.py": 21,
|
||||
"archivebox/tests/test_api_v1_core_tags_remove_from_snapshot.py": 25,
|
||||
"archivebox/tests/test_api_v1_core_tags_search.py": 26,
|
||||
"archivebox/tests/test_api_v1_core_tags.py": 25,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_filename.py": 22,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_filename.py": 25,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_subfolder_filename.py": 24,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py": 86,
|
||||
"archivebox/tests/test_api_v1_crawls_crawls.py": 21,
|
||||
"archivebox/tests/test_api_v1_machine_binaries.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_binary_binary_id.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_binary_by_name_name.py": 26,
|
||||
"archivebox/tests/test_api_v1_machine_machine_current.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_machine_machine_id.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_machines.py": 27,
|
||||
"archivebox/tests/test_api_v1_personas_personas.py": 23,
|
||||
"archivebox/tests/test_api_v1_personas_sync.py": 29,
|
||||
"archivebox/tests/test_api_v1_workflow_core_token_auth_side_effects.py": 36,
|
||||
"archivebox/tests/test_api_v1_workflow_frozen_crawl_config_sources.py": 33,
|
||||
"archivebox/tests/test_archive_result_service.py": 122,
|
||||
"archivebox/tests/test_auth_ldap.py": 44,
|
||||
"archivebox/tests/test_binary_service.py": 83,
|
||||
"archivebox/tests/test_cli_add_1.py": 179,
|
||||
"archivebox/tests/test_cli_add_2.py": 167,
|
||||
"archivebox/tests/test_cli_add_3.py": 194,
|
||||
"archivebox/tests/test_cli_add_4.py": 168,
|
||||
"archivebox/tests/test_cli_archiveresult.py": 145,
|
||||
"archivebox/tests/test_cli_binary.py": 20,
|
||||
"archivebox/tests/test_cli_config.py": 141,
|
||||
"archivebox/tests/test_cli_crawl.py": 127,
|
||||
"archivebox/tests/test_cli_extract_input.py": 205,
|
||||
"archivebox/tests/test_cli_extract.py": 113,
|
||||
"archivebox/tests/test_cli_help.py": 30,
|
||||
"archivebox/tests/test_cli_init.py": 133,
|
||||
"archivebox/tests/test_cli_install.py": 58,
|
||||
"archivebox/tests/test_cli_list.py": 177,
|
||||
"archivebox/tests/test_cli_machine.py": 50,
|
||||
"archivebox/tests/test_cli_manage.py": 48,
|
||||
"archivebox/tests/test_cli_mcp.py": 51,
|
||||
"archivebox/tests/test_cli_oneshot.py": 23,
|
||||
"archivebox/tests/test_cli_persona.py": 82,
|
||||
"archivebox/tests/test_cli_piping.py": 97,
|
||||
"archivebox/tests/test_cli_pluginmap.py": 21,
|
||||
"archivebox/tests/test_cli_process.py": 20,
|
||||
"archivebox/tests/test_cli_remove.py": 150,
|
||||
"archivebox/tests/test_cli_run_1.py": 165,
|
||||
"archivebox/tests/test_cli_run_2.py": 241,
|
||||
"archivebox/tests/test_cli_run_3.py": 162,
|
||||
"archivebox/tests/test_cli_run_4.py": 200,
|
||||
"archivebox/tests/test_cli_schedule.py": 87,
|
||||
"archivebox/tests/test_cli_search.py": 35,
|
||||
"archivebox/tests/test_cli_server.py": 251,
|
||||
"archivebox/tests/test_cli_shell.py": 32,
|
||||
"archivebox/tests/test_cli_snapshot.py": 138,
|
||||
"archivebox/tests/test_cli_status.py": 105,
|
||||
"archivebox/tests/test_cli_tag.py": 21,
|
||||
"archivebox/tests/test_cli_update_reindex_snapshots.py": 144,
|
||||
"archivebox/tests/test_cli_update.py": 148,
|
||||
"archivebox/tests/test_cli_version.py": 181,
|
||||
"archivebox/tests/test_config_DELETE_AFTER.py": 192,
|
||||
"archivebox/tests/test_config_MAX_limits.py": 34,
|
||||
"archivebox/tests/test_config_ONLY_NEW.py": 24,
|
||||
"archivebox/tests/test_config_SAVE_TITLE.py": 99,
|
||||
"archivebox/tests/test_config_URL_filters.py": 26,
|
||||
"archivebox/tests/test_core_config.py": 20,
|
||||
"archivebox/tests/test_core_forms.py": 28,
|
||||
"archivebox/tests/test_crawl_pause.py": 20,
|
||||
"archivebox/tests/test_crawl_runner.py": 45,
|
||||
"archivebox/tests/test_crawl_service.py": 34,
|
||||
"archivebox/tests/test_frozen_crawl_config.py": 47,
|
||||
"archivebox/tests/test_hook_result_lifecycle.py": 78,
|
||||
"archivebox/tests/test_hooks.py": 90,
|
||||
"archivebox/tests/test_machine_models.py": 52,
|
||||
"archivebox/tests/test_machine_service.py": 104,
|
||||
"archivebox/tests/test_migrations_04_to_09.py": 36,
|
||||
"archivebox/tests/test_migrations_07_to_09.py": 140,
|
||||
"archivebox/tests/test_migrations_08_to_09.py": 257,
|
||||
"archivebox/tests/test_migrations_fresh.py": 99,
|
||||
"archivebox/tests/test_misc_checks.py": 22,
|
||||
"archivebox/tests/test_misc_monkey_patches.py": 20,
|
||||
"archivebox/tests/test_opencode_agent_1.py": 135,
|
||||
"archivebox/tests/test_opencode_agent_2.py": 138,
|
||||
"archivebox/tests/test_opencode_agent_3.py": 140,
|
||||
"archivebox/tests/test_opencode_browser_1.py": 290,
|
||||
"archivebox/tests/test_opencode_browser_2.py": 157,
|
||||
"archivebox/tests/test_opencode_browser_3.py": 129,
|
||||
"archivebox/tests/test_orm_helpers.py": 22,
|
||||
"archivebox/tests/test_permissions.py": 21,
|
||||
"archivebox/tests/test_persona_runtime.py": 56,
|
||||
"archivebox/tests/test_postgres_backend.py": 85,
|
||||
"archivebox/tests/test_process_runtime_paths.py": 28,
|
||||
"archivebox/tests/test_process_service.py": 49,
|
||||
"archivebox/tests/test_proof_previews.py": 54,
|
||||
"archivebox/tests/test_recursive_crawl_1.py": 396,
|
||||
"archivebox/tests/test_recursive_crawl_2.py": 193,
|
||||
"archivebox/tests/test_recursive_crawl_3.py": 144,
|
||||
"archivebox/tests/test_release_notes.py": 14,
|
||||
"archivebox/tests/test_release_source_fingerprint.py": 22,
|
||||
"archivebox/tests/test_release_workflow.py": 21,
|
||||
"archivebox/tests/test_resource_admission.py": 21,
|
||||
"archivebox/tests/test_runner_watch.py": 182,
|
||||
"archivebox/tests/test_search.py": 203,
|
||||
"archivebox/tests/test_serve_static.py": 31,
|
||||
"archivebox/tests/test_server_automatic_url.py": 36,
|
||||
"archivebox/tests/test_server_security_browser.py": 241,
|
||||
"archivebox/tests/test_settings_signal_webhooks.py": 21,
|
||||
"archivebox/tests/test_setup_script.py": 21,
|
||||
"archivebox/tests/test_shutdown_util.py": 22,
|
||||
"archivebox/tests/test_snapshot_filesystem_migration.py": 29,
|
||||
"archivebox/tests/test_snapshot_output_stacks.py": 62,
|
||||
"archivebox/tests/test_snapshot_ref_lookup.py": 23,
|
||||
"archivebox/tests/test_snapshot_service.py": 41,
|
||||
"archivebox/tests/test_snapshot_url_length.py": 26,
|
||||
"archivebox/tests/test_tag_service.py": 24,
|
||||
"archivebox/tests/test_takeover_util_1.py": 122,
|
||||
"archivebox/tests/test_takeover_util_2.py": 171,
|
||||
"archivebox/tests/test_takeover_util_3.py": 165,
|
||||
"archivebox/tests/test_takeover_util_4.py": 160,
|
||||
"archivebox/tests/test_test_harness.py": 22,
|
||||
"archivebox/tests/test_ui_add_view_runtime.py": 239,
|
||||
"archivebox/tests/test_ui_add_view.py": 46,
|
||||
"archivebox/tests/test_ui_admin_apitoken.py": 23,
|
||||
"archivebox/tests/test_ui_admin_archiveresult.py": 30,
|
||||
"archivebox/tests/test_ui_admin_config_widget.py": 25,
|
||||
"archivebox/tests/test_ui_admin_crawl.py": 44,
|
||||
"archivebox/tests/test_ui_admin_links.py": 35,
|
||||
"archivebox/tests/test_ui_admin_machine.py": 29,
|
||||
"archivebox/tests/test_ui_admin_persona.py": 28,
|
||||
"archivebox/tests/test_ui_admin_snapshot.py": 288,
|
||||
"archivebox/tests/test_ui_admin_tag.py": 26,
|
||||
"archivebox/tests/test_ui_admin_user.py": 28,
|
||||
"archivebox/tests/test_ui_config_views.py": 43,
|
||||
"archivebox/tests/test_ui_live_progress.py": 303,
|
||||
"archivebox/tests/test_ui_public_snapshot.py": 138,
|
||||
"archivebox/tests/test_ui_savepagenow.py": 48,
|
||||
"archivebox/tests/test_urls.py": 179,
|
||||
"archivebox/tests/test_util.py": 20,
|
||||
"archivebox/tests/test_version_metadata.py": 24
|
||||
}
|
||||
}
|
||||
@@ -18,47 +18,68 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/prepare-release.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Linters
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
install-cli-platform:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Install and CLI platform compatibility
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
tests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Discovered test matrix
|
||||
uses: ./.github/workflows/test-parallel.yml
|
||||
secrets: inherit
|
||||
|
||||
documentation:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Documentation and root tests
|
||||
uses: ./.github/workflows/docs.yml
|
||||
secrets: inherit
|
||||
|
||||
codeql:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
|
||||
python-artifacts:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Tested Python artifacts
|
||||
uses: ./.github/workflows/pip.yml
|
||||
secrets: inherit
|
||||
|
||||
docker-digests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Tested Docker images
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
push_digests: false
|
||||
push_digests: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'main') }}
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
name: All required CI lanes
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && needs.prepare.outputs.run_jobs == 'true' }}
|
||||
needs:
|
||||
- prepare
|
||||
- lint
|
||||
- install-cli-platform
|
||||
- tests
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Deploy Publicsite to GitHub Pages
|
||||
name: Publish website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
@@ -8,37 +9,73 @@ on:
|
||||
workflows: ['Capture publicsite screenshots']
|
||||
types: [completed]
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
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:
|
||||
# Failed captures must not displace a pending push deployment.
|
||||
group: fast-pages-${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'success' && github.run_id || github.ref }}
|
||||
group: pages-${{ github.event_name == 'pull_request' && github.ref || 'production' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
revision: ${{ steps.source.outputs.revision }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.ref || 'dev' }}
|
||||
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 archivebox publicsite/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
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
- 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:
|
||||
path: publicsite
|
||||
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:
|
||||
|
||||
@@ -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 || github.sha }}
|
||||
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
|
||||
@@ -267,12 +295,13 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Validate exact built image version and commit
|
||||
if: ${{ !inputs.push_digests }}
|
||||
if: inputs.full_tests
|
||||
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
|
||||
@@ -316,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=$((900 * 1024 * 1024))
|
||||
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=900 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
|
||||
@@ -395,7 +424,7 @@ 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
|
||||
@@ -408,7 +437,7 @@ jobs:
|
||||
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"
|
||||
@@ -449,7 +478,7 @@ jobs:
|
||||
'${{ runner.temp }}/archivebox-image.tar' \
|
||||
'${{ steps.docker_build_pr.outputs.digest }}' \
|
||||
"$LOCAL_IMAGE_ID" \
|
||||
"$((900 * 1024 * 1024))" <<'PY'
|
||||
"$((1025 * 1024 * 1024))" <<'PY'
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
@@ -523,12 +552,17 @@ jobs:
|
||||
|
||||
- name: Export digest
|
||||
if: inputs.push_digests
|
||||
env:
|
||||
ABX_DL_IMAGE_REF: ${{ steps.abx_dl_image.outputs.image }}
|
||||
ARTIFACT_NAME: ${{ matrix.artifact_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
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
|
||||
printf '%s\n' "$ABX_DL_IMAGE_REF" > "/tmp/digests/abx-dl-image-$ARTIFACT_NAME"
|
||||
|
||||
- name: Upload digest
|
||||
if: inputs.push_digests
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -146,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 }}'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Prepare release version
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
run_jobs:
|
||||
description: Whether this revision owns its release version and should run expensive jobs
|
||||
value: ${{ jobs.prepare.outputs.run_jobs }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare the version before starting expensive jobs
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
run_jobs: ${{ github.event_name != 'push' || steps.channel.outputs.stable == 'true' || steps.version.outputs.action == 'ready' || steps.version.outputs.action == 'reserved' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
token: ${{ github.event_name == 'push' && secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Keep synchronized stable branches on the released version
|
||||
id: channel
|
||||
if: github.event_name == 'push'
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
|
||||
git fetch --no-tags origin main
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
# Stable reconciliation may preserve dev history with a merge commit.
|
||||
# Its tree still matches main; bumping it would undo synchronization
|
||||
# before the stable publisher can advance the Docker :dev tag.
|
||||
if [[ "$GITHUB_REF_NAME" == dev && "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] &&
|
||||
git merge-base --is-ancestor "$MAIN_SHA" "$GITHUB_SHA" &&
|
||||
git diff --quiet "$MAIN_SHA" "$GITHUB_SHA"; then
|
||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'stable=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- id: version
|
||||
if: github.event_name == 'push' && steps.channel.outputs.stable != 'true'
|
||||
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
|
||||
with:
|
||||
package: archivebox
|
||||
branch: ${{ github.ref_name }}
|
||||
tag-prefix: v
|
||||
version-scheme: ${{ github.ref_name == 'main' && 'patch' || 'rc' }}
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-candidate-dev
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
action: ${{ steps.version.outputs.action }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN }}
|
||||
|
||||
- id: version
|
||||
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
|
||||
with:
|
||||
package: archivebox
|
||||
branch: dev
|
||||
tag-prefix: v
|
||||
version-scheme: rc
|
||||
|
||||
python-artifacts:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
uses: ./.github/workflows/pip.yml
|
||||
with:
|
||||
full_tests: false
|
||||
secrets: inherit
|
||||
|
||||
docker-digests:
|
||||
needs: prepare
|
||||
if: 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: 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
|
||||
+260
-36
@@ -2,36 +2,56 @@ name: Release
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Release candidate]
|
||||
workflows: [CI]
|
||||
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.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:
|
||||
@@ -40,49 +60,84 @@ 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'
|
||||
timeout-minutes: 40
|
||||
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
|
||||
CI_RUN_ID=''
|
||||
for _ in {1..12}; do
|
||||
CI_RUN_ID="$(gh run list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--workflow ci.yml \
|
||||
--commit "$RELEASE_SHA" \
|
||||
--event push \
|
||||
--limit 1 \
|
||||
--json databaseId \
|
||||
--jq '.[0].databaseId // empty')"
|
||||
[[ -n "$CI_RUN_ID" ]] && break
|
||||
sleep 5
|
||||
done
|
||||
[[ -n "$CI_RUN_ID" ]]
|
||||
gh run watch "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" --exit-status
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
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 "$CI_RUN_ID" ]] || exit 0
|
||||
DIGEST_RUN_ID="$CI_RUN_ID"
|
||||
fi
|
||||
ARTIFACT_RUN_ID="$CI_RUN_ID"
|
||||
{
|
||||
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 }}
|
||||
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
|
||||
environment: pypi
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
deployments: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
@@ -106,6 +161,26 @@ jobs:
|
||||
version: "0.11.3"
|
||||
enable-cache: false
|
||||
|
||||
# This precedes every stable upload, including workflow_dispatch. A green
|
||||
# build is not evidence that our automatically updated test servers ran it.
|
||||
- name: Download tested Docker base-image metadata
|
||||
if: env.RELEASE_BRANCH == 'main'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/tested-docker-artifacts
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
|
||||
|
||||
- name: Require matching acceptance on Cabbage
|
||||
if: env.RELEASE_BRANCH == 'main'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STAGING_DOCKER_ARTIFACT_DIR: ${{ runner.temp }}/tested-docker-artifacts
|
||||
run: uv run --no-project python bin/verify-staging-release.py
|
||||
|
||||
- name: Resolve release binaries through abxpkg
|
||||
env:
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
@@ -154,6 +229,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]+$ ]] || {
|
||||
@@ -191,20 +270,92 @@ 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: Reconcile 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" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
push_dev() {
|
||||
if ! git push origin "$1:refs/heads/dev"; then
|
||||
CURRENT_DEV="$(git ls-remote origin refs/heads/dev | cut -f1)"
|
||||
if [[ "$CURRENT_DEV" == "$DEV_SHA" ]]; then
|
||||
echo "Failed to push dev although it still points to $DEV_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Leaving dev at $CURRENT_DEV: it advanced during release reconciliation"
|
||||
fi
|
||||
}
|
||||
if git merge-base --is-ancestor "$DEV_SHA" "$RELEASE_SHA"; then
|
||||
push_dev "$RELEASE_SHA"
|
||||
exit 0
|
||||
else
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 1 ]] || exit "$STATUS"
|
||||
fi
|
||||
BASE_SHA="$(git merge-base "$MAIN_SHA" "$DEV_SHA")"
|
||||
if git diff --quiet "$BASE_SHA" "$DEV_SHA" -- . ':!pyproject.toml' ':!uv.lock' ':!etc/package.json'; then
|
||||
:
|
||||
else
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 1 ]] || exit "$STATUS"
|
||||
echo "Leaving dev at $DEV_SHA: it contains changes beyond release metadata"
|
||||
exit 0
|
||||
fi
|
||||
normalized_metadata() {
|
||||
case "$2" in
|
||||
pyproject.toml)
|
||||
git show "$1:$2" | awk '
|
||||
/^\[/ {section = $0}
|
||||
section == "[project]" && /^version = "[^"]+"$/ {print "version = \"__VERSION__\""; next}
|
||||
section == "[tool.bumpver]" && /^current_version = "[^"]+"$/ {print "current_version = \"__VERSION__\""; next}
|
||||
{print}
|
||||
' ;;
|
||||
uv.lock)
|
||||
git show "$1:$2" | awk 'previous == "name = \"archivebox\"" && /^version = / {print "version = \"__VERSION__\""; previous = $0; next} {print; previous = $0}' ;;
|
||||
etc/package.json)
|
||||
git show "$1:$2" | sed -E 's/^( "version": )"[^"]+"(,?)$/\1"__VERSION__"\2/' ;;
|
||||
esac
|
||||
}
|
||||
for path in pyproject.toml uv.lock etc/package.json; do
|
||||
BASE_METADATA="$(normalized_metadata "$BASE_SHA" "$path")"
|
||||
DEV_METADATA="$(normalized_metadata "$DEV_SHA" "$path")"
|
||||
if [[ "$BASE_METADATA" != "$DEV_METADATA" ]]; then
|
||||
echo "Leaving dev at $DEV_SHA: $path contains changes beyond release version metadata"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
# A concurrent release bot may bump only dev's own version metadata.
|
||||
# Keep that commit as a parent while preserving the exact tested stable tree.
|
||||
git switch --detach "$MAIN_SHA"
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
|
||||
merge -s ours --no-ff -m "Merge tested stable release into dev" "$DEV_SHA"
|
||||
MERGE_SHA="$(git rev-parse HEAD)"
|
||||
push_dev "$MERGE_SHA"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -227,13 +378,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"
|
||||
@@ -261,16 +413,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}"
|
||||
@@ -280,12 +447,46 @@ 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 $GIT_BINARY merge-base --is-ancestor "$MAIN_TARGET" "$DEV_TARGET" &&
|
||||
$GIT_BINARY diff --quiet "$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}"
|
||||
@@ -295,6 +496,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}"
|
||||
@@ -305,7 +507,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=()
|
||||
@@ -316,7 +518,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=()
|
||||
@@ -335,6 +537,26 @@ jobs:
|
||||
test "$INSTALLED_VERSION" = "$VERSION"
|
||||
done
|
||||
|
||||
- name: Record published image source
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# workflow_run can report the default branch's head even when this
|
||||
# run published a different branch/SHA. Preserve the verified target.
|
||||
[[ "$RELEASE_BRANCH" == dev || "$RELEASE_BRANCH" == main ]]
|
||||
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
mkdir -p "$RUNNER_TEMP/published-image-source"
|
||||
# shellcheck disable=SC2016
|
||||
"$JQ_BINARY" -n --arg branch "$RELEASE_BRANCH" --arg sha "$RELEASE_SHA" \
|
||||
'{branch: $branch, sha: $sha}' > "$RUNNER_TEMP/published-image-source/source.json"
|
||||
|
||||
- name: Upload published image source
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: published-image-source
|
||||
path: ${{ runner.temp }}/published-image-source/source.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Update Docker Hub README
|
||||
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
|
||||
with:
|
||||
@@ -346,6 +568,7 @@ jobs:
|
||||
name: Cascade verified release
|
||||
if: needs.python-release.outputs.release_ready == 'true'
|
||||
needs:
|
||||
- candidate
|
||||
- python-release
|
||||
- docker-release
|
||||
permissions:
|
||||
@@ -356,5 +579,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 }}
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- publicsite/**
|
||||
- archivebox/**
|
||||
- bin/collect_ui_screenshots.*
|
||||
- bin/generate_ui_screenshot_gallery.py
|
||||
@@ -14,6 +13,7 @@ on:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- .github/workflows/screenshots.yml
|
||||
- .github/workflows/prepare-release.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -24,7 +24,15 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/prepare-release.yml
|
||||
secrets: inherit
|
||||
|
||||
deploy:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -50,6 +58,7 @@ jobs:
|
||||
uv run --no-cache --no-sync python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -59,6 +68,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Staging acceptance
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Release]
|
||||
types: [completed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: staging-acceptance
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
published:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
ready: ${{ steps.source.outputs.ready }}
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
steps:
|
||||
- id: image
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
published=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/jobs?per_page=100" --jq 'any(.jobs[]; .name == "Publish tested multiarch image" and .conclusion == "success")')
|
||||
echo "published=$published" >> "$GITHUB_OUTPUT"
|
||||
- name: Download published image source
|
||||
if: steps.image.outputs.published == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: published-image-source
|
||||
path: ${{ runner.temp }}/published-image-source
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Select published dev image
|
||||
id: source
|
||||
if: steps.image.outputs.published == 'true'
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# The triggering run may report the default branch as its head;
|
||||
# this artifact names the image source verified by the publisher.
|
||||
source="$RUNNER_TEMP/published-image-source/source.json"
|
||||
jq -e 'type == "object" and (keys | sort) == ["branch", "sha"]' "$source" >/dev/null
|
||||
branch=$(jq -er '.branch | select(type == "string")' "$source")
|
||||
sha=$(jq -er '.sha | select(type == "string")' "$source")
|
||||
[[ "$branch" == dev || "$branch" == main ]]
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$branch" == dev ]]; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
acceptance:
|
||||
# The privileged key is never exposed to PRs or arbitrary dispatch refs.
|
||||
needs: published
|
||||
if: needs.published.outputs.ready == 'true'
|
||||
runs-on: [self-hosted, Linux, X64, ugnas-linux]
|
||||
environment: staging
|
||||
timeout-minutes: 110
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [cabbage]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: ${{ needs.published.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e
|
||||
with:
|
||||
version: '0.11.3'
|
||||
- name: Prepare browser and restricted staging identity
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
STAGING_KNOWN_HOSTS: ${{ secrets.STAGING_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$RUNNER_TEMP/staging-key"
|
||||
printf '%s\n' "$STAGING_KNOWN_HOSTS" > "$RUNNER_TEMP/staging-known-hosts"
|
||||
uv run --no-project --with playwright==1.63.0 playwright install chromium
|
||||
- name: Verify actual deployment, full captures and rendered snapshots
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STAGING_TARGET: ${{ matrix.target }}
|
||||
STAGING_SHA: ${{ needs.published.outputs.sha }}
|
||||
STAGING_KEY_FILE: ${{ runner.temp }}/staging-key
|
||||
STAGING_KNOWN_HOSTS_FILE: ${{ runner.temp }}/staging-known-hosts
|
||||
run: uv run --no-project --with playwright==1.63.0 python bin/check-staging-deployment.py
|
||||
- name: Remove staging identity
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/staging-key" "$RUNNER_TEMP/staging-known-hosts"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
if: always()
|
||||
with:
|
||||
name: staging-${{ matrix.target }}-${{ needs.published.outputs.sha }}
|
||||
path: staging-evidence/
|
||||
if-no-files-found: error
|
||||
@@ -1,6 +1,13 @@
|
||||
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:
|
||||
@@ -19,11 +26,27 @@ 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
|
||||
# Jobs queued for this pool must not be mistaken for idle runner slots:
|
||||
# requiring MAX_JOBS idle runners prevents assigning a second wave.
|
||||
primaries-required: ${{ vars.UGNAS_CI_MIN_IDLE || 1 }}
|
||||
fallback-on-error: true
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
submodules: true
|
||||
@@ -66,6 +89,7 @@ jobs:
|
||||
id: discover
|
||||
env:
|
||||
TEST_FILE: ${{ inputs.test_file }}
|
||||
UGNAS_CI_MAX_JOBS: ${{ vars.UGNAS_CI_MAX_JOBS || 3 }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py)
|
||||
@@ -80,13 +104,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.
|
||||
# Ordinary Linux tests are eligible; # ci-runner: hosted declares exceptions.
|
||||
# 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(matrix.test.ugnas && 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
|
||||
@@ -94,6 +118,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
|
||||
@@ -143,6 +176,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:
|
||||
@@ -153,13 +189,35 @@ jobs:
|
||||
for test_path in "${test_paths[@]}"; do
|
||||
test -f "$test_path"
|
||||
done
|
||||
test_status=0
|
||||
for test_path in "${test_paths[@]}"; do
|
||||
test_name="$(basename "$test_path" .py)"
|
||||
test_slug="${test_name//[^A-Za-z0-9_.-]/_}"
|
||||
export DATA_DIR="$RUNNER_TEMP/archivebox-test-data/$test_slug"
|
||||
export PERSONAS_DIR="$RUNNER_TEMP/abx-personas/$test_slug"
|
||||
export CHROME_USER_DATA_DIR="$PERSONAS_DIR/Default/chrome_profile"
|
||||
if (( ${#test_paths[@]} > 1 )); then
|
||||
export ABXPKG_LIB_DIR="$RUNNER_TEMP/archivebox-lib/$test_slug"
|
||||
fi
|
||||
mkdir -p "$DATA_DIR" "$PERSONAS_DIR" "$RUNNER_TEMP/pytest/$test_slug"
|
||||
uv run --no-cache --no-sync --no-sources pytest -vs "$test_path" \
|
||||
--basetemp="$RUNNER_TEMP/pytest/$test_slug"
|
||||
uv run --no-cache --no-sync --no-sources pytest -vs "$test_path" --durations=20 \
|
||||
--basetemp="$RUNNER_TEMP/pytest/$test_slug" \
|
||||
--junitxml="$RUNNER_TEMP/test-results/$test_slug.xml" || test_status=1
|
||||
done
|
||||
exit "$test_status"
|
||||
- name: Save test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: test-results-${{ strategy.job-index }}
|
||||
path: ${{ runner.temp }}/test-results/*.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Save failed runtime diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: runtime-logs-${{ strategy.job-index }}
|
||||
path: ${{ runner.temp }}/pytest/**/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -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
|
||||
|
||||
@@ -77,11 +77,7 @@ archivebox run
|
||||
|
||||
## Verification
|
||||
|
||||
Use targeted tests for focused work:
|
||||
Run the test suite with `uv run pytest archivebox/tests -x` and repository checks
|
||||
with `uv run prek run --all-files`. For focused work, select the relevant tests.
|
||||
|
||||
```bash
|
||||
uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_tag_options -q
|
||||
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.
|
||||
+38
-7
@@ -5,6 +5,34 @@
|
||||
# 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 \
|
||||
@@ -12,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.328 \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:dev \
|
||||
# -t archivebox:multistage
|
||||
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.328
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:main
|
||||
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
|
||||
@@ -49,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 \
|
||||
@@ -252,18 +281,20 @@ RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777
|
||||
&& 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
|
||||
|
||||
# The optional AI service belongs to ArchiveBox, not the downloader base image.
|
||||
# Always bundle its executable while leaving OPENCODE_ENABLED opt-in.
|
||||
# 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 opencode \
|
||||
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 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 opencode'
|
||||
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..." \
|
||||
|
||||
@@ -105,12 +105,11 @@ curl -fsSL 'https://get.archivebox.io' | bash
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
|
||||
<div align="center" style="text-align: center">
|
||||
<br/><br/>
|
||||
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/5a7d95f2-6977-4de6-9f08-42851a1fe1d2" height="70px" alt="bookshelf graphic"> <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/b2765a33-0d1e-4019-a1db-920c7e00e20e" height="75px" alt="logo" align="top"/> <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/5a7d95f2-6977-4de6-9f08-42851a1fe1d2" height="70px" alt="bookshelf graphic">
|
||||
<br/><br/>
|
||||
<small><a href="https://demo.archivebox.io">Demo</a> | <a href="#screenshots">Screenshots</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a></small>
|
||||
<small><a href="https://demo.archivebox.io">Demo</a> | <a href="https://archivebox.io/screenshots">Screenshots</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a></small>
|
||||
<br/>
|
||||
<sub>. . . . . . . . . . . . . . . . . . . . . . . . . . . .</sub>
|
||||
<br/><br/>
|
||||
@@ -131,8 +130,8 @@ curl -fsSL 'https://get.archivebox.io' | bash
|
||||
- [**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://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/>
|
||||
|
||||
@@ -381,7 +380,7 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, W
|
||||
> *Warning: These are contributed by external volunteers and may lag behind the official Docker and `uv` channels.*
|
||||
|
||||
<ul>
|
||||
<li><s>TrueNAS: <a href="https://truecharts.org/charts/stable/archivebox/">Official ArchiveBox TrueChart</a> / <a href="https://dev.to/finloop/setting-up-archivebox-on-truenas-scale-1788">Custom App Guide</a></s> (<a href="https://truecharts.org/news/scale-deprecation/">TrueCharts is discontinued</a>, wait for <a href="https://forums.truenas.com/t/the-future-of-electric-eel-and-apps/5409/">Electric Eel</a>)</li>
|
||||
<li><a href="https://apps.truenas.com/catalog/archivebox_community/">TrueNAS</a></li>
|
||||
<li><a href="https://unraid.net/community/apps?q=archivebox#r">UnRaid</a></li>
|
||||
<li><a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=archivebox">Proxmox</a></li>
|
||||
<li><a href="https://github.com/YunoHost-Apps/archivebox_ynh">Yunohost</a></li>
|
||||
@@ -1005,14 +1004,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/Chromium-Install#import-an-existing-browser-profile">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/>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -800,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}"
|
||||
|
||||
@@ -182,6 +182,14 @@ def crawl_file(request: HttpRequest, crawl_id: str, path: str):
|
||||
if not file_path.is_file() or crawl_root not in file_path.parents:
|
||||
raise HttpError(404, "Crawl file not found")
|
||||
|
||||
if path == "chrome_screencast/latest.jpg" and request.GET.get("after"):
|
||||
try:
|
||||
started_at = float(request.GET["after"])
|
||||
except ValueError:
|
||||
raise HttpError(400, "Invalid screencast start time")
|
||||
if file_path.stat().st_mtime < started_at:
|
||||
raise HttpError(404, "No frame for this capture yet")
|
||||
|
||||
response = FileResponse(file_path.open("rb"))
|
||||
response["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"
|
||||
response["Pragma"] = "no-cache"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,8 +174,12 @@ def cli(ctx, help=False):
|
||||
archivebox_init(quick=True)
|
||||
os.environ.pop("ARCHIVEBOX_WANTS_INIT", None)
|
||||
check_data_folder()
|
||||
if subcommand != "update":
|
||||
check_migrations(auto_apply=True)
|
||||
if subcommand != "update" and not check_migrations(auto_apply=True):
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
# Host registration and config reconciliation belong to CLI
|
||||
# startup; get_config() is also used by read-only HTTP views.
|
||||
Machine.current()
|
||||
except Exception as e:
|
||||
STDERR.print(f"[red][X] Error setting up Django or checking data folder: {e}[/red]")
|
||||
if subcommand not in ("manage", "shell"): # not all management commands need django to be setup beforehand
|
||||
|
||||
@@ -6,6 +6,7 @@ __command__ = "archivebox add"
|
||||
|
||||
import sys
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
@@ -227,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"])
|
||||
@@ -234,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,
|
||||
@@ -246,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"])
|
||||
@@ -311,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}")
|
||||
|
||||
@@ -111,6 +111,11 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
else:
|
||||
os.environ["ARCHIVEBOX_WANTS_INIT"] = previous_wants_init
|
||||
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
# Registration requires the final schema and reconciles legacy file config.
|
||||
Machine.current()
|
||||
|
||||
from archivebox.misc.db import database_display_location, is_postgres
|
||||
|
||||
assert database_exists()
|
||||
|
||||
@@ -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:
|
||||
@@ -285,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(
|
||||
@@ -306,10 +310,16 @@ def run_runner(
|
||||
maintenance_only=maintenance_only,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
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
|
||||
@@ -362,11 +372,11 @@ def main(
|
||||
):
|
||||
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:
|
||||
@@ -386,7 +396,7 @@ def main(
|
||||
if crawl_id:
|
||||
sys.exit(
|
||||
run_runner(
|
||||
daemon=False,
|
||||
daemon=daemon,
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
),
|
||||
@@ -401,7 +411,7 @@ def main(
|
||||
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
|
||||
@@ -416,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):
|
||||
@@ -424,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:
|
||||
@@ -437,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
|
||||
|
||||
@@ -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,7 +248,6 @@ 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, get_plugin_catalog
|
||||
from abx_dl.config import get_required_binary_requests
|
||||
@@ -272,37 +256,29 @@ def version(
|
||||
from abxpkg.binary_service import BinaryEvent, BinaryService
|
||||
|
||||
plugins = get_plugin_catalog()
|
||||
enabled_plugins = plugins.select(get_enabled_plugins(config=config))
|
||||
enabled_plugin_names = set(enabled_plugins)
|
||||
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
|
||||
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,
|
||||
@@ -320,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)
|
||||
|
||||
@@ -341,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,
|
||||
@@ -472,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]")
|
||||
|
||||
@@ -468,7 +468,13 @@ class ArchivingConfig(BaseConfigSet):
|
||||
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})
|
||||
@@ -1107,7 +1113,7 @@ def get_config(
|
||||
if apps.ready:
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
machine = Machine.current()
|
||||
machine = Machine.current_readonly()
|
||||
except (DatabaseError, ImportError, RuntimeError, TypeError, ValueError):
|
||||
machine = None
|
||||
|
||||
|
||||
@@ -63,7 +63,14 @@ def setup_django(check_db=False) -> None:
|
||||
os.chown(CONSTANTS.DATA_DIR, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
if CONSTANTS.DATA_DIR.exists():
|
||||
for child in CONSTANTS.DATA_DIR.iterdir():
|
||||
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
try:
|
||||
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
# A concurrent SQLite transaction can remove its journal
|
||||
# between directory enumeration and chown. There is no
|
||||
# ownership left to fix; other permission errors must
|
||||
# still fail startup rather than being hidden.
|
||||
continue
|
||||
|
||||
# Suppress the "database access during app initialization" warning
|
||||
# This warning can be triggered during django.setup() but is safe to ignore
|
||||
|
||||
+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.output_folder_path(),
|
||||
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.output_folder_path(),
|
||||
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):
|
||||
|
||||
+227
-191
@@ -1,8 +1,8 @@
|
||||
__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
|
||||
@@ -10,11 +10,12 @@ from django.urls import path, reverse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
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
|
||||
@@ -24,17 +25,32 @@ 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,17 +64,14 @@ from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget
|
||||
GLOBAL_CONTEXT = {}
|
||||
|
||||
SNAPSHOT_PERMISSION_META = PERMISSIONS_META
|
||||
GRID_PREVIEW_OUTPUTS = {
|
||||
("screenshot", "screenshot.png"),
|
||||
("chrome_extension_screenshot", "screenshot-1.png"),
|
||||
("chrome_extension_screenshot", "screenshot.png"),
|
||||
("favicon", "favicon.ico"),
|
||||
}
|
||||
|
||||
|
||||
@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):
|
||||
@@ -270,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, {})
|
||||
@@ -302,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)
|
||||
@@ -314,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,
|
||||
@@ -376,7 +404,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
list_display = (
|
||||
"permissions_badge",
|
||||
"created_at",
|
||||
"created_at_display",
|
||||
"preview_icon",
|
||||
"title_str",
|
||||
"tags_inline",
|
||||
@@ -384,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",
|
||||
@@ -498,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
|
||||
|
||||
@@ -563,14 +600,15 @@ 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()
|
||||
@@ -592,17 +630,17 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return custom_urls + urls
|
||||
|
||||
def preview_view(self, request, object_id, plugin, filename):
|
||||
"""Serve grid thumbnails from the authenticated admin origin."""
|
||||
if (plugin, filename) not in GRID_PREVIEW_OUTPUTS:
|
||||
raise Http404("Unsupported snapshot preview")
|
||||
"""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()
|
||||
file_info = (result.output_files or {}).get(filename) if result else None
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
raise Http404("Snapshot preview does not exist")
|
||||
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
|
||||
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/"):
|
||||
@@ -742,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:
|
||||
@@ -888,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 {})
|
||||
@@ -926,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(
|
||||
@@ -936,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")
|
||||
@@ -970,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(), "
|
||||
@@ -1074,45 +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(
|
||||
'<a href="{}" title="Open snapshot details">'
|
||||
'<img src="{}" alt="{}" class="snapshot-preview {}" decoding="async" loading="lazy" onerror="{}" data-fallbacks="{}">'
|
||||
"</a>",
|
||||
build_web_url(f"/{obj.archive_path_from_db}/index.html", request=self.request, config=self.request.archivebox_config),
|
||||
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;">'
|
||||
@@ -1127,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:
|
||||
@@ -1149,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,
|
||||
)
|
||||
|
||||
@@ -1268,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."""
|
||||
@@ -1276,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>',
|
||||
@@ -1421,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],
|
||||
),
|
||||
|
||||
@@ -33,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"
|
||||
|
||||
@@ -154,8 +155,21 @@ def CacheControlMiddleware(get_response):
|
||||
response.headers["Last-Modified"] = http_date(mtime)
|
||||
return response
|
||||
|
||||
# Keep artifact freshness/validators, but never let a shared cache store
|
||||
# private or unlisted snapshots. Auth redirects and errors stay unstored.
|
||||
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"):
|
||||
|
||||
@@ -100,11 +100,12 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
if schema_editor.connection.vendor != "sqlite":
|
||||
return
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
import uuid as uuid_lib
|
||||
from datetime import datetime
|
||||
|
||||
connection = schema_editor.connection
|
||||
cursor = connection.cursor()
|
||||
now = connection.ops.adapt_datetimefield_value(timezone.now())
|
||||
|
||||
# Check if there are any snapshots without a crawl
|
||||
cursor.execute("SELECT COUNT(*) FROM core_snapshot WHERE crawl_id IS NULL")
|
||||
@@ -125,14 +126,13 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
INSERT INTO auth_user (id, password, is_superuser, username, first_name, last_name, email, is_staff, is_active, date_joined)
|
||||
VALUES (1, '!', 1, 'system', '', '', '', 1, 1, %s)
|
||||
""",
|
||||
[datetime.now().isoformat()],
|
||||
[now],
|
||||
)
|
||||
|
||||
# Create a default crawl for migrated snapshots.
|
||||
# Depending on migration graph order, later crawls migrations may already
|
||||
# have removed output_dir by the time this data migration runs.
|
||||
crawl_id = uuid_lib.uuid4().hex
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("PRAGMA table_info(crawls_crawl)")
|
||||
crawl_columns = {row[1] for row in cursor.fetchall()}
|
||||
default_columns: list[str] = []
|
||||
@@ -155,9 +155,9 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
status, retry_at, created_by_id, schedule_id, config, persona_id
|
||||
) VALUES (%s, %s, %s, 0, 0, %s, 0, '', 'Migrated from v0.7.2/v0.8.6',
|
||||
'Auto-created crawl for migrated snapshots', {default_values_sql}
|
||||
'sealed', %s, 1, NULL, '{{}}', NULL)
|
||||
'sealed', NULL, 1, NULL, '{{}}', NULL)
|
||||
""",
|
||||
[crawl_id, now, now, crawl_urls, now],
|
||||
[crawl_id, now, now, crawl_urls],
|
||||
)
|
||||
|
||||
# Assign all snapshots without a crawl to the default crawl
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Repair SQLite queue timestamps written as raw ISO strings by old migrations."""
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
|
||||
def repair_legacy_retry_timestamps(apps, schema_editor):
|
||||
connection = schema_editor.connection
|
||||
if connection.vendor != "sqlite":
|
||||
return
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 0024 created this Crawl already sealed, but left a due retry marker.
|
||||
# Its orphaned snapshots have their own scheduler rows for maintenance.
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE crawls_crawl SET retry_at = NULL
|
||||
WHERE status = 'sealed'
|
||||
AND label = 'Migrated from v0.7.2/v0.8.6'
|
||||
AND notes = 'Auto-created crawl for migrated snapshots'
|
||||
AND substr(retry_at, 11, 1) = 'T'
|
||||
""",
|
||||
)
|
||||
|
||||
# SQLite compares stored date strings lexically. A raw `T` timestamp
|
||||
# from a legacy migration sorts differently from Django's space-separated
|
||||
# parameter, so a due row can be selected but never claimed. Preserve
|
||||
# the datetime Django already reads from each row while rewriting its
|
||||
# storage representation through the active database adapter.
|
||||
for table in ("crawls_crawl", "core_snapshot", "machine_binary", "machine_process"):
|
||||
last_id = ""
|
||||
while True:
|
||||
cursor.execute(
|
||||
f"SELECT id, CAST(retry_at AS TEXT) FROM {table} WHERE id > %s AND substr(retry_at, 11, 1) = 'T' ORDER BY id LIMIT 500",
|
||||
[last_id],
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
break
|
||||
for row_id, raw_retry_at in rows:
|
||||
try:
|
||||
parsed = parse_datetime(raw_retry_at)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed is None:
|
||||
continue
|
||||
normalized = connection.ops.adapt_datetimefield_value(parsed)
|
||||
cursor.execute(
|
||||
f"UPDATE {table} SET retry_at = %s WHERE id = %s AND retry_at = %s",
|
||||
[normalized, row_id, raw_retry_at],
|
||||
)
|
||||
last_id = rows[-1][0]
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0054_snapshot_fs_version_index"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(repair_legacy_retry_timestamps, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Repair process links after both core's legacy copy and machine migrations."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
repair_process_binary_iface_links = import_module(
|
||||
"archivebox.machine.migrations.0020_repair_process_binary_iface_links",
|
||||
).repair_process_binary_iface_links
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("core", "0055_repair_legacy_retry_timestamps"),
|
||||
("machine", "0022_networkinterface_identity_without_mac"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# The original repair can run before core.0027 creates legacy Process
|
||||
# rows. Repair again after the copy, including already upgraded DBs.
|
||||
migrations.RunPython(repair_process_binary_iface_links, migrations.RunPython.noop),
|
||||
]
|
||||
+495
-258
File diff suppressed because it is too large.
Load diff
@@ -1,4 +0,0 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
|
||||
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
|
||||
@@ -74,8 +74,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
**snapshot_filter,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
|
||||
# Find rare uploads through hook_name's index before checking their parents;
|
||||
# a correlated upload probe otherwise visits every sealed snapshot.
|
||||
extension_upload_results = ArchiveResult.objects.filter(
|
||||
snapshot_id=OuterRef("pk"),
|
||||
hook_name=Snapshot.BROWSER_EXTENSION_UPLOAD_HOOK_NAME,
|
||||
)
|
||||
server_results = ArchiveResult.objects.filter(snapshot_id=OuterRef("pk")).exclude(
|
||||
@@ -83,15 +84,15 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
)
|
||||
extension_only_snapshots = (
|
||||
Snapshot.objects.filter(
|
||||
id__in=extension_upload_results.values("snapshot_id"),
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
crawl__status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED],
|
||||
**snapshot_filter,
|
||||
)
|
||||
.annotate(
|
||||
has_extension_upload=Exists(extension_upload_results),
|
||||
has_server_result=Exists(server_results),
|
||||
)
|
||||
.filter(has_extension_upload=True, has_server_result=False)
|
||||
.filter(has_server_result=False)
|
||||
)
|
||||
# Older browser-extension uploads could win a race with runner startup:
|
||||
# their successful external rows made the fresh Snapshot look finished
|
||||
|
||||
+113
-22
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -1,3 +1,28 @@
|
||||
"""Shared foreground signal and child-process cleanup contracts.
|
||||
|
||||
Shutdown intent and process exit are separate: takeover must preserve the
|
||||
replacement owner's work, while a user abort must not later resume the crawl.
|
||||
Callers own that policy; this module preserves the received signal and stops
|
||||
only the process objects they explicitly supply.
|
||||
|
||||
Cooperative boundaries are essential, not just quieter exception handling.
|
||||
A real Linux CI regression interrupted Popen.poll() with SIGINT and stranded
|
||||
its wait lock. SIGKILL stopped the child (it became a zombie), but Popen.wait()
|
||||
still timed out. Raising a different exception or waiting longer cannot repair
|
||||
that interrupted critical section. Polling loops must record the signal first
|
||||
and honor it between operations; async owners may cancel their own task while
|
||||
their loop is running. Once exiting, the last-resort process cleanup must stay
|
||||
synchronous: the original loop may be closed, and creating a replacement loop
|
||||
cannot recover tasks or resources owned by it.
|
||||
|
||||
Keep immediate signal notices and repeated-signal force exit independent of
|
||||
the event bus: it may already be draining or closed during shutdown. Resumable
|
||||
pause/retry/skip belongs to the existing crawl controller and must not set
|
||||
sticky command-shutdown state. See foreground_shutdown_signals below for both
|
||||
cooperative and legacy blocking callers; do not change all callers' policy at
|
||||
once without supplying their interruption boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -23,7 +48,7 @@ _active_shutdown_state: ShutdownSignalState | None = None
|
||||
|
||||
|
||||
def raise_if_shutdown_requested() -> None:
|
||||
"""Let long foreground loops honor a signal even if Python ignored it once."""
|
||||
"""Honor sticky shutdown intent at a caller-owned interruption boundary."""
|
||||
|
||||
if _active_shutdown_state and _active_shutdown_state.signal_name:
|
||||
raise KeyboardInterrupt
|
||||
@@ -48,14 +73,37 @@ def wait_popen_and_kill_children(
|
||||
timeout: float,
|
||||
kill_timeout: float = 2.0,
|
||||
) -> None:
|
||||
"""Wait for a Popen parent and then hard-kill any surviving descendants."""
|
||||
"""Reap our own Popen child and stop its explicitly captured descendants.
|
||||
|
||||
A dead child still needs its parent to reap it. Keep wait failures visible;
|
||||
bypassing Popen's lock would hide the interrupted-owner bug described above.
|
||||
Descendants must still be cleaned up when that wait fails.
|
||||
"""
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=kill_timeout)
|
||||
kill_remaining_processes(children, timeout=kill_timeout)
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=kill_timeout)
|
||||
except subprocess.TimeoutExpired as err:
|
||||
try:
|
||||
state = psutil.Process(proc.pid).as_dict(attrs=["pid", "ppid", "status", "num_threads", "cpu_times"])
|
||||
except psutil.NoSuchProcess:
|
||||
state = {"pid": proc.pid, "status": "exited"}
|
||||
err.add_note(f"Process state after SIGKILL: {state}")
|
||||
# A locked Popen wait can time out even after the OS process exits.
|
||||
# Inspect CPython's lock only for diagnostics; never release it here.
|
||||
waitpid_lock = getattr(proc, "_waitpid_lock", None)
|
||||
err.add_note(
|
||||
f"Popen state: returncode={proc.returncode}, "
|
||||
f"waitpid_lock_locked={waitpid_lock.locked() if waitpid_lock is not None else None}, "
|
||||
f"threads={[thread.name for thread in threading.enumerate()]}",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# A parent that cannot be reaped must not leave its descendants running.
|
||||
kill_remaining_processes(children, timeout=kill_timeout)
|
||||
|
||||
|
||||
def wait_psutil_and_kill_children(
|
||||
@@ -101,13 +149,17 @@ 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.
|
||||
"""Keep shutdown intent visible until the owning foreground command unwinds.
|
||||
|
||||
Some log-tail loops intentionally swallow KeyboardInterrupt so that callers
|
||||
can centralize cleanup in finally blocks. The handler writes the signal name
|
||||
immediately, then raises KeyboardInterrupt to break out of the blocking read.
|
||||
Signal state was added because inner loops can swallow KeyboardInterrupt
|
||||
while their caller still needs to stop its children. Cooperative callers use
|
||||
raise_on_first_signal=False and check raise_if_shutdown_requested() between
|
||||
operations, or cancel their owned async task through on_signal. Raising from
|
||||
the handler can otherwise interrupt library locks or partially sent I/O.
|
||||
Blocking callers without a cooperative boundary retain immediate exceptions.
|
||||
"""
|
||||
|
||||
global _active_shutdown_state
|
||||
@@ -118,10 +170,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 +191,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 +202,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:
|
||||
|
||||
@@ -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
|
||||
@@ -202,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,
|
||||
}
|
||||
|
||||
@@ -221,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)
|
||||
|
||||
@@ -1,34 +1,76 @@
|
||||
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, 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.urls import reverse
|
||||
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)
|
||||
@@ -155,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
|
||||
|
||||
@@ -170,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("*"):
|
||||
@@ -191,7 +233,13 @@ 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(
|
||||
@@ -270,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 "")
|
||||
@@ -287,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 = "",
|
||||
@@ -306,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"
|
||||
@@ -330,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>'
|
||||
@@ -345,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>'
|
||||
@@ -385,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
|
||||
@@ -557,73 +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
|
||||
|
||||
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 ""
|
||||
return url_cache[cache_key]
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def admin_snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
|
||||
if not snapshot_archiveresult_url(context, snapshot, plugin, filename):
|
||||
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 ""
|
||||
return reverse("admin:core_snapshot_preview", args=(snapshot.pk, plugin, filename))
|
||||
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)
|
||||
@@ -642,10 +773,11 @@ 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)
|
||||
tag_html = "".join(
|
||||
f'<span class="snapshot-tag" title="{escape(tag)}">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag
|
||||
)
|
||||
if tag_html:
|
||||
tag_cell = f'<span class="snapshot-tags">{tag_html}</span>'
|
||||
else:
|
||||
@@ -660,50 +792,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:]))}" '
|
||||
@@ -713,7 +809,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)}" '
|
||||
@@ -728,27 +824,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>
|
||||
@@ -820,6 +913,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
|
||||
@@ -827,9 +942,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
|
||||
@@ -848,12 +962,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,
|
||||
@@ -867,11 +991,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)
|
||||
@@ -924,7 +1076,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,
|
||||
@@ -932,6 +1088,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)
|
||||
|
||||
+280
-146
@@ -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
|
||||
@@ -17,7 +17,6 @@ from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core import signing
|
||||
from django.core.paginator import InvalidPage
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidden, QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
@@ -48,7 +47,6 @@ from archivebox.config.configset import BaseConfigSet
|
||||
from archivebox.core.forms import AddLinkForm
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_PRIVATE,
|
||||
PERMISSIONS_PUBLIC,
|
||||
PERMISSIONS_UNLISTED,
|
||||
can_view_snapshot,
|
||||
@@ -59,6 +57,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,
|
||||
@@ -69,7 +69,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,
|
||||
@@ -78,10 +78,19 @@ 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
|
||||
from archivebox.progressmonitor.collection import collection_summary
|
||||
from archivebox.search.config import (
|
||||
get_search_mode,
|
||||
get_search_mode_backend,
|
||||
@@ -111,7 +120,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)
|
||||
@@ -328,6 +340,19 @@ 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",
|
||||
@@ -345,10 +370,6 @@ 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))
|
||||
@@ -357,7 +378,11 @@ class SnapshotView(View):
|
||||
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}"
|
||||
@@ -372,8 +397,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)
|
||||
@@ -407,15 +433,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(
|
||||
@@ -439,27 +465,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"
|
||||
@@ -467,8 +493,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,
|
||||
),
|
||||
@@ -487,7 +513,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()
|
||||
@@ -515,15 +541,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(
|
||||
@@ -536,7 +562,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}"
|
||||
@@ -641,7 +667,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}"
|
||||
@@ -687,11 +715,14 @@ 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:])
|
||||
@@ -703,19 +734,38 @@ def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[s
|
||||
).only("plugin", "output_files"),
|
||||
)
|
||||
if not results:
|
||||
return rel_path, None
|
||||
from archivebox.plugins.discovery import get_plugin_default_output_path
|
||||
|
||||
fallbacks = (plugin_relpath,) if get_plugin_default_output_path(plugin) == rel_path else ()
|
||||
return rel_path, None, fallbacks
|
||||
|
||||
declared_paths: list[tuple[str, ArchiveResult]] = []
|
||||
historical_fallbacks: list[str] = []
|
||||
for result in results:
|
||||
output_files = result.output_files or {}
|
||||
output_files = result.output_file_map()
|
||||
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
|
||||
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))
|
||||
|
||||
return rel_path, results[0]
|
||||
# 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(
|
||||
@@ -725,7 +775,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
|
||||
@@ -733,23 +783,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(
|
||||
@@ -758,23 +810,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"
|
||||
# Trusted viewers live on the snapshot origin, but the collection UI lives
|
||||
# on web.* (and can be embedded by admin.*). Permit only those configured
|
||||
# origins, not arbitrary sites or other snapshots. This does not grant the
|
||||
# archived document access to the parent frame or its admin session cookies.
|
||||
# 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))))
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
return (
|
||||
"default-src 'self' data: blob:; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
|
||||
"style-src 'unsafe-inline' data: blob: 'self'; "
|
||||
@@ -789,6 +858,47 @@ def _plugin_full_preview_response(
|
||||
"form-action 'none'; "
|
||||
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
|
||||
|
||||
|
||||
@@ -907,7 +1017,7 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
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
|
||||
|
||||
@@ -918,9 +1028,24 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
# subdomain (this keeps the endpoint identical across all security modes).
|
||||
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("/"):
|
||||
@@ -932,22 +1057,23 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
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
|
||||
@@ -961,6 +1087,8 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
|
||||
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"
|
||||
@@ -1109,14 +1237,23 @@ class PublicIndexView(ListView):
|
||||
"status",
|
||||
"output_size",
|
||||
"permissions",
|
||||
# Modern replay URLs need the filesystem version and owner's name.
|
||||
# Deferring these caused four extra SELECTs per displayed snapshot.
|
||||
"fs_version",
|
||||
"crawl__created_by__username",
|
||||
)
|
||||
|
||||
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot] | None:
|
||||
target_count = page_number * page_size
|
||||
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot]:
|
||||
# One extra row determines Next without a full collection count. Cached
|
||||
# totals can be stale while the runner adds/removes snapshots, so they
|
||||
# must never decide whether the requested page exists.
|
||||
target_count = page_number * page_size + 1
|
||||
public_snapshots: list[Snapshot] = []
|
||||
scanned = 0
|
||||
chunk_size = max(self.public_page_scan_chunk_size, page_size)
|
||||
ordered_snapshots = Snapshot.objects.order_by(*self.ordering).only(*self._base_public_snapshot_fields())
|
||||
chunk_size = max(self.public_page_scan_chunk_size, page_size + 1)
|
||||
ordered_snapshots = (
|
||||
Snapshot.objects.select_related("crawl__created_by").order_by(*self.ordering).only(*self._base_public_snapshot_fields())
|
||||
)
|
||||
|
||||
while len(public_snapshots) < target_count:
|
||||
chunk = list(ordered_snapshots[scanned : scanned + chunk_size])
|
||||
@@ -1132,20 +1269,31 @@ class PublicIndexView(ListView):
|
||||
if self.request.GET.get("q", default="").strip():
|
||||
return super().paginate_queryset(queryset, page_size)
|
||||
|
||||
public_count = self.get_exact_public_snapshot_count()
|
||||
paginator = self.get_paginator(range(public_count), page_size)
|
||||
page_kwarg = self.kwargs.get(self.page_kwarg)
|
||||
page_query = self.request.GET.get(self.page_kwarg)
|
||||
page_number = page_kwarg or page_query or 1
|
||||
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except InvalidPage as err:
|
||||
page_number = int(page_number)
|
||||
if page_number < 1:
|
||||
raise ValueError("Page must be positive")
|
||||
except (ValueError, TypeError) as err:
|
||||
raise Http404(f"Invalid page ({page_number}): {err}") from err
|
||||
|
||||
object_list = self._ordered_public_page_from_order_index(page_number=page.number, page_size=page_size)
|
||||
page.object_list = object_list
|
||||
return paginator, page, object_list, page.has_other_pages()
|
||||
snapshots = self._ordered_public_page_from_order_index(page_number=page_number, page_size=page_size)
|
||||
if not snapshots and page_number > 1:
|
||||
raise Http404(f"Invalid page ({page_number}): No snapshots")
|
||||
observed_count = (page_number - 1) * page_size + len(snapshots)
|
||||
self.public_snapshot_count = observed_count
|
||||
if len(snapshots) > page_size:
|
||||
summary = collection_summary(self.request.user, public=True)
|
||||
cached_count = summary["snapshots"] if summary is not None else None
|
||||
self.public_snapshot_count = cached_count if cached_count is not None and cached_count >= observed_count else None
|
||||
|
||||
paginator = self.get_paginator(range(observed_count), page_size)
|
||||
page = paginator.page(page_number)
|
||||
page.object_list = snapshots[:page_size]
|
||||
return paginator, page, page.object_list, page.has_other_pages()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
runtime_config = self.__dict__.get("runtime_config")
|
||||
@@ -1174,11 +1322,20 @@ class PublicIndexView(ListView):
|
||||
and search_mode_backend
|
||||
and context["paginator"].count == 0,
|
||||
)
|
||||
context["public_snapshot_count"] = context["paginator"].count if query else self.public_snapshot_count
|
||||
count = context["public_snapshot_count"]
|
||||
context["public_page_count"] = (
|
||||
max(1, (count + context["paginator"].per_page - 1) // context["paginator"].per_page) if count is not None else None
|
||||
)
|
||||
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}
|
||||
snapshots_by_id = {str(snapshot.id): snapshot 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,
|
||||
@@ -1199,89 +1356,66 @@ 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",
|
||||
)
|
||||
)
|
||||
# This is already bounded by the displayed snapshot page. Finish
|
||||
# the SELECT before parsing output manifests: a streaming cursor
|
||||
# otherwise keeps SQLite's read lock throughout rendering.
|
||||
for result in list(results):
|
||||
snapshot_key = str(result.snapshot_id)
|
||||
# Thumbnail URLs traverse result.snapshot too; share the page's
|
||||
# already-loaded parent rather than fetching it once per result.
|
||||
result.snapshot = snapshots_by_id[snapshot_key]
|
||||
# These read-only page objects are shared by icons, sizes, and
|
||||
# thumbnails. Normalize each manifest once for this render.
|
||||
result._render_output_file_map = result.output_file_map()
|
||||
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
|
||||
|
||||
def get_exact_public_snapshot_count(self) -> int:
|
||||
hidden_count = Snapshot.objects.filter(permissions=PERMISSIONS_PRIVATE).count()
|
||||
hidden_count += Snapshot.objects.filter(permissions=PERMISSIONS_UNLISTED).count()
|
||||
return Snapshot.objects.count() - hidden_count
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
qs = public_snapshots_queryset(super().get_queryset(**kwargs)).only(*self._base_public_snapshot_fields())
|
||||
qs = (
|
||||
public_snapshots_queryset(super().get_queryset(**kwargs))
|
||||
.select_related("crawl__created_by")
|
||||
.only(*self._base_public_snapshot_fields())
|
||||
)
|
||||
query = self.request.GET.get("q", default="").strip()
|
||||
|
||||
if not query:
|
||||
@@ -1560,7 +1694,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)
|
||||
@@ -1600,7 +1734,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
|
||||
@@ -1632,7 +1766,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):
|
||||
|
||||
@@ -717,7 +717,7 @@ class InlineTagEditorWidget(TagEditorWidget):
|
||||
)
|
||||
pills_html += f'''
|
||||
<span class="tag-pill" data-tag="{self._escape(td["name"])}" data-tag-id="{td["id"]}" style="{self._tag_style(td["name"])}">
|
||||
<a href="/admin/core/snapshot/?tags__id__exact={td["id"]}" class="tag-link">{self._escape(td["name"])}</a>
|
||||
<a href="/admin/core/snapshot/?tags__id__exact={td["id"]}" class="tag-link" title="{self._escape(td["name"])}">{self._escape(td["name"])}</a>
|
||||
{remove_button}
|
||||
</span>
|
||||
'''
|
||||
|
||||
+39
-10
@@ -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>
|
||||
|
||||
+20
-17
@@ -846,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:
|
||||
@@ -1250,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -221,35 +221,21 @@ class Machine(ModelWithHealthStats):
|
||||
app_label = "machine"
|
||||
|
||||
@classmethod
|
||||
def current(cls, refresh: bool = False) -> Machine:
|
||||
global _CURRENT_MACHINE
|
||||
if refresh:
|
||||
_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
|
||||
# stays sub-microsecond.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
def current_readonly(cls) -> Machine | None:
|
||||
"""Read the current machine without registration, refresh, or config writes."""
|
||||
if _CURRENT_MACHINE is not None:
|
||||
return _CURRENT_MACHINE
|
||||
return cls.objects.filter(guid=get_host_guid()).first()
|
||||
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
return machine
|
||||
else:
|
||||
_CURRENT_MACHINE = None
|
||||
@classmethod
|
||||
def current(cls) -> Machine:
|
||||
global _CURRENT_MACHINE
|
||||
if _CURRENT_MACHINE is not None:
|
||||
return _CURRENT_MACHINE
|
||||
|
||||
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.
|
||||
# Host detection, sanitation, and config reconciliation run only on
|
||||
# initial registration, never on cached reads.
|
||||
try:
|
||||
machine = cls.objects.get(guid=host_guid)
|
||||
except cls.DoesNotExist:
|
||||
@@ -296,17 +282,14 @@ class Machine(ModelWithHealthStats):
|
||||
],
|
||||
)
|
||||
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.
|
||||
# Reconcile once, on the first Machine.current() call in this process.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
|
||||
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.
|
||||
# Publish only after initialization is complete.
|
||||
_CURRENT_MACHINE = machine
|
||||
return machine
|
||||
|
||||
@@ -377,18 +360,15 @@ class Machine(ModelWithHealthStats):
|
||||
if update_fields is not None:
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
|
||||
|
||||
# Drop the ``Machine.current()`` module-level cache on every save so
|
||||
# config edits (admin form, from_json, etc.) become live in the same
|
||||
# process without waiting out the 7-day ``MACHINE_RECHECK_INTERVAL``.
|
||||
# The cache reads ``_CURRENT_MACHINE.modified_at`` and that value
|
||||
# never moves forward on the cached object even when the row is
|
||||
# updated in the DB, so without this we'd keep serving stale
|
||||
# ``machine.config`` (incl. ``BASE_URL``) until the worker restarts.
|
||||
# Apply explicit edits to the cached instance without making the next
|
||||
# read repeat host detection, sanitation, or config reconciliation.
|
||||
update_fields = kwargs.get("update_fields")
|
||||
super().save(*args, **kwargs)
|
||||
global _CURRENT_MACHINE
|
||||
if _CURRENT_MACHINE is not None and _CURRENT_MACHINE.pk == self.pk:
|
||||
_CURRENT_MACHINE = None
|
||||
for field in self._meta.concrete_fields:
|
||||
if field.attname in self.__dict__ and (update_fields is None or field.name in update_fields):
|
||||
setattr(_CURRENT_MACHINE, field.attname, self.__dict__[field.attname])
|
||||
|
||||
# Mirror Machine.config into ArchiveBox.conf so the two stores stay
|
||||
# 1:1. Skipped when ``update_fields`` is set and doesn't touch
|
||||
@@ -443,7 +423,7 @@ class NetworkInterface(ModelWithHealthStats):
|
||||
@classmethod
|
||||
def current(cls, refresh: bool = False) -> NetworkInterface:
|
||||
global _CURRENT_INTERFACE
|
||||
machine = Machine.current(refresh=refresh)
|
||||
machine = Machine.current()
|
||||
if _CURRENT_INTERFACE and _CURRENT_INTERFACE.machine_id == machine.id:
|
||||
if not refresh:
|
||||
# Callers that pass refresh=False are asking for attribution to
|
||||
@@ -1778,7 +1758,8 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
return None
|
||||
|
||||
# Can't validate processes on other machines
|
||||
if self.machine_id != Machine.current().id:
|
||||
machine = Machine.current_readonly()
|
||||
if machine is None or self.machine_id != machine.id:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
+13
-3
@@ -445,7 +445,10 @@ def migration_lock(stdout: TextIO | None = None):
|
||||
|
||||
|
||||
# Migration names that previously existed in ArchiveBox's source tree but have
|
||||
# since been deleted (squashed away, renamed, moved between apps, etc.). DBs
|
||||
# since been deleted (squashed away, renamed, moved between apps, etc.), plus
|
||||
# exact older migrations generated in the installed package by v0.6.2's
|
||||
# startup makemigrations. Those runtime-generated names were never in Git, so
|
||||
# the deleted-file history alone cannot cover them. DBs
|
||||
# upgraded incrementally through 0.8.x → 0.9.x dev rcs accumulate rows for
|
||||
# these in ``django_migrations``; the newer-DB guard added in 65dc2521 would
|
||||
# otherwise refuse to start with "applied migrations missing from this build"
|
||||
@@ -454,13 +457,18 @@ def migration_lock(stdout: TextIO | None = None):
|
||||
# splits the migration graph when only a *subset* of the replaces list is
|
||||
# applied (which is exactly what happens for users at different intermediate
|
||||
# dev branch states). This set is the authoritative compat list — extend it
|
||||
# when squashing more migrations away. Generated from
|
||||
# ``git log --diff-filter=D --name-only`` over each app's migrations/ tree.
|
||||
# when squashing more migrations away. The source-tracked names were generated
|
||||
# from ``git log --diff-filter=D --name-only`` over each app's migrations/ tree.
|
||||
HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
# core: 0023→0075 sequence plus every transient dev rename
|
||||
("core", "0002_auto_20190417_0739"),
|
||||
("core", "0006_auto_20200915_2006"),
|
||||
# Reported v0.6.2 runtime-generated 0021 migrations; the checked-in
|
||||
# replacement only changes ArchiveResult.extractor choices.
|
||||
("core", "0021_auto_20220510_0644"),
|
||||
("core", "0021_auto_20220724_1254"),
|
||||
("core", "0021_auto_20221128_1116"),
|
||||
("core", "0023_alter_archiveresult_options_archiveresult_abid_and_more"),
|
||||
("core", "0023_new_schema"),
|
||||
("core", "0024_auto_20240513_1143"),
|
||||
@@ -518,6 +526,8 @@ HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
|
||||
("core", "0053_remove_snapshottag_snapshot_old"),
|
||||
("core", "0054_alter_snapshot_timestamp"),
|
||||
("core", "0055_alter_tag_slug"),
|
||||
# Dev-only progress indexes removed in favor of existing-index queries.
|
||||
("core", "0056_progress_covering_indexes"),
|
||||
("core", "0056_remove_tag_uuid"),
|
||||
("core", "0057_rename_id_tag_old_id"),
|
||||
("core", "0058_alter_tag_old_id"),
|
||||
|
||||
@@ -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,8 +29,10 @@ 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 {
|
||||
@@ -527,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():
|
||||
@@ -561,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
|
||||
|
||||
@@ -572,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
|
||||
@@ -589,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:
|
||||
@@ -620,7 +667,7 @@ def _set_transformed_response_headers(
|
||||
|
||||
|
||||
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,
|
||||
@@ -893,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
|
||||
@@ -908,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}, 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,
|
||||
@@ -919,7 +975,14 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
)
|
||||
|
||||
content_type, encoding = mimetypes.guess_type(str(fullpath))
|
||||
preserve_plain_text = fullpath.suffix.lower() in {".log", ".sh"}
|
||||
# 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"
|
||||
@@ -936,21 +999,19 @@ 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"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
return _apply_archive_replay_headers(
|
||||
not_modified,
|
||||
fullpath=fullpath,
|
||||
@@ -976,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:
|
||||
@@ -996,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,
|
||||
@@ -1017,22 +1078,24 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
),
|
||||
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 not preserve_plain_text and 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:
|
||||
@@ -1046,7 +1109,7 @@ 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)
|
||||
@@ -1100,6 +1163,8 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
|
||||
if content_type.startswith("image/"):
|
||||
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):
|
||||
|
||||
+15
-103
@@ -3,7 +3,7 @@ __package__ = "archivebox.misc"
|
||||
# Bootable utility functions (URL parsing, date parsing, JSON encoding, decorators).
|
||||
# MUST NOT import archivebox.config, archivebox.core, or Django — this module is
|
||||
# loaded by hooks.py and other early code paths. Only depends on stdlib and the
|
||||
# bootable .logging module.
|
||||
# bootable .logging module and standalone abx_plugins URL helpers.
|
||||
|
||||
import re
|
||||
import json as pyjson
|
||||
@@ -19,6 +19,9 @@ from urllib.parse import urlparse, quote, unquote
|
||||
from html import escape, unescape
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from abx_plugins.plugins.base.url_cleaning import URL_REGEX, fix_url_from_markdown
|
||||
from abx_plugins.plugins.base.url_cleaning import sanitize_extracted_url as sanitize_plugin_url
|
||||
|
||||
from .logging import COLOR_DICT
|
||||
|
||||
|
||||
@@ -90,18 +93,6 @@ def ts_to_date_str(ts: Any) -> str | None:
|
||||
COLOR_REGEX = re.compile(r"\[(?P<arg_1>\d+)(;(?P<arg_2>\d+)(;(?P<arg_3>\d+))?)?m")
|
||||
|
||||
|
||||
# https://mathiasbynens.be/demo/url-regex
|
||||
URL_REGEX = re.compile(
|
||||
r"(?=("
|
||||
r"http[s]?://" # start matching from allowed schemes
|
||||
r"(?:[a-zA-Z]|[0-9]" # followed by allowed alphanum characters
|
||||
r"|[-_$@.&+!*\(\),]" # or allowed symbols (keep hyphen first to match literal hyphen)
|
||||
r"|[^\u0000-\u007F])+" # or allowed unicode bytes
|
||||
r'[^\]\[<>"\'\s]+' # stop parsing at these symbols
|
||||
r"))",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
# Maximum supported URL length. Very long URLs are rare but must be supported correctly
|
||||
# (e.g. data: URLs, deeply nested query strings). The Snapshot.url column is stored as a
|
||||
# variable-length TextField (so short URLs don't reserve space and very long URLs still
|
||||
@@ -109,29 +100,6 @@ URL_REGEX = re.compile(
|
||||
# all keep working.
|
||||
MAX_URL_LENGTH = 65535
|
||||
|
||||
QUOTE_DELIMITERS = (
|
||||
'"',
|
||||
"'",
|
||||
"`",
|
||||
"“",
|
||||
"”",
|
||||
"‘",
|
||||
"’",
|
||||
)
|
||||
QUOTE_ENTITY_DELIMITERS = (
|
||||
""",
|
||||
""",
|
||||
""",
|
||||
"'",
|
||||
"'",
|
||||
"'",
|
||||
)
|
||||
URL_ENTITY_REPLACEMENTS = (
|
||||
("&", "&"),
|
||||
("&", "&"),
|
||||
("&", "&"),
|
||||
)
|
||||
|
||||
FILESIZE_UNITS: dict[str, int] = {
|
||||
"": 1,
|
||||
"b": 1,
|
||||
@@ -153,36 +121,9 @@ FILESIZE_UNITS: dict[str, int] = {
|
||||
|
||||
|
||||
def sanitize_extracted_url(url: str) -> str:
|
||||
"""Trim quote garbage and dangling prose punctuation from an extracted URL candidate."""
|
||||
cleaned = (url or "").strip()
|
||||
if not cleaned:
|
||||
return cleaned
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
cut_index = len(cleaned)
|
||||
|
||||
for delimiter in QUOTE_DELIMITERS:
|
||||
found_index = cleaned.find(delimiter)
|
||||
if found_index != -1:
|
||||
cut_index = min(cut_index, found_index)
|
||||
|
||||
for delimiter in QUOTE_ENTITY_DELIMITERS:
|
||||
found_index = lower_cleaned.find(delimiter)
|
||||
if found_index != -1:
|
||||
cut_index = min(cut_index, found_index)
|
||||
|
||||
cleaned = cleaned[:cut_index].strip()
|
||||
lower_cleaned = cleaned.lower()
|
||||
for entity, replacement in URL_ENTITY_REPLACEMENTS:
|
||||
while entity in lower_cleaned:
|
||||
entity_index = lower_cleaned.find(entity)
|
||||
cleaned = cleaned[:entity_index] + replacement + cleaned[entity_index + len(entity) :]
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
cleaned = cleaned.rstrip(".,;:!?\\'\"")
|
||||
cleaned = cleaned.rstrip('"')
|
||||
|
||||
return cleaned
|
||||
"""Use the standalone parser's quote rules, then trim dangling prose punctuation."""
|
||||
cleaned = sanitize_plugin_url(url)
|
||||
return cleaned.rstrip('.,;:!?\\"')
|
||||
|
||||
|
||||
def validate_url_length(url: str) -> str:
|
||||
@@ -215,39 +156,6 @@ def parens_are_matched(string: str, open_char="(", close_char=")"):
|
||||
return count == 0
|
||||
|
||||
|
||||
def fix_url_from_markdown(url_str: str) -> str:
|
||||
"""
|
||||
cleanup a regex-parsed url that may contain dangling trailing parens from markdown link syntax
|
||||
helpful to fix URLs parsed from markdown e.g.
|
||||
input: https://wikipedia.org/en/some_article_(Disambiguation).html?abc=def).somemoretext
|
||||
result: https://wikipedia.org/en/some_article_(Disambiguation).html?abc=def
|
||||
|
||||
IMPORTANT ASSUMPTION: valid urls wont have unbalanced or incorrectly nested parentheses
|
||||
e.g. this will fail the user actually wants to ingest a url like 'https://example.com/some_wei)(rd_url'
|
||||
in that case it will return https://example.com/some_wei (truncated up to the first unbalanced paren)
|
||||
This assumption is true 99.9999% of the time, and for the rare edge case the user can use url_list parser.
|
||||
"""
|
||||
trimmed_url = url_str
|
||||
if len(trimmed_url) > 2048:
|
||||
return trimmed_url
|
||||
|
||||
# cut off one trailing character at a time
|
||||
# until parens are balanced e.g. /a(b)c).x(y)z -> /a(b)c
|
||||
trim_attempts = 0
|
||||
while trimmed_url and not parens_are_matched(trimmed_url) and trim_attempts < 256:
|
||||
trimmed_url = trimmed_url[:-1]
|
||||
trim_attempts += 1
|
||||
|
||||
if not trimmed_url or not parens_are_matched(trimmed_url):
|
||||
return url_str
|
||||
|
||||
# make sure trimmed url is still valid
|
||||
if any(match == trimmed_url for match in re.findall(URL_REGEX, trimmed_url)):
|
||||
return trimmed_url
|
||||
|
||||
return url_str
|
||||
|
||||
|
||||
def split_comma_separated_urls(url: str):
|
||||
offset = 0
|
||||
while True:
|
||||
@@ -274,7 +182,8 @@ def find_all_urls(urls_str: str):
|
||||
if match.start() in skipped_starts:
|
||||
continue
|
||||
|
||||
cleaned_match = sanitize_extracted_url(fix_url_from_markdown(match.group(1)))
|
||||
cleaned_match = fix_url_from_markdown(match.group(1), preceding_text=urls_str[max(0, match.start(1) - 6) : match.start(1)])
|
||||
cleaned_match = sanitize_extracted_url(cleaned_match)
|
||||
for offset, url in split_comma_separated_urls(cleaned_match):
|
||||
if offset:
|
||||
skipped_starts.add(match.start() + offset)
|
||||
@@ -388,9 +297,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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,7 +2,9 @@ __package__ = "archivebox.plugins"
|
||||
|
||||
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
|
||||
@@ -66,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]:
|
||||
@@ -151,12 +286,10 @@ 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"
|
||||
|
||||
catalog = get_plugin_catalog()
|
||||
if base_name in catalog:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Snapshot output taxonomy. Display preference is independent of hook order."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from archivebox.plugins.discovery import (
|
||||
get_extra_snapshot_output_cards,
|
||||
get_plugin_catalog,
|
||||
get_plugin_name,
|
||||
get_snapshot_thumbnail_card_order,
|
||||
)
|
||||
|
||||
_GROUP_LABELS = (
|
||||
("html", "HTML"),
|
||||
("raster", "Raster"),
|
||||
("article_text", "Article text"),
|
||||
("embedded_media", "Embedded media"),
|
||||
("metadata", "Metadata"),
|
||||
("other", "Other files"),
|
||||
)
|
||||
|
||||
|
||||
def _plugin_presentation() -> dict[str, dict[str, Any]]:
|
||||
presentation = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
manifest = plugin.manifest
|
||||
order = manifest.get("snapshot_output_order")
|
||||
presentation[plugin.name] = {
|
||||
"group": manifest.get("snapshot_output_group") or "other",
|
||||
"order": order if isinstance(order, int) and not isinstance(order, bool) else None,
|
||||
"display_name": manifest.get("snapshot_display_name"),
|
||||
"icon_hidden": bool(manifest.get("icon_hidden")),
|
||||
"card_hidden": bool(manifest.get("card_hidden")),
|
||||
}
|
||||
return presentation
|
||||
|
||||
|
||||
PLUGIN_PRESENTATION = _plugin_presentation()
|
||||
OUTPUT_GROUPS = tuple(
|
||||
(
|
||||
group_id,
|
||||
label,
|
||||
tuple(
|
||||
name
|
||||
for name, metadata in sorted(
|
||||
PLUGIN_PRESENTATION.items(),
|
||||
key=lambda item: (item[1]["order"] is None, item[1]["order"] or 0, item[0]),
|
||||
)
|
||||
if metadata["group"] == group_id and metadata["order"] is not None
|
||||
),
|
||||
)
|
||||
for group_id, label in _GROUP_LABELS
|
||||
)
|
||||
UNORDERED_OUTPUTS = {
|
||||
group_id: {name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["group"] == group_id and metadata["order"] is None}
|
||||
for group_id, _label in _GROUP_LABELS
|
||||
}
|
||||
HIDDEN_ICON_PLUGINS = frozenset(name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["icon_hidden"])
|
||||
HIDDEN_CARD_PLUGINS = frozenset(name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["card_hidden"])
|
||||
|
||||
|
||||
def output_group_for_plugin(plugin: str) -> str:
|
||||
"""Return the snapshot-detail stack that contains a plugin output."""
|
||||
plugin = get_plugin_name(plugin)
|
||||
for group_id, _, plugins in OUTPUT_GROUPS:
|
||||
if plugin in plugins or plugin in UNORDERED_OUTPUTS.get(group_id, set()):
|
||||
return group_id
|
||||
return "other"
|
||||
|
||||
|
||||
def plugin_icon_is_hidden(plugin: str) -> bool:
|
||||
return get_plugin_name(plugin) in HIDDEN_ICON_PLUGINS
|
||||
|
||||
|
||||
def plugin_card_is_hidden(plugin: str) -> bool:
|
||||
return get_plugin_name(plugin) in HIDDEN_CARD_PLUGINS
|
||||
|
||||
|
||||
def display_plugin_name(plugin: str) -> str:
|
||||
canonical = get_plugin_name(plugin)
|
||||
return PLUGIN_PRESENTATION.get(canonical, {}).get("display_name") or plugin.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def plugin_output_sizes(results) -> dict[str, int]:
|
||||
"""Count each plugin's files once, even when several hooks report them."""
|
||||
files_by_plugin: dict[str, dict[str, int]] = {}
|
||||
for result in results:
|
||||
files = files_by_plugin.setdefault(result.plugin, {})
|
||||
for path, metadata in result.output_file_map().items():
|
||||
files[path] = result._coerce_output_file_size(metadata.get("size"))
|
||||
return {plugin: sum(files.values()) for plugin, files in files_by_plugin.items() if files}
|
||||
|
||||
|
||||
def order_output_plugins(plugins, sizes: dict[str, int]) -> list[str]:
|
||||
"""Use the detail-card ordering for icon piles, without synthetic cards."""
|
||||
return [output["name"] for output in order_snapshot_outputs([{"name": plugin, "size": sizes.get(plugin, 0)} for plugin in plugins])]
|
||||
|
||||
|
||||
def snapshot_thumbnail_result(results):
|
||||
"""Select the first available card using plugin-owned thumbnail priority."""
|
||||
card_order = get_snapshot_thumbnail_card_order()
|
||||
candidates = []
|
||||
for result in results:
|
||||
if result.plugin not in card_order:
|
||||
continue
|
||||
try:
|
||||
path = result.embed_path()
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if not path:
|
||||
continue
|
||||
candidates.append((card_order[result.plugin], str(result.id), result))
|
||||
return min(candidates, default=(None, None, None))[2]
|
||||
|
||||
|
||||
def order_snapshot_outputs(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Annotate and order available cards; unknown plugins stay visible in Other files."""
|
||||
group_order = {group_id: index for index, (group_id, _, _) in enumerate(OUTPUT_GROUPS)}
|
||||
preferences = {}
|
||||
for group_id, _, plugins in OUTPUT_GROUPS:
|
||||
for rank, plugin in enumerate(plugins):
|
||||
preferences[plugin] = (group_id, rank)
|
||||
for group_id, plugins in UNORDERED_OUTPUTS.items():
|
||||
for plugin in plugins:
|
||||
preferences[plugin] = (group_id, None)
|
||||
|
||||
if cards := get_extra_snapshot_output_cards(outputs):
|
||||
outputs = [*outputs, *cards]
|
||||
|
||||
def sort_key(output):
|
||||
plugin = get_plugin_name(output["name"])
|
||||
declared_group = output.get("output_group")
|
||||
declared_order = output.get("output_order")
|
||||
if declared_group in group_order and isinstance(declared_order, int) and not isinstance(declared_order, bool):
|
||||
group_id, rank = declared_group, declared_order
|
||||
else:
|
||||
group_id, rank = preferences.get(plugin, ("other", None))
|
||||
output["output_group"] = group_id
|
||||
return (group_order[group_id], rank is None, rank if rank is not None else -int(output.get("size") or 0), plugin)
|
||||
|
||||
return sorted(outputs, key=sort_key)
|
||||
@@ -12,9 +12,62 @@ from django.db.models import Count, Sum
|
||||
_refresh_lock = Lock()
|
||||
|
||||
|
||||
def collection_summary(user):
|
||||
def crawl_summary(crawl_id, *, snapshot_id=None):
|
||||
"""Sample sealed snapshot metadata without adding table scans to polls."""
|
||||
key = f"progress-crawl:{crawl_id}:{snapshot_id or 'all'}"
|
||||
summary = cache.get(key)
|
||||
if (summary is None or time() - summary["sampled_at"] >= 30) and _refresh_lock.acquire(blocking=False):
|
||||
if not cache.add(f"{key}:refresh", True, timeout=30):
|
||||
_refresh_lock.release()
|
||||
return summary
|
||||
|
||||
def refresh():
|
||||
try:
|
||||
close_old_connections()
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = Snapshot.objects.filter(crawl_id=crawl_id, status=Snapshot.StatusChoices.SEALED)
|
||||
if snapshot_id is not None:
|
||||
snapshots = snapshots.filter(pk=snapshot_id)
|
||||
snapshots = snapshots.order_by("modified_at").values_list("modified_at", "downloaded_at", "output_size")
|
||||
totals = {"snapshots": 0, "cancelled": 0, "bytes": 0}
|
||||
modified_at, offset = None, 0
|
||||
while True:
|
||||
batch_scope = snapshots.filter(modified_at__gte=modified_at) if modified_at is not None else snapshots
|
||||
# Use (crawl, status, modified_at), releasing the SQLite read
|
||||
# cursor after each small batch. Never hold a cursor over the
|
||||
# whole crawl, even in this background worker.
|
||||
batch = list(batch_scope[offset : offset + 100])
|
||||
for _, downloaded_at, size in batch:
|
||||
totals["snapshots"] += 1
|
||||
totals["cancelled"] += downloaded_at is None
|
||||
totals["bytes"] += size or 0
|
||||
if len(batch) < 100:
|
||||
break
|
||||
last_modified_at = batch[-1][0]
|
||||
# Only offset within ties; otherwise advance the indexed
|
||||
# timestamp range instead of rescanning previous pages.
|
||||
offset = (offset if last_modified_at == modified_at else 0) + sum(row[0] == last_modified_at for row in batch)
|
||||
modified_at = last_modified_at
|
||||
totals["sampled_at"] = time()
|
||||
cache.set(key, totals, timeout=3600)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not refresh crawl totals")
|
||||
finally:
|
||||
connections.close_all()
|
||||
_refresh_lock.release()
|
||||
|
||||
try:
|
||||
Thread(target=refresh, name="crawl-summary", daemon=True).start()
|
||||
except RuntimeError:
|
||||
_refresh_lock.release()
|
||||
logging.getLogger(__name__).exception("Could not start crawl totals refresh")
|
||||
return summary
|
||||
|
||||
|
||||
def collection_summary(user, *, public=False):
|
||||
user_id = None if user.is_superuser else user.pk
|
||||
key = f"progress-collection:{'all' if user_id is None else user_id}"
|
||||
key = "progress-collection:public" if public else f"progress-collection:{'all' if user_id is None else user_id}"
|
||||
summary = cache.get(key)
|
||||
# Even an indexed SUM scans the index. Never make an HTTP request wait for
|
||||
# that scan on a large collection. Serve stale totals while one worker refreshes;
|
||||
@@ -32,12 +85,23 @@ def collection_summary(user):
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = Snapshot.objects.all()
|
||||
if user_id is not None:
|
||||
snapshots = snapshots.filter(crawl__created_by_id=user_id)
|
||||
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
|
||||
# Snapshot.output_size is maintained when ArchiveResult sizes change.
|
||||
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
totals["bytes"] = totals["bytes"] or 0
|
||||
if public:
|
||||
from archivebox.core.permissions import PERMISSIONS_PUBLIC
|
||||
|
||||
# SQLite can choose a table scan for equality after ANALYZE
|
||||
# on an all-public archive. Equal range bounds keep the count
|
||||
# on the existing permissions index. It is still O(n), so
|
||||
# public pages share this background cache, never await it.
|
||||
totals = snapshots.filter(permissions__gte=PERMISSIONS_PUBLIC, permissions__lte=PERMISSIONS_PUBLIC).aggregate(
|
||||
snapshots=Count("permissions"),
|
||||
)
|
||||
else:
|
||||
if user_id is not None:
|
||||
snapshots = snapshots.filter(crawl__created_by_id=user_id)
|
||||
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
|
||||
# Snapshot.output_size is maintained when ArchiveResult sizes change.
|
||||
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
totals["bytes"] = totals.get("bytes") or 0
|
||||
totals["sampled_at"] = time()
|
||||
cache.set(key, totals, timeout=3600)
|
||||
except Exception:
|
||||
|
||||
@@ -549,6 +549,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 84px;
|
||||
height: 52px;
|
||||
flex: 0 0 84px;
|
||||
@@ -570,6 +571,17 @@
|
||||
object-position: top center;
|
||||
display: block;
|
||||
}
|
||||
#progress-monitor .snapshot-preview img:not([data-loaded]),
|
||||
#progress-monitor .snapshot-favicon:not([data-loaded]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
#progress-monitor .snapshot-preview-loading {
|
||||
position: absolute;
|
||||
font-size: 20px;
|
||||
}
|
||||
#progress-monitor .snapshot-preview img[data-loaded] + .snapshot-preview-loading {
|
||||
display: none;
|
||||
}
|
||||
#progress-monitor .snapshot-preview.placeholder {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -580,12 +592,17 @@
|
||||
#progress-monitor .snapshot-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
position: relative;
|
||||
padding-left: 23px;
|
||||
min-width: 0;
|
||||
}
|
||||
#progress-monitor .snapshot-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
@@ -1061,54 +1078,133 @@
|
||||
}
|
||||
function stableSnapshotMedia(snapshot) {
|
||||
// The progress endpoint can briefly omit media URLs while ArchiveResult
|
||||
// rows settle. Once this page has seen a snapshot image URL, keep using
|
||||
// it so the preview/favicon branch does not flicker back to placeholder.
|
||||
// rows settle. Keep displaying the last *loaded* image while probing
|
||||
// new URLs offscreen: an output URL may 404 until its capture finishes.
|
||||
const snapshotId = String(snapshot.id || '');
|
||||
const media = snapshotMedia.get(snapshotId) || {};
|
||||
if (snapshot.favicon_url) media.favicon_url ||= snapshot.favicon_url;
|
||||
if (snapshot.favicon_url && snapshot.favicon_url !== media.favicon_url) {
|
||||
media.favicon_url = snapshot.favicon_url;
|
||||
media.favicon_next_probe = 0;
|
||||
media.favicon_retry_delay = 5000;
|
||||
}
|
||||
if (snapshot.preview_url) {
|
||||
media.preview_url ||= snapshot.preview_url;
|
||||
media.preview_link ||= snapshot.preview_link;
|
||||
media.preview_fallbacks ||= snapshot.preview_fallbacks || [];
|
||||
if (snapshot.preview_url !== media.preview_url) {
|
||||
media.preview_url = snapshot.preview_url;
|
||||
media.preview_next_probe = 0;
|
||||
media.preview_retry_delay = 5000;
|
||||
}
|
||||
media.preview_link = snapshot.preview_link || media.preview_link;
|
||||
}
|
||||
if (snapshotId) snapshotMedia.set(snapshotId, media);
|
||||
probeSnapshotImage(media, 'favicon');
|
||||
probeSnapshotImage(media, 'preview');
|
||||
return media;
|
||||
}
|
||||
function preserveStableMediaNodes(nextRoot) {
|
||||
// Snapshot preview/favicon files are immutable once visible. Keep the
|
||||
// loaded DOM nodes across poll renders so the browser never reloads
|
||||
// them while progress text/badges continue updating around them.
|
||||
const currentCards = new Map(
|
||||
Array.from(crawlTree.querySelectorAll('.snapshot-item[data-snapshot-id]')).map(card => [card.dataset.snapshotId, card])
|
||||
);
|
||||
nextRoot.querySelectorAll('.snapshot-item[data-snapshot-id]').forEach(nextCard => {
|
||||
const currentCard = currentCards.get(nextCard.dataset.snapshotId);
|
||||
if (!currentCard) return;
|
||||
currentCard.querySelectorAll('[data-stable-media]').forEach(currentNode => {
|
||||
const nextNode = nextCard.querySelector(`[data-stable-media="${currentNode.dataset.stableMedia}"]`);
|
||||
if (nextNode) nextNode.replaceWith(currentNode);
|
||||
});
|
||||
});
|
||||
function probeSnapshotImage(media, kind) {
|
||||
const url = media[`${kind}_url`];
|
||||
if (!url || media[`${kind}_ready_url`] === url || media[`${kind}_probing`] === url || Date.now() < (media[`${kind}_next_probe`] || 0)) return;
|
||||
media[`${kind}_probing`] = url;
|
||||
const probe = new Image();
|
||||
probe.onload = function() {
|
||||
if (media[`${kind}_url`] !== url) return;
|
||||
media[`${kind}_ready_url`] = url;
|
||||
media[`${kind}_ready_src`] = probe.src;
|
||||
media[`${kind}_probing`] = '';
|
||||
media[`${kind}_retry_delay`] = 5000;
|
||||
};
|
||||
probe.onerror = function() {
|
||||
if (media[`${kind}_url`] !== url) return;
|
||||
media[`${kind}_probing`] = '';
|
||||
const delay = media[`${kind}_retry_delay`] || 5000;
|
||||
media[`${kind}_next_probe`] = Date.now() + delay;
|
||||
media[`${kind}_retry_delay`] = Math.min(delay * 2, 30000);
|
||||
};
|
||||
// A previous 404 may be cached. Bust only the background probe URL;
|
||||
// the visible card keeps its loaded image or fixed-size placeholder.
|
||||
const probeUrl = new URL(url, window.location.href);
|
||||
probeUrl.searchParams.set('progress_retry', String(Date.now()));
|
||||
probe.src = probeUrl.href;
|
||||
}
|
||||
function sameProgressNode(current, next) {
|
||||
if (current.nodeType !== next.nodeType) return false;
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return true;
|
||||
if (current.tagName !== next.tagName) return false;
|
||||
for (const key of ['crawlId', 'snapshotId', 'stableMedia']) {
|
||||
if ((current.dataset[key] || next.dataset[key]) && current.dataset[key] !== next.dataset[key]) return false;
|
||||
}
|
||||
return (current.className || '').split(' ')[0] === (next.className || '').split(' ')[0];
|
||||
}
|
||||
function reconcileProgressNode(current, next) {
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
if (current.textContent !== next.textContent) current.textContent = next.textContent;
|
||||
return;
|
||||
}
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return;
|
||||
// Never detach a loaded image on a poll. Its URL only changes when the
|
||||
// progress API reports a new output; keep a successful fallback too.
|
||||
if (current.dataset.stableMedia && current.dataset.mediaUrl === next.dataset.mediaUrl) {
|
||||
if (current.tagName === 'A' && current.getAttribute('href') !== next.getAttribute('href')) {
|
||||
current.setAttribute('href', next.getAttribute('href'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const attr of Array.from(current.attributes)) {
|
||||
if (!next.hasAttribute(attr.name)) current.removeAttribute(attr.name);
|
||||
}
|
||||
for (const attr of Array.from(next.attributes)) {
|
||||
if (current.getAttribute(attr.name) !== attr.value) current.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
reconcileProgressChildren(current, next);
|
||||
}
|
||||
function reconcileProgressChildren(current, next) {
|
||||
let position = 0;
|
||||
const nextNodes = Array.from(next.childNodes);
|
||||
for (const nextChild of nextNodes) {
|
||||
let child = current.childNodes[position];
|
||||
if (!child) {
|
||||
current.appendChild(nextChild);
|
||||
} else if (sameProgressNode(child, nextChild)) {
|
||||
reconcileProgressNode(child, nextChild);
|
||||
} else {
|
||||
const laterNext = nextNodes.slice(position + 1);
|
||||
if (laterNext.some(node => sameProgressNode(child, node))) {
|
||||
// A new optional node (such as a favicon) precedes the old
|
||||
// one. Insert it without detaching the old loaded image.
|
||||
current.insertBefore(nextChild, child);
|
||||
} else if (Array.from(current.childNodes).slice(position + 1).some(node => sameProgressNode(node, nextChild))) {
|
||||
// The old optional node disappeared; remove only that node.
|
||||
child.remove();
|
||||
reconcileProgressNode(current.childNodes[position], nextChild);
|
||||
} else {
|
||||
child.replaceWith(nextChild);
|
||||
}
|
||||
}
|
||||
position += 1;
|
||||
}
|
||||
while (current.childNodes.length > position) current.lastChild.remove();
|
||||
}
|
||||
function replaceCrawlTree(html) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
preserveStableMediaNodes(template.content);
|
||||
crawlTree.replaceChildren(...template.content.childNodes);
|
||||
reconcileProgressChildren(crawlTree, template.content);
|
||||
}
|
||||
window.nextPreviewFallback = function(img) {
|
||||
const fallbacks = (img.dataset.fallbacks || '').split(',').filter(Boolean);
|
||||
if (fallbacks.length > 0) {
|
||||
img.src = fallbacks.shift();
|
||||
img.dataset.fallbacks = fallbacks.join(',');
|
||||
} else {
|
||||
const preview = img.closest('.snapshot-preview');
|
||||
if (preview) {
|
||||
preview.removeAttribute('data-stable-media');
|
||||
preview.classList.add('placeholder');
|
||||
preview.innerHTML = '<span>▤</span>';
|
||||
window.failedProgressImage = function(img) {
|
||||
const snapshotId = img.closest('.snapshot-item')?.dataset.snapshotId;
|
||||
const media = snapshotMedia.get(snapshotId);
|
||||
if (media) {
|
||||
for (const kind of ['favicon', 'preview']) {
|
||||
if (media[`${kind}_ready_src`] === img.src) {
|
||||
media[`${kind}_ready_url`] = '';
|
||||
media[`${kind}_ready_src`] = '';
|
||||
media[`${kind}_next_probe`] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
const preview = img.closest('.snapshot-preview');
|
||||
if (preview) {
|
||||
preview.classList.add('placeholder');
|
||||
preview.innerHTML = '<span>▤</span>';
|
||||
} else img.remove();
|
||||
};
|
||||
function formatUrl(url) {
|
||||
if (!url) return '(no URL)';
|
||||
@@ -1466,11 +1562,13 @@
|
||||
const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url);
|
||||
const urlText = snapshot.full_url || snapshot.url || '';
|
||||
const media = stableSnapshotMedia(snapshot);
|
||||
const faviconHtml = media.favicon_url
|
||||
? `<img class="snapshot-favicon" data-stable-media="favicon" src="${escapeAttr(media.favicon_url)}" alt="" decoding="async" loading="lazy" onerror="this.remove()">`
|
||||
const faviconHtml = media.favicon_ready_src
|
||||
? `<img class="snapshot-favicon" data-stable-media="favicon" data-media-url="${escapeAttr(media.favicon_ready_url)}" src="${escapeAttr(media.favicon_ready_src)}" alt="" decoding="async" loading="lazy" onload="this.dataset.loaded='1'" onerror="failedProgressImage(this)">`
|
||||
: '';
|
||||
const previewHtml = media.preview_url
|
||||
? `<a class="snapshot-preview" data-stable-media="preview" href="${escapeAttr(media.preview_link || snapshot.view_url || adminUrl)}" title="Open snapshot output"><img src="${escapeAttr(media.preview_url)}" alt="" decoding="async" loading="lazy" data-fallbacks="${escapeAttr((media.preview_fallbacks || []).join(','))}" onerror="nextPreviewFallback(this)"></a>`
|
||||
const previewSrc = media.preview_ready_src || media.favicon_ready_src;
|
||||
const previewMediaUrl = media.preview_ready_src ? media.preview_ready_url : media.favicon_ready_url;
|
||||
const previewHtml = previewSrc
|
||||
? `<a class="snapshot-preview" data-stable-media="preview" data-media-url="${escapeAttr(previewMediaUrl)}" href="${escapeAttr(media.preview_link || snapshot.view_url || adminUrl)}" title="Open snapshot output"><img src="${escapeAttr(previewSrc)}" alt="" decoding="async" loading="lazy" onload="this.dataset.loaded='1'" onerror="failedProgressImage(this)"><span class="snapshot-preview-loading">▤</span></a>`
|
||||
: `<a class="snapshot-preview placeholder" href="${escapeAttr(snapshot.view_url || adminUrl)}" title="Open snapshot"><span>${statusIcon}</span></a>`;
|
||||
|
||||
let extractorHtml = '';
|
||||
@@ -1806,12 +1904,22 @@
|
||||
const progressEndpoint = monitor.dataset.progressEndpoint || '/progress.json';
|
||||
|
||||
function fetchProgress() {
|
||||
// Opening a replay replaces the queued snapshot's progress panel.
|
||||
// Its interval and already-running fetch must stop using that old DOM.
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
fetch(progressEndpoint, { credentials: 'same-origin' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (data.error) {
|
||||
console.error('Progress API error:', data.error, data.traceback);
|
||||
setOrchestratorState('error', 'Backend not responding');
|
||||
@@ -1823,6 +1931,10 @@
|
||||
updateProgress(data);
|
||||
})
|
||||
.catch(error => {
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
console.error('Progress fetch error:', error);
|
||||
setOrchestratorState('error', 'Backend not responding');
|
||||
idleMessage.textContent = 'Fetch Error: ' + error.message;
|
||||
@@ -1831,7 +1943,7 @@
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval) return;
|
||||
if (pollInterval || !monitor.isConnected) return;
|
||||
fetchProgress();
|
||||
pollInterval = setInterval(fetchProgress, pollDelayMs);
|
||||
}
|
||||
@@ -1935,7 +2047,13 @@
|
||||
|
||||
// Start polling when page loads
|
||||
startPolling();
|
||||
setInterval(updateDurationBadges, 1000);
|
||||
const durationInterval = setInterval(() => {
|
||||
if (!monitor.isConnected) {
|
||||
clearInterval(durationInterval);
|
||||
return;
|
||||
}
|
||||
updateDurationBadges();
|
||||
}, 1000);
|
||||
|
||||
// Pause polling when tab is hidden
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
|
||||
+103
-161
@@ -7,17 +7,15 @@ from typing import Literal
|
||||
from abx_dl.events import PROCESS_EXIT_SKIPPED
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from django.db.models import CharField, Count, Q, Sum
|
||||
from django.db.models import CharField, Count, Q
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.permissions import can_view_snapshot, is_admin_user
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url, get_api_base_url
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_url, get_api_base_url
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.plugins.discovery import discover_plugin_configs
|
||||
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_default_output_path, get_snapshot_role_result
|
||||
|
||||
|
||||
def progress_endpoint(scope: Literal["crawl", "snapshot"] | None = None, object_id: object | None = None) -> str:
|
||||
@@ -152,68 +150,25 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
return hook_details(Path(hook_path).name, plugin=Path(hook_path).parent.name or "setup")
|
||||
|
||||
def archiveresult_output_path(ar) -> str | None:
|
||||
output_file_map = ar.output_files if isinstance(ar.output_files, dict) else {}
|
||||
|
||||
def is_root_relative(path: str) -> bool:
|
||||
metadata = output_file_map.get(path) or {}
|
||||
return bool(isinstance(metadata, dict) and metadata.get("root_relative"))
|
||||
|
||||
if ar.output_str:
|
||||
raw_output = str(ar.output_str).strip()
|
||||
if ar._looks_like_output_path(raw_output, ar.plugin):
|
||||
output_path = Path(raw_output)
|
||||
if output_path.is_absolute():
|
||||
return None
|
||||
|
||||
if raw_output.startswith(f"{ar.plugin}/"):
|
||||
candidates = [raw_output]
|
||||
elif len(output_path.parts) == 1:
|
||||
candidates = [f"{ar.plugin}/{raw_output}", raw_output]
|
||||
else:
|
||||
candidates = [raw_output]
|
||||
|
||||
if raw_output in output_file_map and is_root_relative(raw_output):
|
||||
return raw_output
|
||||
|
||||
for relative_path in candidates:
|
||||
plugin_relative = relative_path.removeprefix(f"{ar.plugin}/")
|
||||
if relative_path in output_file_map:
|
||||
return f"{ar.plugin}/{relative_path}" if not relative_path.startswith(f"{ar.plugin}/") else relative_path
|
||||
if plugin_relative in output_file_map:
|
||||
return f"{ar.plugin}/{plugin_relative}"
|
||||
|
||||
output_file_paths = list(output_file_map.keys())
|
||||
if output_file_paths:
|
||||
fallback_path = ArchiveResult._fallback_output_file_path(output_file_paths, ar.plugin, output_file_map)
|
||||
if fallback_path:
|
||||
if is_root_relative(fallback_path):
|
||||
return fallback_path
|
||||
return f"{ar.plugin}/{fallback_path}"
|
||||
|
||||
return None
|
||||
|
||||
def snapshot_output_url(snapshot, output_path: str) -> str:
|
||||
return build_snapshot_url(str(snapshot["id"]), output_path, request=request, config=request_config)
|
||||
|
||||
def snapshot_archive_path(snapshot) -> str:
|
||||
if snapshot["fs_version"] in ("0.7.0", "0.8.0"):
|
||||
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{snapshot['timestamp']}"
|
||||
crawl = crawls_by_id.get(str(snapshot["crawl_id"]))
|
||||
username = "web"
|
||||
if crawl is not None and crawl["created_by_id"]:
|
||||
username = crawl["created_by__username"]
|
||||
if username == "system":
|
||||
username = "web"
|
||||
date_base = snapshot["bookmarked_at"] or snapshot["created_at"]
|
||||
date_str = date_base.strftime("%Y%m%d") if date_base else "unknown"
|
||||
domain = Snapshot.extract_domain_from_url(snapshot["url"])
|
||||
return f"{username}/{date_str}/{domain}/{snapshot['id']}"
|
||||
return Snapshot.archive_path_for_values(
|
||||
fs_version=snapshot["fs_version"],
|
||||
timestamp=snapshot["timestamp"],
|
||||
snapshot_id=snapshot["id"],
|
||||
url=snapshot["url"],
|
||||
username=crawl["created_by__username"] if crawl is not None and crawl["created_by_id"] else "web",
|
||||
bookmarked_at=snapshot["bookmarked_at"],
|
||||
created_at=snapshot["created_at"],
|
||||
)
|
||||
|
||||
def snapshot_view_url(snapshot, output_path: str = "") -> str:
|
||||
anchor = f"#{output_path}" if output_path else ""
|
||||
return build_web_url(
|
||||
f"/{snapshot_archive_path(snapshot)}/index.html{anchor}",
|
||||
return build_snapshot_detail_url(
|
||||
snapshot_archive_path(snapshot),
|
||||
output_path=output_path,
|
||||
request=request,
|
||||
config=request_config,
|
||||
)
|
||||
@@ -226,53 +181,37 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
running_screencasts = {}
|
||||
|
||||
def screencast_frame_url(crawl_id: str, crawl_dir: Path) -> str:
|
||||
frame_path = crawl_dir / "chrome_screencast" / "latest.jpg"
|
||||
try:
|
||||
frame_stat = frame_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
if frame_stat.st_size <= 0:
|
||||
return ""
|
||||
def screencast_frame_url(crawl_id: str) -> str:
|
||||
started_at = running_screencasts.get(crawl_id)
|
||||
if started_at is None or frame_stat.st_mtime < started_at.timestamp():
|
||||
if started_at is None:
|
||||
return ""
|
||||
rel = f"/api/v1/crawls/crawl/{crawl_id}/files/chrome_screencast/latest.jpg?v={frame_stat.st_mtime_ns}"
|
||||
# File existence/freshness belongs to the image request, never to
|
||||
# the one-second JSON poll (archive storage may be remote).
|
||||
rel = f"/api/v1/crawls/crawl/{crawl_id}/files/chrome_screencast/latest.jpg?v={int(now.timestamp())}&after={started_at.timestamp()}"
|
||||
return f"{api_base}{rel}" if api_base else rel
|
||||
|
||||
machine_id = Machine.current().id
|
||||
machine = Machine.current_readonly()
|
||||
machine_id = machine.id if machine is not None else None
|
||||
orchestrator_proc = (
|
||||
Process.objects.filter(
|
||||
machine_id=machine_id,
|
||||
process_type=Process.TypeChoices.ORCHESTRATOR,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
)
|
||||
.only("id", "pid", "started_at", "machine_id", "process_type", "status")
|
||||
.only("id", "pid", "started_at", "machine_id", "process_type", "status", "env", "cmd")
|
||||
.order_by("-started_at")
|
||||
.first()
|
||||
if machine_id is not None
|
||||
else None
|
||||
)
|
||||
runner_worker = None
|
||||
orchestrator_proc_running = bool(orchestrator_proc and orchestrator_proc.is_running)
|
||||
if not orchestrator_proc_running:
|
||||
try:
|
||||
from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
|
||||
|
||||
supervisor = get_existing_supervisord_process(quiet=True)
|
||||
runner_worker = get_worker(supervisor, "worker_runner") if supervisor else None
|
||||
except (OSError, RuntimeError, TimeoutError):
|
||||
runner_worker = None
|
||||
|
||||
runner_worker_running = bool(runner_worker and runner_worker.get("statename") in ("STARTING", "RUNNING"))
|
||||
runner_worker_pid = runner_worker.get("pid") if runner_worker else None
|
||||
orchestrator_running = orchestrator_proc_running or runner_worker_running
|
||||
orchestrator_pid = orchestrator_proc.pid if orchestrator_proc_running and orchestrator_proc else runner_worker_pid
|
||||
orchestrator_running = orchestrator_proc_running
|
||||
orchestrator_pid = orchestrator_proc.pid if orchestrator_proc_running and orchestrator_proc else None
|
||||
|
||||
# Get model counts by status
|
||||
crawl_status_counts = Crawl.status_counts(
|
||||
crawl_scope,
|
||||
(Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.PAUSED),
|
||||
(Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED),
|
||||
)
|
||||
crawls_queued = crawl_status_counts.get(Crawl.StatusChoices.QUEUED, 0)
|
||||
crawls_active = crawl_status_counts.get(Crawl.StatusChoices.STARTED, 0)
|
||||
@@ -286,7 +225,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
snapshot_status_counts = Snapshot.status_counts(
|
||||
snapshot_scope,
|
||||
Snapshot.OPEN_STATES,
|
||||
Snapshot.RUNNABLE_STATES,
|
||||
)
|
||||
snapshots_queued = snapshot_status_counts.get(Snapshot.StatusChoices.QUEUED, 0)
|
||||
snapshots_active = snapshot_status_counts.get(Snapshot.StatusChoices.STARTED, 0)
|
||||
@@ -296,15 +235,24 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
ArchiveResult.StatusChoices.QUEUED,
|
||||
ArchiveResult.StatusChoices.STARTED,
|
||||
)
|
||||
archiveresult_status_counts = ArchiveResult.status_counts(archiveresult_scope, result_statuses)
|
||||
# Start from open results using the existing status index. Filtering by
|
||||
# plugin first scans historical runs, including all completed results.
|
||||
result_counts = list(
|
||||
archiveresult_scope.filter(status__in=result_statuses).order_by().values("status", "plugin").annotate(count=Count("*")),
|
||||
)
|
||||
archiveresult_status_counts = {
|
||||
status: sum(row["count"] for row in result_counts if row["status"] == status) for status in result_statuses
|
||||
}
|
||||
download_scope = archiveresult_scope.filter(
|
||||
plugin__in=download_plugin_names,
|
||||
snapshot__status__in=Snapshot.RUNNABLE_STATES,
|
||||
snapshot__crawl__status__in=Crawl.RUNNABLE_STATES,
|
||||
)
|
||||
indexing_scope = archiveresult_scope.filter(plugin__in=indexing_plugin_names)
|
||||
download_status_counts = ArchiveResult.status_counts(download_scope, result_statuses)
|
||||
indexing_status_counts = ArchiveResult.status_counts(indexing_scope, result_statuses)
|
||||
indexing_status_counts = {
|
||||
status: sum(row["count"] for row in result_counts if row["status"] == status and row["plugin"] in indexing_plugin_names)
|
||||
for status in result_statuses
|
||||
}
|
||||
archiveresults_queued = archiveresult_status_counts.get(ArchiveResult.StatusChoices.QUEUED, 0)
|
||||
archiveresults_active = archiveresult_status_counts.get(ArchiveResult.StatusChoices.STARTED, 0)
|
||||
|
||||
@@ -340,21 +288,27 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
.values(*active_crawl_fields)
|
||||
.order_by("-modified_at")[:max_active_crawls],
|
||||
)
|
||||
paused_crawls = list(
|
||||
crawl_scope.filter(
|
||||
Q(status=Crawl.StatusChoices.PAUSED, created_at__gte=paused_crawl_cutoff)
|
||||
| Q(
|
||||
status=Crawl.StatusChoices.PAUSED,
|
||||
snapshot_set__status__in=Snapshot.RUNNABLE_STATES,
|
||||
snapshot_set__retry_at__lte=now,
|
||||
)
|
||||
| Q(
|
||||
status=Crawl.StatusChoices.PAUSED,
|
||||
snapshot_set__archiveresult__status=ArchiveResult.StatusChoices.QUEUED,
|
||||
),
|
||||
# Start with runnable children, not every historical snapshot belonging
|
||||
# to a paused crawl. These uncorrelated subqueries run once per poll.
|
||||
due_paused_snapshots = (
|
||||
Snapshot.objects.filter(
|
||||
status__in=Snapshot.RUNNABLE_STATES,
|
||||
retry_at__lte=now,
|
||||
)
|
||||
.order_by()
|
||||
.values("crawl_id")
|
||||
)
|
||||
queued_paused_results = (
|
||||
ArchiveResult.objects.filter(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
)
|
||||
.order_by()
|
||||
.values("snapshot__crawl_id")
|
||||
)
|
||||
paused_crawls = list(
|
||||
crawl_scope.filter(status=Crawl.StatusChoices.PAUSED)
|
||||
.filter(Q(created_at__gte=paused_crawl_cutoff) | Q(id__in=due_paused_snapshots) | Q(id__in=queued_paused_results))
|
||||
.values(*active_crawl_fields)
|
||||
.distinct()
|
||||
.order_by("-modified_at")[:max_active_crawls],
|
||||
)
|
||||
queued_crawls = list(
|
||||
@@ -368,52 +322,29 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
crawl["persona_id"] = str(crawl["persona_id"])
|
||||
persona_details_by_id: dict[str, dict[str, str]] = {}
|
||||
persona_details_by_name: dict[str, dict[str, str]] = {}
|
||||
persona_objects_by_id = {}
|
||||
persona_objects_by_name = {}
|
||||
persona_ids = {crawl["persona_id"] for crawl in active_crawls_list if crawl["persona_id"]}
|
||||
persona_names = {"Default"} if any(not crawl["persona_id"] for crawl in active_crawls_list) else set()
|
||||
if persona_ids or persona_names:
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name", "config"):
|
||||
for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name"):
|
||||
persona_details = {
|
||||
"name": persona.name,
|
||||
"admin_url": f"/admin/personas/persona/{persona.pk}/change/",
|
||||
}
|
||||
persona_details_by_id[str(persona.id)] = persona_details
|
||||
persona_details_by_name[persona.name] = persona_details
|
||||
persona_objects_by_id[str(persona.id)] = persona
|
||||
persona_objects_by_name[persona.name] = persona
|
||||
active_crawl_ids = [crawl["id"] for crawl in active_crawls_list]
|
||||
active_crawl_objects = {}
|
||||
if active_crawl_ids:
|
||||
for crawl_obj in Crawl.objects.filter(id__in=active_crawl_ids).select_related("created_by", "persona"):
|
||||
crawl_obj._runtime_config = request_config
|
||||
active_crawl_objects[str(crawl_obj.id)] = crawl_obj
|
||||
snapshot_counts_by_crawl: dict[str, dict[str, int]] = {str(crawl_id): {} for crawl_id in active_crawl_ids}
|
||||
cancelled_snapshot_counts_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
crawl_output_sizes_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
queued_snapshot_overflow_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
active_snapshot_scope = snapshot_scope.filter(crawl_id__in=active_crawl_ids)
|
||||
if active_crawl_ids:
|
||||
for row in active_snapshot_scope.values("crawl_id", "status").annotate(count=Count("id")):
|
||||
snapshot_counts_by_crawl.setdefault(str(row["crawl_id"]), {})[row["status"]] = row["count"]
|
||||
|
||||
for row in (
|
||||
active_snapshot_scope.filter(status=Snapshot.StatusChoices.SEALED, downloaded_at__isnull=True)
|
||||
.values("crawl_id")
|
||||
.annotate(count=Count("id"))
|
||||
):
|
||||
cancelled_snapshot_counts_by_crawl[str(row["crawl_id"])] = row["count"]
|
||||
|
||||
for row in (
|
||||
active_snapshot_scope.filter(
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
)
|
||||
.values("crawl_id")
|
||||
.annotate(size=Sum("output_size"))
|
||||
):
|
||||
crawl_output_sizes_by_crawl[str(row["crawl_id"])] = int(row["size"] or 0)
|
||||
# The existing (crawl, status, modified_at) index covers these counts.
|
||||
# Byte totals and cancellation details require table reads, so sample
|
||||
# those separately and share cached values between viewer tabs.
|
||||
for row in active_snapshot_scope.order_by().values("crawl_id", "status").annotate(count=Count("*")):
|
||||
crawl_id = str(row["crawl_id"])
|
||||
snapshot_counts_by_crawl[crawl_id][row["status"]] = row["count"]
|
||||
|
||||
crawl_process_pids: dict[str, int] = {}
|
||||
snapshot_process_pids: dict[str, int] = {}
|
||||
@@ -481,7 +412,10 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
detailed_snapshot_ids = [snapshot["id"] for snapshot in snapshots if snapshot["status"] != Snapshot.StatusChoices.QUEUED]
|
||||
process_value_fields = ("id", "process_type", "status", "pwd", "cmd", "pid", "exit_code", "started_at", "ended_at", "modified_at")
|
||||
if active_crawl_ids or displayed_snapshot_ids:
|
||||
relevant_process_ids = tuple(dict.fromkeys([*active_crawl_ids, *displayed_snapshot_ids]))
|
||||
relevant_process_path_pattern = "|".join(relevant_process_ids)
|
||||
process_scope = Process.objects.filter(
|
||||
pwd__regex=relevant_process_path_pattern,
|
||||
machine_id=machine_id,
|
||||
process_type__in=[
|
||||
Process.TypeChoices.HOOK,
|
||||
@@ -629,7 +563,14 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
completed_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.SEALED, 0)
|
||||
started_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.STARTED, 0)
|
||||
pending_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.QUEUED, 0)
|
||||
cancelled_snapshots = cancelled_snapshot_counts_by_crawl.get(crawl_id, 0)
|
||||
from archivebox.progressmonitor.collection import crawl_summary
|
||||
|
||||
totals = (
|
||||
crawl_summary(crawl_id, snapshot_id=scoped_snapshot.pk if scoped_snapshot is not None else None)
|
||||
if completed_snapshots
|
||||
else {"snapshots": 0, "cancelled": 0, "bytes": 0}
|
||||
)
|
||||
cancelled_snapshots = totals["cancelled"] if totals is not None else None
|
||||
|
||||
# Count URLs in the crawl (for when snapshots haven't been created yet)
|
||||
urls_count = 0
|
||||
@@ -648,7 +589,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
crawl_setup_completed = sum(1 for item in crawl_setup_plugins if item.get("status") == "succeeded")
|
||||
crawl_setup_failed = sum(1 for item in crawl_setup_plugins if item.get("status") == "failed")
|
||||
crawl_setup_pending = sum(1 for item in crawl_setup_plugins if item.get("status") == "queued")
|
||||
crawl_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir)
|
||||
crawl_screencast_url = screencast_frame_url(crawl_id)
|
||||
crawl_screencast_link = f"/admin/crawls/crawl/{crawl_id.replace('-', '')}/change/" if crawl_screencast_url else ""
|
||||
|
||||
# Get active snapshots for this crawl (already prefetched)
|
||||
@@ -681,27 +622,28 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
snapshot_screencast_url = ""
|
||||
snapshot_screencast_link = ""
|
||||
snapshot_fallback_urls: list[str] = []
|
||||
result_by_plugin = {result.plugin: result for result in snapshot_results}
|
||||
title_result = result_by_plugin.get("title")
|
||||
if not snapshot_title and title_result is not None and title_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
title_result = get_snapshot_role_result(snapshot_results, "title_source")
|
||||
if not snapshot_title and title_result is not None:
|
||||
snapshot_title = Snapshot._normalize_title_candidate(title_result.output_str, snapshot_url=snapshot["url"])
|
||||
favicon_result = result_by_plugin.get("favicon")
|
||||
if favicon_result is not None and favicon_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
favicon_path = archiveresult_output_path(favicon_result) or "favicon/favicon.ico"
|
||||
snapshot_favicon_url = snapshot_output_url(snapshot, favicon_path)
|
||||
screenshot_result = result_by_plugin.get("screenshot")
|
||||
if screenshot_result is not None and screenshot_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
favicon_result = get_snapshot_role_result(snapshot_results, "list_icon")
|
||||
if favicon_result is not None:
|
||||
favicon_path = favicon_result.embed_path_db() or get_plugin_default_output_path(favicon_result.plugin)
|
||||
if favicon_path:
|
||||
snapshot_favicon_url = snapshot_output_url(snapshot, favicon_path)
|
||||
screenshot_result = get_snapshot_role_result(snapshot_results, "primary_preview")
|
||||
if screenshot_result is not None:
|
||||
snapshot_preview_link = snapshot_view_url(snapshot)
|
||||
screenshot_path = archiveresult_output_path(screenshot_result) or "screenshot/screenshot.png"
|
||||
snapshot_preview_url = snapshot_output_url(snapshot, screenshot_path)
|
||||
snapshot_preview_link = snapshot_view_url(snapshot, screenshot_path)
|
||||
screenshot_path = screenshot_result.embed_path_db() or get_plugin_default_output_path(screenshot_result.plugin)
|
||||
if screenshot_path:
|
||||
snapshot_preview_url = snapshot_output_url(snapshot, screenshot_path)
|
||||
snapshot_preview_link = snapshot_view_url(snapshot, screenshot_path)
|
||||
if snapshot_favicon_url:
|
||||
snapshot_fallback_urls.append(snapshot_favicon_url)
|
||||
elif snapshot_favicon_url:
|
||||
snapshot_preview_url = snapshot_favicon_url
|
||||
|
||||
if snapshot["status"] == Snapshot.StatusChoices.STARTED:
|
||||
snapshot_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir)
|
||||
snapshot_screencast_url = screencast_frame_url(crawl_id)
|
||||
snapshot_screencast_link = snapshot_view_url(snapshot) if snapshot_screencast_url else ""
|
||||
|
||||
def plugin_sort_key(ar):
|
||||
@@ -749,7 +691,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
"process_id": str(process.id) if process else None,
|
||||
"admin_url": f"/admin/core/archiveresult/{ar.id}/change/",
|
||||
}
|
||||
output_path = archiveresult_output_path(ar)
|
||||
output_path = ar.embed_path_db()
|
||||
if output_path:
|
||||
plugin_payload["output_path"] = output_path
|
||||
plugin_payload["output_url"] = snapshot_view_url(snapshot, output_path)
|
||||
@@ -849,18 +791,18 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
persona_details = persona_details_by_id.get(str(crawl["persona_id"])) if crawl["persona_id"] else None
|
||||
persona_name = persona_details["name"] if persona_details else "Default"
|
||||
persona_details = persona_details or persona_details_by_name.get(persona_name)
|
||||
crawl_output_size = crawl_output_sizes_by_crawl.get(crawl_id, 0)
|
||||
avg_snapshot_size = int(crawl_output_size / completed_snapshots) if completed_snapshots else 0
|
||||
crawl_obj = active_crawl_objects[crawl_id]
|
||||
effective_crawl_config = get_config(crawl=crawl_obj, resolve_plugins=False)
|
||||
max_urls = int(effective_crawl_config.CRAWL_MAX_URLS or 0)
|
||||
crawl_max_size = int(effective_crawl_config.CRAWL_MAX_SIZE or 0)
|
||||
crawl_timeout = int(effective_crawl_config.CRAWL_TIMEOUT or 0)
|
||||
snapshot_max_size = int(effective_crawl_config.SNAPSHOT_MAX_SIZE or 0)
|
||||
crawl_output_size = totals["bytes"] if totals is not None else None
|
||||
avg_snapshot_size = int(totals["bytes"] / max(totals["snapshots"], 1)) if totals is not None else None
|
||||
# These crawl-scope limits are frozen in Crawl.config. Rendering
|
||||
# them must not resolve personas, probe binaries, or reread config.
|
||||
max_urls = int(crawl["config"].get("CRAWL_MAX_URLS", request_config.CRAWL_MAX_URLS) or 0)
|
||||
crawl_max_size = int(crawl["config"].get("CRAWL_MAX_SIZE", request_config.CRAWL_MAX_SIZE) or 0)
|
||||
crawl_timeout = int(crawl["config"].get("CRAWL_TIMEOUT", request_config.CRAWL_TIMEOUT) or 0)
|
||||
snapshot_max_size = int(crawl["config"].get("SNAPSHOT_MAX_SIZE", request_config.SNAPSHOT_MAX_SIZE) or 0)
|
||||
|
||||
# Check if retry_at is in the future (would prevent worker from claiming)
|
||||
retry_at_future = crawl["retry_at"] > now if crawl["retry_at"] else False
|
||||
is_paused = crawl_obj.is_paused
|
||||
is_paused = crawl["status"] == Crawl.StatusChoices.PAUSED
|
||||
seconds_until_retry = (
|
||||
0 if is_paused else int((crawl["retry_at"] - now).total_seconds()) if crawl["retry_at"] and retry_at_future else 0
|
||||
)
|
||||
@@ -900,8 +842,8 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
"max_snapshot_size_display": printable_filesize(snapshot_max_size) if snapshot_max_size else "unlimited",
|
||||
"crawl_output_size": crawl_output_size,
|
||||
"avg_snapshot_size": avg_snapshot_size,
|
||||
"crawl_output_size_display": printable_filesize(crawl_output_size) if crawl_output_size else "0 B",
|
||||
"avg_snapshot_size_display": printable_filesize(avg_snapshot_size) if avg_snapshot_size else "0 B",
|
||||
"crawl_output_size_display": printable_filesize(crawl_output_size or 0) if totals is not None else "…",
|
||||
"avg_snapshot_size_display": printable_filesize(avg_snapshot_size or 0) if totals is not None else "…",
|
||||
"tags": crawl_tags,
|
||||
"urls_count": urls_count,
|
||||
"total_snapshots": total_snapshots,
|
||||
|
||||
@@ -12,8 +12,11 @@ _search_backends_cache: dict | None = None
|
||||
|
||||
def search_backend_command_env(config: dict[str, Any] | None = None, **config_kwargs: Any) -> dict[str, str]:
|
||||
"""Serialize resolved application config for a standalone plugin command."""
|
||||
from abx_dl.models import PluginEnv
|
||||
from abxpkg import BinProvider
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
env = os.environ.copy()
|
||||
env: dict[str, str] = {}
|
||||
for key, value in config.items():
|
||||
key = str(key)
|
||||
if value is None:
|
||||
@@ -24,7 +27,9 @@ def search_backend_command_env(config: dict[str, Any] | None = None, **config_kw
|
||||
env[key] = json.dumps(value)
|
||||
elif isinstance(value, (str, int, float, os.PathLike)):
|
||||
env[key] = str(value)
|
||||
return env
|
||||
# Use the same runtime bootstrap as hooks so the installation's abxpkg
|
||||
# entrypoint remains available even without an activated virtualenv.
|
||||
return BinProvider.build_exec_env(base_env=PluginEnv(**os.environ).to_env(), extra_env=env)
|
||||
|
||||
|
||||
def normalize_search_backend_name(backend_name: str | None) -> str:
|
||||
|
||||
@@ -141,6 +141,23 @@ def _save_archiveresult_event_to_db(
|
||||
if snapshot is None:
|
||||
return
|
||||
|
||||
if event.status == "cancelled":
|
||||
# Stopping a capture tells the user nothing about whether it would have
|
||||
# succeeded. Remove this attempt instead of showing a failure card or
|
||||
# adding it to "retry failed". The next scheduled run creates a new row.
|
||||
results = ArchiveResult.objects.filter(snapshot=snapshot, plugin=event.plugin, hook_name=event.hook_name)
|
||||
if process_started is not None:
|
||||
# A late completion must not erase a newer retry of the same hook.
|
||||
results = results.filter(start_ts=parse_event_datetime(process_started.start_ts))
|
||||
# Ordinary deletion invokes output-directory cleanup. Cancellation is
|
||||
# only withdrawing the DB fact: retain partial files and sibling hooks'
|
||||
# shared output: they may be the only copy we ever capture. A rerun's
|
||||
# hook owns replacing files without deleting the old copy first (see
|
||||
# abx-plugins README, Rules). Retain Process/logs to explain the stop.
|
||||
results._raw_delete(results.db)
|
||||
ArchiveResult.refresh_snapshot_output_sizes([snapshot.id])
|
||||
return
|
||||
|
||||
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.plugin_dir"):
|
||||
plugin_dir = (
|
||||
Path(process_started.output_dir)
|
||||
@@ -224,7 +241,15 @@ def _save_archiveresult_event_to_db(
|
||||
|
||||
|
||||
def mark_archiveresult_started(event: ProcessStartedEvent, *, snapshot_id: str, process_id: str) -> None:
|
||||
"""Project a running abx-dl hook after its OS process is persisted."""
|
||||
"""Project a running abx-dl hook after its OS process is persisted.
|
||||
|
||||
This DB state records process execution; the scheduler separately waits for
|
||||
stdout readiness before advancing past a background hook. Neither boundary
|
||||
proves that an archive was produced. Final status comes from the owning
|
||||
hook's ArchiveResultEvent, including corrections after an abnormal exit.
|
||||
Rows are per snapshot/plugin/hook: a recorder's start hook cannot declare
|
||||
that its separate export hook succeeded.
|
||||
"""
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
started_at = parse_event_datetime(event.start_ts)
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
RESOURCE_RECHECK_SECONDS = 2.0
|
||||
# Scheduling policy, not a minimum-RAM claim: stop increasing extraction
|
||||
@@ -12,6 +17,65 @@ RESOURCE_RECHECK_SECONDS = 2.0
|
||||
# stalls. Require both recent and rolling pressure so transient reclaim does
|
||||
# not indefinitely starve otherwise viable work on small hosts.
|
||||
MEMORY_FULL_STALL_PERCENT = 10.0
|
||||
ZRAM_DEVICE_NAME = re.compile(r"zram\d+")
|
||||
|
||||
|
||||
def zram_swap_identifiers(sys_block: Path = Path("/sys/block")) -> set[str]:
|
||||
"""Return zram block names and major:minor IDs known to sysfs."""
|
||||
identifiers = set()
|
||||
for device in sys_block.glob("zram*"):
|
||||
if ZRAM_DEVICE_NAME.fullmatch(device.name):
|
||||
identifiers.add(device.name)
|
||||
try:
|
||||
identifiers.add((device / "dev").read_text().strip())
|
||||
except OSError:
|
||||
pass
|
||||
return identifiers
|
||||
|
||||
|
||||
def swap_source_identifiers(filename: str) -> set[str]:
|
||||
"""Identify a swap source by its visible name and, for block devices, ID."""
|
||||
identifiers = {Path(filename).name}
|
||||
try:
|
||||
source = os.stat(filename)
|
||||
if stat.S_ISBLK(source.st_mode):
|
||||
identifiers.add(f"{os.major(source.st_rdev)}:{os.minor(source.st_rdev)}")
|
||||
except OSError:
|
||||
pass
|
||||
return identifiers
|
||||
|
||||
|
||||
def disk_backed_swap_free_bytes(swaps: str, zram_identifiers: set[str]) -> int:
|
||||
"""Sum free active swap except zram, whose capacity is already backed by RAM."""
|
||||
lines = swaps.splitlines()
|
||||
free_kib = 0
|
||||
for line in lines[1:]: # /proc/swaps starts with a column header
|
||||
fields = line.split()
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
filename, _, size_kib, used_kib, _ = fields[:5]
|
||||
source_ids = swap_source_identifiers(filename)
|
||||
if source_ids & zram_identifiers or any(ZRAM_DEVICE_NAME.fullmatch(name) for name in source_ids):
|
||||
continue
|
||||
try:
|
||||
free_kib += max(0, int(size_kib) - int(used_kib))
|
||||
except ValueError:
|
||||
continue
|
||||
return free_kib * 1024
|
||||
|
||||
|
||||
def host_swap_free_bytes(meminfo_swap_free: int) -> int:
|
||||
"""Use only independently backed swap as extra capacity on Linux."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return psutil.swap_memory().free
|
||||
try:
|
||||
swaps = Path("/proc/swaps").read_text()
|
||||
except OSError:
|
||||
# If Linux swap sources cannot be classified, fail closed rather than
|
||||
# treating potentially RAM-backed zram capacity as extra headroom.
|
||||
return 0
|
||||
disk_free = disk_backed_swap_free_bytes(swaps, zram_swap_identifiers())
|
||||
return min(max(0, meminfo_swap_free), disk_free)
|
||||
|
||||
|
||||
class ResourceAdmission:
|
||||
@@ -44,10 +108,73 @@ class ResourceAdmission:
|
||||
cpus = min(cpus, max(1, math.ceil(int(quota) / int(period))))
|
||||
except (OSError, ValueError, ZeroDivisionError):
|
||||
continue
|
||||
# CPU quota limits parallel admission, not forward progress. A host
|
||||
# with a fractional CPU must still be able to run one snapshot.
|
||||
# CPU quota bounds parallel work; measured memory admission is done
|
||||
# only after the first complete snapshot establishes its actual cost.
|
||||
return max(1, min(configured, cpus))
|
||||
|
||||
def additional_snapshot_slots(self, configured: int, active: int, observed_cost: int | None) -> int:
|
||||
if not observed_cost:
|
||||
# Stage one complete real capture before spending memory on parallel
|
||||
# hook trees. A zero-cost/failed probe cannot justify fanout.
|
||||
return 1 if active == 0 and configured > 0 else 0
|
||||
headroom = self.memory_headroom()
|
||||
return self.slots_for_headroom(configured, active, observed_cost, headroom[1] if headroom else 0)
|
||||
|
||||
@staticmethod
|
||||
def slots_for_headroom(configured: int, active: int, observed_cost: int, available: int) -> int:
|
||||
slots = max(0, configured - active)
|
||||
if observed_cost > 0:
|
||||
# Active captures can still grow to the measured peak. Reserve for
|
||||
# that growth before promising the remaining headroom to new work.
|
||||
unpromised = max(0, available - active * observed_cost)
|
||||
slots = min(slots, unpromised // observed_cost)
|
||||
return max(1, slots) if active == 0 and configured else slots
|
||||
|
||||
def memory_headroom(self) -> tuple[int, int] | None:
|
||||
"""Return workload RAM+swap usage and effective available headroom."""
|
||||
try:
|
||||
meminfo = {
|
||||
name: int(value.split()[0]) * 1024
|
||||
for name, value in (line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() if ":" in line)
|
||||
}
|
||||
host_swap_free = host_swap_free_bytes(meminfo.get("SwapFree", 0))
|
||||
host_available = meminfo["MemAvailable"] + host_swap_free
|
||||
except (OSError, ValueError, KeyError):
|
||||
host_swap_free = 0 if sys.platform.startswith("linux") else psutil.swap_memory().free
|
||||
host_available = psutil.virtual_memory().available + host_swap_free
|
||||
|
||||
available = host_available
|
||||
used_bytes = None
|
||||
for path in self.cgroup_paths():
|
||||
try:
|
||||
memory_max = (path / "memory.max").read_text().strip()
|
||||
if memory_max == "max":
|
||||
continue
|
||||
current = int((path / "memory.current").read_text())
|
||||
swap_max = (path / "memory.swap.max").read_text().strip()
|
||||
swap_current = int((path / "memory.swap.current").read_text())
|
||||
swap_room = host_swap_free if swap_max == "max" else min(host_swap_free, max(0, int(swap_max) - swap_current))
|
||||
available = min(available, max(0, int(memory_max) - current) + swap_room)
|
||||
if used_bytes is None:
|
||||
used_bytes = current + swap_current
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
if used_bytes is None:
|
||||
# Bare-metal and non-Linux installs have no finite cgroup. Count
|
||||
# the real runner process tree rather than the entire host.
|
||||
try:
|
||||
process = psutil.Process()
|
||||
processes = [process, *process.children(recursive=True)]
|
||||
except (psutil.Error, OSError):
|
||||
return None
|
||||
used_bytes = 0
|
||||
for child in processes:
|
||||
try:
|
||||
used_bytes += child.memory_info().rss
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
return used_bytes, available
|
||||
|
||||
def observe_memory_stalls(self, source: str, total: int, avg10: float, sampled_at: float) -> bool:
|
||||
previous = self._memory_stalls.get(source)
|
||||
self._memory_stalls[source] = (total, sampled_at)
|
||||
|
||||
+292
-66
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
@@ -12,7 +14,7 @@ from contextlib import nullcontext
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -26,6 +28,7 @@ from abx_dl.events import (
|
||||
CrawlCleanupEvent,
|
||||
CrawlCompletedEvent,
|
||||
CrawlEvent,
|
||||
CrawlPauseEvent,
|
||||
CrawlSetupEvent,
|
||||
CrawlStartEvent,
|
||||
InstallEvent,
|
||||
@@ -36,7 +39,6 @@ from abx_dl.events import (
|
||||
SnapshotEvent,
|
||||
slow_warning_timeout,
|
||||
)
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.catalog import PluginCatalog
|
||||
from abx_dl.config import GlobalConfig, RuntimeConfig
|
||||
from abx_dl.models import Snapshot as AbxSnapshot
|
||||
@@ -198,7 +200,12 @@ class CrawlRunner:
|
||||
self.crawl = crawl
|
||||
self.bus = create_bus(name=_bus_name("ArchiveBox", str(crawl.id)), total_timeout=3600.0)
|
||||
self.catalog = get_plugin_catalog()
|
||||
HookProcessService(self.bus, emit_jsonl=False, interactive_tty=interactive_interrupts)
|
||||
self.process_service = HookProcessService(
|
||||
self.bus,
|
||||
emit_jsonl=False,
|
||||
interactive_tty=interactive_interrupts,
|
||||
interrupted_hook_prompt=self._prompt_in_foreground if interactive_interrupts else None,
|
||||
)
|
||||
register_sonic_daemon_event_handler(self.bus)
|
||||
PersistedProcessService(self.bus)
|
||||
ArchiveBoxBinaryService(self.bus)
|
||||
@@ -209,6 +216,9 @@ class CrawlRunner:
|
||||
self.process_discovered_snapshots_inline = process_discovered_snapshots_inline
|
||||
self.show_progress = show_progress
|
||||
self.interactive_interrupts = interactive_interrupts
|
||||
self._interrupt_choice: asyncio.Future[Literal["abort", "retry", "skip"]] | None = None
|
||||
self._user_aborted = False
|
||||
self.bus.on(CrawlAbortEvent, self.on_CrawlAbortEvent)
|
||||
self.config_overrides = dict(config_overrides or {})
|
||||
|
||||
self.snapshot_service = SnapshotService(
|
||||
@@ -223,6 +233,10 @@ class CrawlRunner:
|
||||
self.snapshot_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self.snapshot_semaphore = asyncio.Semaphore(1)
|
||||
self.max_concurrent_snapshots = 1
|
||||
self._warmup_snapshot_id: str | None = None
|
||||
self._memory_baseline: int | None = None
|
||||
self._memory_peak_cost = 0
|
||||
self._observed_snapshot_cost: int | None = None
|
||||
self._resource_deferred = False
|
||||
self._initial_snapshot_admitted = False
|
||||
self.persona = None
|
||||
@@ -231,9 +245,11 @@ class CrawlRunner:
|
||||
self.primary_url = ""
|
||||
self.crawl_output_dir = ""
|
||||
self._live_stream = None
|
||||
self._live_ui: LiveBusUI | None = None
|
||||
self.root_crawl_event_id: str | None = None
|
||||
self.root_crawl_start_event_id: str | None = None
|
||||
self._run_task: asyncio.Task[None] | None = None
|
||||
self._run_started = False
|
||||
self._skip_wait_until_idle = False
|
||||
# This is intentionally a synchronous OS-signal side channel, not bus
|
||||
# state. During SIGINT/SIGTERM/SIGHUP, asyncio.run() may already be
|
||||
@@ -242,21 +258,115 @@ class CrawlRunner:
|
||||
self._signal_abort_requested = False
|
||||
self._last_lease_heartbeat_at = 0.0
|
||||
|
||||
def _request_abort_from_signal(self, _sig: signal.Signals) -> None:
|
||||
if os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
|
||||
# The daemon runner is owned by supervisord, not by the interactive
|
||||
# CLI foreground flow. A direct signal to this child should be short
|
||||
# and unambiguous: exit non-zero immediately so supervisord restarts
|
||||
# the runner, while the parent server and supervisord stay alive.
|
||||
os._exit(128 + int(_sig))
|
||||
already_requested = self._signal_abort_requested
|
||||
async def on_CrawlAbortEvent(self, event: CrawlAbortEvent) -> None:
|
||||
self._signal_abort_requested = True
|
||||
# An abort can arrive while the prompt transport is being established,
|
||||
# before another SIGINT sees _interrupt_choice. Release that waiter too;
|
||||
# shutdown must never depend on a subsequent keypress or IPC response.
|
||||
if self._interrupt_choice is not None and not self._interrupt_choice.done():
|
||||
self._interrupt_choice.set_result("abort")
|
||||
if event.user_initiated:
|
||||
self._user_aborted = True
|
||||
# Exit 130 alone cannot distinguish a user's abort from a worker
|
||||
# displaced by takeover. Tell the owning add command explicitly
|
||||
# so it exits instead of reclaiming and restarting this crawl.
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
sys.__stderr__.write("ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
sys.__stderr__.flush()
|
||||
|
||||
def _interrupt_hook(self) -> None:
|
||||
if self._user_aborted:
|
||||
# An explicit abort is already draining hook groups with normal
|
||||
# grace periods. A further Ctrl+C requests immediate force exit.
|
||||
# This service owns only this runner's hook groups and children;
|
||||
# a server's supervisor or another foreground command is outside
|
||||
# the boundary. Tell a supervising add parent this was user intent
|
||||
# before bypassing the worker's normal finalization path.
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
os.write(sys.__stderr__.fileno(), b"ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
os.write(sys.__stderr__.fileno(), b"\n[!] Forcing aborted crawl to exit now.\n")
|
||||
self.process_service.force_kill_owned_processes()
|
||||
os._exit(130)
|
||||
if self._run_task is None:
|
||||
# The crawl is already draining/finalizing; there is no live bus on
|
||||
# which to reopen a prompt. Remember the user's abort and finish the
|
||||
# cleanup that is already in progress. In particular do not hand a
|
||||
# second signal to Python's default handler in this teardown window.
|
||||
self._user_aborted = True
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
os.write(sys.__stderr__.fileno(), b"ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
stream = self._live_stream if self._live_stream is not None and not self._live_stream.closed else sys.__stderr__
|
||||
os.write(stream.fileno(), b"\nAborting crawl - finishing shutdown... Ctrl+C again to force exit.\n")
|
||||
return
|
||||
if self._interrupt_choice is not None:
|
||||
self._choose_interrupt_action("abort")
|
||||
return
|
||||
|
||||
async def dispatch() -> None:
|
||||
# ArchiveBox transports terminal intent; abx-dl alone decides pause
|
||||
# versus abort. No local counter may compete with its controller.
|
||||
await self.bus.emit(CrawlPauseEvent()).now()
|
||||
|
||||
asyncio.get_running_loop().create_task(dispatch())
|
||||
|
||||
async def _prompt_in_foreground(self, hook_name: str) -> Literal["abort", "retry", "skip"]:
|
||||
rendered = self._live_ui.show_interrupt_prompt(hook_name) if self._live_ui is not None else False
|
||||
if sys.stdin.isatty():
|
||||
# Direct run is itself the outer terminal owner. Keep its asyncio
|
||||
# loop alive so background completions render above the prompt. No
|
||||
# supervisor parent exists here to consume an IPC prompt request.
|
||||
choice = (
|
||||
await HookProcessService.read_interrupt_choice(
|
||||
hook_name,
|
||||
render=not rendered,
|
||||
is_active=lambda: not self._signal_abort_requested,
|
||||
on_abort=lambda: setattr(self, "_user_aborted", True),
|
||||
)
|
||||
or "abort"
|
||||
)
|
||||
if choice == "abort":
|
||||
# Also cover an inactive prompt that defaulted to abort. A
|
||||
# typed answer is recorded at the keypress by on_abort above.
|
||||
self._user_aborted = True
|
||||
return choice
|
||||
# A supervised worker has log pipes instead of terminal input. Its add
|
||||
# parent owns the same shared reader and returns the answer via signals.
|
||||
self._interrupt_choice = asyncio.get_running_loop().create_future()
|
||||
sys.__stderr__.write("ARCHIVEBOX_HOOK_INTERRUPTED " + json.dumps({"hook_name": hook_name, "rendered": rendered}) + "\n")
|
||||
sys.__stderr__.flush()
|
||||
try:
|
||||
return await self._interrupt_choice
|
||||
finally:
|
||||
self._interrupt_choice = None
|
||||
|
||||
def _choose_interrupt_action(self, choice: Literal["abort", "retry", "skip"]) -> None:
|
||||
if self._interrupt_choice is not None and not self._interrupt_choice.done():
|
||||
if choice == "abort":
|
||||
self._user_aborted = True
|
||||
self._interrupt_choice.set_result(choice)
|
||||
|
||||
def _request_abort_from_signal(self, sig: signal.Signals) -> None:
|
||||
# Daemons also need cooperative cleanup. os._exit used to leave hook
|
||||
# process groups orphaned and Process rows running forever. A daemon's
|
||||
# nonzero exit still lets supervisord restart it, AFTER finally blocks
|
||||
# stop descendants and finalize the ledger. SIGINT uses the same shared
|
||||
# controller as other runners, with interactive_tty=False (whole abort).
|
||||
# Takeover/parent loss stops this execution owner, not the user's crawl
|
||||
# intent. Do not set _user_aborted or emit ARCHIVEBOX_CRAWL_ABORTED here:
|
||||
# the outer add may need to wait for the new owner and reclaim afterward.
|
||||
# Cancellation intentionally bypasses the interactive pause gate; a
|
||||
# waiting prompt must never keep a displaced worker or its hooks alive.
|
||||
self._signal_abort_requested = True
|
||||
self._skip_wait_until_idle = True
|
||||
if not self._run_started and os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
|
||||
# A signal before run() starts has no async task or hooks to drain.
|
||||
# It must still be an unexpected supervisor exit, not a clean idle exit.
|
||||
raise SystemExit(128 + sig.value)
|
||||
# The foreground signal handler runs while the event loop may be in the
|
||||
# middle of shutdown. Flip cheap in-memory flags here and let normal
|
||||
# finally blocks do cleanup; only cancel the runner task immediately for
|
||||
# non-interactive commands or for a second interrupt escalation.
|
||||
if (not self.interactive_interrupts or already_requested) and self._run_task is not None and not self._run_task.done():
|
||||
# finally blocks do cleanup. Interactive SIGINT uses _interrupt_hook;
|
||||
# actual shutdown signals must cancel promptly in either mode.
|
||||
if self._run_task is not None and not self._run_task.done():
|
||||
self._run_task.cancel()
|
||||
|
||||
async def crawl_is_cancelled(self) -> bool:
|
||||
@@ -284,7 +394,7 @@ class CrawlRunner:
|
||||
await asyncio.sleep(poll_interval)
|
||||
if not await self.crawl_is_cancelled():
|
||||
continue
|
||||
abort_event = parent_event.emit(CrawlAbortEvent())
|
||||
abort_event = parent_event.emit(CrawlAbortEvent(user_initiated=False))
|
||||
await _run_event_now(abort_event, abort_event.event_timeout)
|
||||
return
|
||||
|
||||
@@ -299,25 +409,36 @@ class CrawlRunner:
|
||||
root_snapshot_id: str | None = None
|
||||
bus_destroyed = False
|
||||
run_state_loaded = False
|
||||
try:
|
||||
first_signal_message = (
|
||||
"\n[🛑] Got {signal_name}, aborting the active hook...\n"
|
||||
if self.interactive_interrupts
|
||||
else "\n[🛑] Got {signal_name}, stopping gracefully...\n"
|
||||
)
|
||||
self._run_task = asyncio.current_task()
|
||||
# Do not raise KeyboardInterrupt directly from an OS signal while
|
||||
# the asyncio loop is active. Python can inject it into whichever
|
||||
# task is currently running, which produces noisy "Task exception
|
||||
# was never retrieved" logs from unrelated abxbus housekeeping
|
||||
# tasks. _request_abort_from_signal() cancels the runner task
|
||||
# cooperatively instead; repeated signals still hard-exit in the
|
||||
# shared foreground signal handler.
|
||||
with foreground_shutdown_signals(
|
||||
first_signal_message=first_signal_message,
|
||||
on_signal=self._request_abort_from_signal,
|
||||
raise_on_first_signal=False,
|
||||
):
|
||||
self._run_task = asyncio.current_task()
|
||||
self._run_started = True
|
||||
# Do not raise KeyboardInterrupt directly from an OS signal while
|
||||
# the asyncio loop is active. Python can inject it into whichever
|
||||
# task is currently running, which produces noisy "Task exception
|
||||
# was never retrieved" logs from unrelated abxbus housekeeping
|
||||
# tasks. _request_abort_from_signal() cancels the runner task
|
||||
# cooperatively instead; repeated signals still hard-exit in the
|
||||
# shared foreground signal handler.
|
||||
# Keep signal ownership through bus destruction AND durable finalization.
|
||||
# Restoring the outer/default handler before those awaits leaves a real
|
||||
# race: a rapid second Ctrl+C can kill Python during teardown, producing
|
||||
# supervisor exit -1 / shell 255 instead of an acknowledged user abort.
|
||||
with foreground_shutdown_signals(
|
||||
first_signal_message="\n[🛑] Got {signal_name}, stopping crawl worker...\n",
|
||||
on_signal=self._request_abort_from_signal,
|
||||
interrupt_handlers={
|
||||
signal.SIGINT: self._interrupt_hook,
|
||||
**(
|
||||
{
|
||||
signal.SIGUSR1: lambda: self._choose_interrupt_action("skip"),
|
||||
signal.SIGUSR2: lambda: self._choose_interrupt_action("retry"),
|
||||
}
|
||||
if self.interactive_interrupts
|
||||
else {}
|
||||
),
|
||||
},
|
||||
raise_on_first_signal=False,
|
||||
):
|
||||
try:
|
||||
if await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(self.crawl):
|
||||
return
|
||||
# Startup and its first snapshot are one admission. Browser
|
||||
@@ -330,6 +451,7 @@ class CrawlRunner:
|
||||
self.max_concurrent_snapshots = max_concurrent_snapshots
|
||||
self.snapshot_semaphore = asyncio.Semaphore(max_concurrent_snapshots)
|
||||
live_ui = self._create_live_ui()
|
||||
self._live_ui = live_ui
|
||||
with live_ui if live_ui is not None else nullcontext():
|
||||
try:
|
||||
if snapshot_ids:
|
||||
@@ -339,26 +461,30 @@ class CrawlRunner:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
try:
|
||||
await self.process_service.stop_background_hooks()
|
||||
await self.bus.wait_until_idle(timeout=1.0 if self._skip_wait_until_idle else 30.0)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
await self.bus.destroy(clear=False)
|
||||
bus_destroyed = True
|
||||
finally:
|
||||
if not bus_destroyed:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
await self.bus.destroy(clear=False)
|
||||
if self._live_stream is not None:
|
||||
try:
|
||||
self._live_stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._live_stream = None
|
||||
if run_state_loaded:
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
finally:
|
||||
if not bus_destroyed:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
await self.process_service.stop_background_hooks()
|
||||
await self.bus.destroy(clear=False)
|
||||
if self._live_stream is not None:
|
||||
try:
|
||||
self._live_stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._live_stream = None
|
||||
if run_state_loaded:
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
if self._user_aborted:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
async def enqueue_snapshot(self, snapshot_id: str, crawl_start_event: CrawlStartEvent | None = None) -> None:
|
||||
if self._resource_deferred:
|
||||
@@ -377,10 +503,15 @@ class CrawlRunner:
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
current_event = crawl_start_event or get_current_event()
|
||||
if not isinstance(current_event, CrawlStartEvent) and in_handler_context():
|
||||
return
|
||||
if self._observed_snapshot_cost is None and self._warmup_snapshot_id is None:
|
||||
observation = resource_admission.memory_headroom()
|
||||
self._memory_baseline = observation[0] if observation is not None else None
|
||||
self._memory_peak_cost = 0
|
||||
self._warmup_snapshot_id = snapshot_id
|
||||
if isinstance(current_event, CrawlStartEvent):
|
||||
task = asyncio.create_task(self.run_snapshot(snapshot_id, current_event), context=_runner_task_context())
|
||||
elif in_handler_context():
|
||||
return
|
||||
else:
|
||||
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
|
||||
self._initial_snapshot_admitted = False
|
||||
@@ -409,6 +540,7 @@ class CrawlRunner:
|
||||
if task.done():
|
||||
if self.snapshot_tasks.get(snapshot_id) is task:
|
||||
self.snapshot_tasks.pop(snapshot_id, None)
|
||||
self._finish_warmup_snapshot(snapshot_id)
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError as err:
|
||||
@@ -433,13 +565,16 @@ class CrawlRunner:
|
||||
return
|
||||
continue
|
||||
await self.heartbeat_active_leases()
|
||||
done, _pending = await asyncio.wait(pending_tasks, timeout=10.0, return_when=asyncio.FIRST_COMPLETED)
|
||||
self._observe_snapshot_memory()
|
||||
done, _pending = await asyncio.wait(pending_tasks, timeout=RESOURCE_RECHECK_SECONDS, return_when=asyncio.FIRST_COMPLETED)
|
||||
self._observe_snapshot_memory()
|
||||
if not done:
|
||||
continue
|
||||
for task in done:
|
||||
for snapshot_id, tracked_task in list(self.snapshot_tasks.items()):
|
||||
if tracked_task is task:
|
||||
self.snapshot_tasks.pop(snapshot_id, None)
|
||||
self._finish_warmup_snapshot(snapshot_id)
|
||||
break
|
||||
try:
|
||||
task.result()
|
||||
@@ -457,6 +592,41 @@ class CrawlRunner:
|
||||
if not stop_scheduling and enqueue_projected:
|
||||
await self.enqueue_pending_snapshots_from_projection()
|
||||
|
||||
def _observe_snapshot_memory(self) -> None:
|
||||
if self._memory_baseline is None:
|
||||
return
|
||||
observation = resource_admission.memory_headroom()
|
||||
if observation is not None:
|
||||
used, _available = observation
|
||||
# Only attribute this workload's growth to a capture. Other jobs on
|
||||
# the host reduce available headroom, but are not a per-capture cost
|
||||
# to multiply again for every snapshot we want to admit.
|
||||
self._memory_peak_cost = max(
|
||||
self._memory_peak_cost,
|
||||
used - self._memory_baseline,
|
||||
)
|
||||
if self._observed_snapshot_cost is not None:
|
||||
self._observed_snapshot_cost = max(self._observed_snapshot_cost, self._memory_peak_cost)
|
||||
|
||||
def _finish_warmup_snapshot(self, snapshot_id: str) -> None:
|
||||
if snapshot_id != self._warmup_snapshot_id or self._observed_snapshot_cost is not None:
|
||||
return
|
||||
self._observe_snapshot_memory()
|
||||
if self._memory_peak_cost > 0:
|
||||
self._observed_snapshot_cost = self._memory_peak_cost
|
||||
headroom = resource_admission.memory_headroom()
|
||||
logging.getLogger(__name__).info(
|
||||
"Snapshot admission: concurrency limit=%s, observed memory growth=%s bytes, available=%s bytes",
|
||||
self.max_concurrent_snapshots,
|
||||
self._observed_snapshot_cost,
|
||||
headroom[1] if headroom else None,
|
||||
)
|
||||
else:
|
||||
# No observed cost cannot justify parallel admission. Probe the
|
||||
# next real snapshot while the queue keeps moving one at a time.
|
||||
self._memory_baseline = None
|
||||
self._warmup_snapshot_id = None
|
||||
|
||||
async def heartbeat_active_leases(self) -> None:
|
||||
# These are resumable work-item leases, not orchestrator-election
|
||||
# heartbeats. Each update is a short autocommit statement; network and
|
||||
@@ -524,7 +694,11 @@ class CrawlRunner:
|
||||
self.max_concurrent_snapshots = resource_admission.snapshot_limit(int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
|
||||
|
||||
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
|
||||
available_slots = max(0, self.max_concurrent_snapshots - len(active_snapshot_ids))
|
||||
available_slots = resource_admission.additional_snapshot_slots(
|
||||
self.max_concurrent_snapshots,
|
||||
len(active_snapshot_ids),
|
||||
self._observed_snapshot_cost,
|
||||
)
|
||||
if available_slots <= 0:
|
||||
return
|
||||
pending_snapshot_ids = await sync_to_async(
|
||||
@@ -548,6 +722,9 @@ class CrawlRunner:
|
||||
from archivebox.machine.models import Machine, NetworkInterface, Process
|
||||
|
||||
self.primary_url = self.crawl.get_urls_list()[0] if self.crawl.get_urls_list() else ""
|
||||
# Validate saved binary paths at the execution boundary, including
|
||||
# config edited since this process first registered its Machine.
|
||||
machine = Machine._sanitize_config(Machine.current())
|
||||
current_iface = NetworkInterface.current(refresh=not self.allow_maintenance_on_inactive_crawl)
|
||||
current_process = Process.current()
|
||||
if current_process.iface_id != current_iface.id or current_process.machine_id != current_iface.machine_id:
|
||||
@@ -556,7 +733,7 @@ class CrawlRunner:
|
||||
current_process.save(update_fields=["iface", "machine", "modified_at"])
|
||||
self.persona = self.crawl.resolve_persona()
|
||||
self.base_config = get_config(crawl=self.crawl, overrides=self.config_overrides)
|
||||
self.derived_config = dict(Machine.current().config or {})
|
||||
self.derived_config = dict(machine.config or {})
|
||||
self.crawl_output_dir = str(self.crawl.output_dir)
|
||||
if self.persona:
|
||||
self.base_config.update(
|
||||
@@ -697,10 +874,12 @@ class CrawlRunner:
|
||||
stderr_is_tty = sys.stderr.isatty()
|
||||
interactive_tty = stdout_is_tty or stderr_is_tty
|
||||
stream = sys.stderr if stderr_is_tty or not stdout_is_tty else sys.stdout
|
||||
if interactive_tty and os.path.exists("/dev/tty"):
|
||||
progress_tty = os.environ.get("ARCHIVEBOX_PROGRESS_TTY")
|
||||
if progress_tty or (interactive_tty and os.path.exists("/dev/tty")):
|
||||
try:
|
||||
self._live_stream = open("/dev/tty", "w", buffering=1, encoding=stream.encoding or "utf-8")
|
||||
self._live_stream = open(progress_tty or "/dev/tty", "w", buffering=1, encoding=stream.encoding or "utf-8")
|
||||
stream = self._live_stream
|
||||
interactive_tty = stream.isatty()
|
||||
except OSError:
|
||||
self._live_stream = None
|
||||
try:
|
||||
@@ -874,7 +1053,8 @@ class CrawlRunner:
|
||||
if self.initial_snapshot_ids is not None:
|
||||
remaining_snapshot_ids = iter(snapshot_ids)
|
||||
while True:
|
||||
batch = list(zip(range(self.max_concurrent_snapshots), remaining_snapshot_ids, strict=False))
|
||||
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
|
||||
batch = list(zip(range(slots), remaining_snapshot_ids, strict=False))
|
||||
if not batch:
|
||||
return
|
||||
for _slot, snapshot_id in batch:
|
||||
@@ -885,7 +1065,8 @@ class CrawlRunner:
|
||||
await self.enqueue_snapshot(snapshot_id)
|
||||
await self.wait_for_snapshot_tasks(enqueue_projected=False)
|
||||
else:
|
||||
for snapshot_id in snapshot_ids[: self.max_concurrent_snapshots]:
|
||||
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
|
||||
for snapshot_id in snapshot_ids[:slots]:
|
||||
if await self.crawl_is_cancelled():
|
||||
break
|
||||
if await self.crawl_is_paused():
|
||||
@@ -1033,7 +1214,9 @@ class CrawlRunner:
|
||||
snapshot_selected_plugins = (
|
||||
self.requested_plugins or snapshot["retry_plugins"] or snapshot["selected_plugins"] or self.selected_plugins
|
||||
)
|
||||
if snapshot["depth"] > 0 and CrawlLimitState.from_config(snapshot["config"]).get_stop_reason() in (
|
||||
# All remaining URLs share the crawl budget, including explicitly
|
||||
# submitted roots. abx-dl only enforces the current snapshot's size.
|
||||
if await sync_to_async(snapshot["_snapshot"].crawl.limit_stop_reason, thread_sensitive=True)(config=config) in (
|
||||
"crawl_max_size",
|
||||
"crawl_timeout",
|
||||
):
|
||||
@@ -1346,7 +1529,9 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
|
||||
# avoids hot-looping on the parent while child work is ready without
|
||||
# resurrecting a user cancellation that sealed the crawl after
|
||||
# selection.
|
||||
crawl.safe_update(
|
||||
if crawl.status != crawl.StatusChoices.STARTED:
|
||||
return False
|
||||
return crawl.safe_update(
|
||||
{
|
||||
"status": crawl.StatusChoices.STARTED,
|
||||
"retry_at": now + timedelta(seconds=ACTIVE_STATE_LEASE_SECONDS),
|
||||
@@ -1355,7 +1540,6 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
|
||||
refresh=False,
|
||||
extra_filter={"status": crawl.StatusChoices.STARTED},
|
||||
)
|
||||
return True
|
||||
if snapshot_count and not due_active_snapshots:
|
||||
if crawl.is_finished():
|
||||
if not crawl.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
@@ -1468,6 +1652,49 @@ def _run_due_snapshot_locked(snapshot, *, lock_seconds: int, interactive_interru
|
||||
return True
|
||||
return run_snapshot_maintenance(str(snapshot.id))
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
|
||||
config = get_config(crawl=snapshot.crawl, persona=snapshot.crawl.resolve_persona())
|
||||
configured_plugins = {name.strip().lower() for name in str(config.get("PLUGINS") or "").split(",") if name.strip()}
|
||||
chrome_selected = "chrome" in (configured_plugins or set(get_enabled_plugins(config=config)))
|
||||
if chrome_selected and str(config.get("CHROME_ISOLATION") or "crawl").lower() == "crawl":
|
||||
# The due Snapshot is only the scheduler's admission signal. Running
|
||||
# it as an explicit one-snapshot CrawlRunner tears down CrawlSetup after
|
||||
# every URL, even though crawl isolation owns one browser for the whole
|
||||
# crawl. The crawl lifecycle lock already serializes all of its child
|
||||
# work, so let one runner drain the due siblings and discoveries.
|
||||
if any(process.is_running for process in snapshot.process_set.filter(status="running").iterator()):
|
||||
snapshot.update_and_requeue(retry_at=timezone.now() + timedelta(seconds=lock_seconds))
|
||||
return True
|
||||
if snapshot.fs_migration_needed:
|
||||
# Crawl isolation bypasses the per-Snapshot path below, so migrate
|
||||
# this admitted Snapshot before the CrawlRunner derives its output
|
||||
# directory. Claim the Snapshot as well as holding the lifecycle
|
||||
# lock: maintenance-only workers use the Snapshot lease directly.
|
||||
if not snapshot.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
snapshot.refresh_from_db()
|
||||
claimed_status = snapshot.status
|
||||
claimed_retry_at = snapshot.retry_at
|
||||
if snapshot.fs_migration_needed:
|
||||
snapshot.migrate_filesystem_to_current_version()
|
||||
if not snapshot.safe_update(
|
||||
{"retry_at": timezone.now()},
|
||||
refresh=True,
|
||||
extra_filter={"status": claimed_status, "retry_at": claimed_retry_at},
|
||||
):
|
||||
return False
|
||||
if not snapshot.crawl.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
_runner_console_line(crawl=snapshot.crawl)
|
||||
_run_crawl_locked(
|
||||
str(snapshot.crawl_id),
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
return True
|
||||
|
||||
if not snapshot.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
snapshot.refresh_from_db()
|
||||
@@ -1670,12 +1897,11 @@ def _run_due_crawl_status(
|
||||
due_crawl = Crawl.objects.filter(id=due_crawl_id).first()
|
||||
if due_crawl is None:
|
||||
return True
|
||||
run_due_crawl(
|
||||
return run_due_crawl(
|
||||
due_crawl,
|
||||
lock_seconds=lock_seconds,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _run_due_snapshot_query(
|
||||
@@ -1704,13 +1930,14 @@ def _run_due_snapshot_id(snapshot_id, *, lock_seconds: int, interactive_interrup
|
||||
due_snapshot = Snapshot.objects.filter(id=due_snapshot_id).first()
|
||||
if due_snapshot is None:
|
||||
return True
|
||||
run_due_snapshot(
|
||||
# A lost/busy claim is not progress. Let the scheduler try other work and
|
||||
# reach its idle wait instead of spinning on the same due child forever.
|
||||
return run_due_snapshot(
|
||||
due_snapshot,
|
||||
lock_seconds=lock_seconds,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _run_due_binary() -> bool:
|
||||
@@ -1728,8 +1955,7 @@ def _run_due_binary() -> bool:
|
||||
due_binary = Binary.objects.filter(id=due_binary_id).first()
|
||||
if due_binary is None:
|
||||
return True
|
||||
run_due_binary(due_binary, lock_seconds=60)
|
||||
return True
|
||||
return run_due_binary(due_binary, lock_seconds=60)
|
||||
|
||||
|
||||
def run_pending_crawls(
|
||||
|
||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.services.base import BaseService
|
||||
|
||||
|
||||
@@ -34,7 +33,7 @@ def project_discovered_snapshots(snapshot_id: str) -> list:
|
||||
crawl_output_dir=crawl.output_dir,
|
||||
snapshot_output_dir=snapshot.output_dir,
|
||||
)
|
||||
if CrawlLimitState.from_config(config).get_stop_reason() in ("crawl_max_size", "crawl_timeout"):
|
||||
if crawl.limit_stop_reason(config=config) in ("crawl_max_size", "crawl_timeout"):
|
||||
return []
|
||||
return crawl.create_discovered_snapshots(snapshot, discovered_urls, depth=snapshot.depth + 1)
|
||||
|
||||
@@ -114,12 +113,7 @@ def finalize_completed_snapshot(
|
||||
def _crawl_limit_stop_reason(crawl) -> str:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config_model = get_config(crawl=crawl)
|
||||
config = config_model.for_crawl_runtime(
|
||||
crawl=crawl,
|
||||
persona=crawl.resolve_persona(),
|
||||
)
|
||||
return CrawlLimitState.from_config(config).get_stop_reason()
|
||||
return crawl.limit_stop_reason(config=get_config(crawl=crawl))
|
||||
|
||||
|
||||
class SnapshotService(BaseService):
|
||||
|
||||
@@ -1036,7 +1036,6 @@
|
||||
}
|
||||
|
||||
#result_list tbody td {
|
||||
padding: 12px 16px;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1246,8 +1245,8 @@
|
||||
body.change-list #changelist {
|
||||
border: 0 !important;
|
||||
}
|
||||
body.change-list #changelist .actions .button,
|
||||
body.change-list #changelist .actions select {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .button,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
@@ -1340,8 +1339,8 @@
|
||||
}
|
||||
|
||||
/* The native select receives the actual click, including in WKWebView. */
|
||||
body.change-list #changelist .actions .action-rearchive-select,
|
||||
body.change-list #changelist .actions .action-permissions-select {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .action-rearchive-select,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .action-permissions-select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -1352,9 +1351,9 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.change-list #changelist .action-rearchive-wrapper > .button,
|
||||
body.change-list #changelist .action-permissions-wrapper > .button {
|
||||
padding: 8px 16px !important;
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .action-rearchive-wrapper > .button,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .action-permissions-wrapper > .button {
|
||||
padding: 8px 18px !important;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
flex: 0 0 auto;
|
||||
@@ -1528,6 +1527,9 @@
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
@@ -1541,15 +1543,27 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-pill {
|
||||
min-width: 0;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
padding: 2px 6px 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 12px;
|
||||
@@ -2548,6 +2562,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'admin-inline-tags.js' %}"></script>
|
||||
<script src="{% static 'files-icon-piles.js' %}"></script>
|
||||
{% include "includes/output_delete_controls.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,7 +46,7 @@
|
||||
<script src="{% static 'admin/js/filters.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% if embedded_changelist %} embedded-change-list{% endif %}{% endblock %}
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list filters-collapsed{% if embedded_changelist %} embedded-change-list{% endif %}{% endblock %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
|
||||
@@ -69,10 +69,7 @@
|
||||
if (!toggle) return;
|
||||
|
||||
function applyState() {
|
||||
var storedState = localStorage.getItem(storageKey);
|
||||
var collapsed = storedState === null
|
||||
? window.matchMedia('(max-width: 1180px)').matches
|
||||
: storedState === 'true';
|
||||
var collapsed = localStorage.getItem(storageKey) !== 'false';
|
||||
document.body.classList.toggle('filters-collapsed', collapsed);
|
||||
toggle.textContent = collapsed ? toggle.dataset.showLabel : toggle.dataset.hideLabel;
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ media.js }}
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list filters-collapsed{% endblock %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
@@ -113,7 +113,7 @@
|
||||
if (!toggle) return;
|
||||
|
||||
function applyState() {
|
||||
var collapsed = localStorage.getItem(storageKey) === 'true';
|
||||
var collapsed = localStorage.getItem(storageKey) !== 'false';
|
||||
document.body.classList.toggle('filters-collapsed', collapsed);
|
||||
toggle.textContent = collapsed ? toggle.dataset.showLabel : toggle.dataset.hideLabel;
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{% if add %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modeInputs = Array.from(document.querySelectorAll('input[name="import_mode"]'));
|
||||
@@ -256,9 +257,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_top %}
|
||||
{% if add %}
|
||||
<section class="persona-import-hero">
|
||||
<div>
|
||||
<h2>Create a new Persona (Authentication + Configuration Profile)</h2>
|
||||
@@ -274,6 +277,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
type="button"
|
||||
class="button filter-pane-toggle"
|
||||
id="changelist-toolbar-filter-toggle"
|
||||
aria-expanded="true"
|
||||
>{% translate "Filters" %} ◂</button>
|
||||
aria-expanded="false"
|
||||
>{% translate "Filters" %} ▸</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,13 @@
|
||||
}
|
||||
.cards .card .card-tags span {
|
||||
display: inline-block;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
pointer-events: auto;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
opacity: 0.95;
|
||||
@@ -108,21 +115,21 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.cards .card .card-title {
|
||||
padding: 4px 5px;
|
||||
padding: 4px 5px 1px;
|
||||
background-color: #fffcfc;
|
||||
/*height: 50px;
|
||||
vertical-align: middle;
|
||||
line-height: 50px;*/
|
||||
}
|
||||
.cards .card .card-title h4 {
|
||||
color: initial;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
line-height: normal;
|
||||
margin: 0px;
|
||||
padding: 5px 0px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -131,6 +138,20 @@
|
||||
.cards .card .card-title h4 .title-text {
|
||||
user-select: all;
|
||||
}
|
||||
.cards .card .card-title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.cards .card .card-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cards .card .card-url .snapshot-url-domain {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cards .card .card-url :is(.snapshot-url-prefix, .snapshot-url-suffix) {
|
||||
color: #b0b7c0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cards .card .card-title .link-favicon {
|
||||
height: 15px;
|
||||
margin: 2px;
|
||||
@@ -156,8 +177,10 @@
|
||||
justify-self: start;
|
||||
}
|
||||
.cards .card .card-meta .card-date {
|
||||
display: block;
|
||||
justify-self: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.cards .card .card-meta .card-size {
|
||||
justify-self: end;
|
||||
@@ -187,8 +210,11 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 8px 6px;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
color: #84909f;
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -240,51 +266,40 @@
|
||||
<div class="card-info card-meta">
|
||||
<input type="checkbox" class="action-select" name="_selected_action" value="{{obj.pk}}"/>
|
||||
<a href="{% url 'admin:core_snapshot_change' obj.pk %}" class="card-date">
|
||||
<span class="timestamp">{{obj.bookmarked_at}}</span>
|
||||
<span class="timestamp">{{ obj.bookmarked_at|date:"Y-m-d" }}</span>
|
||||
<span class="timestamp" style="white-space:nowrap">{{ obj.bookmarked_at|date:"g:i A" }}</span>
|
||||
</a>
|
||||
<span class="card-size">{{ obj.archive_size | file_size }}</span>
|
||||
{% with size=obj.archive_size|grid_file_size %}
|
||||
{% if size %}<span class="card-size">{{ size }}</span>{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-title" title="{{obj.title}}">
|
||||
<a href="{% snapshot_base_url obj %}/index.html">
|
||||
<a href="{% snapshot_detail_url obj %}">
|
||||
<h4>
|
||||
{% if obj.is_archived %}
|
||||
<img src="{% snapshot_base_url obj %}/favicon.ico" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>
|
||||
{% snapshot_role_output_url obj 'list_icon' True as favicon_url %}
|
||||
{% if favicon_url %}<img src="{{ favicon_url }}" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>{% endif %}
|
||||
{% else %}
|
||||
<img src="{% static 'spinner.gif' %}" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>
|
||||
{% endif %}
|
||||
<span class="title-text">{{obj.title|default:'Pending...' }}</span>
|
||||
<span class="title-text">{{ obj.title|default:obj.domain }}</span>
|
||||
</h4>
|
||||
</a>
|
||||
</div>
|
||||
<code class="card-url" title="{{obj.url}}">{{obj.url}}</code>
|
||||
<code class="card-url" title="{{obj.url}}">{{obj.url|snapshot_url_text}}</code>
|
||||
<div class="card-media">
|
||||
<a href="{% snapshot_base_url obj %}/index.html" class="card-thumbnail">
|
||||
{% admin_snapshot_archiveresult_url obj 'screenshot' 'screenshot.png' as screenshot_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot-1.png' as extension_screenshot_1_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot.png' as extension_screenshot_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'favicon' 'favicon.ico' as favicon_url %}
|
||||
{% if screenshot_url or extension_screenshot_1_url or extension_screenshot_url or favicon_url %}
|
||||
<img
|
||||
src="{% firstof screenshot_url extension_screenshot_1_url extension_screenshot_url favicon_url %}"
|
||||
alt="{{obj.title|default:'Not yet archived...'}}"
|
||||
data-fallbacks="{{ extension_screenshot_1_url }},{{ extension_screenshot_url }},{{ favicon_url }}"
|
||||
onerror="const current=this.src; const fallbacks=(this.dataset.fallbacks || '').split(',').filter(Boolean).map(candidate => new URL(candidate, document.baseURI).href).filter((candidate, index, all) => candidate !== current && all.indexOf(candidate) === index); if (fallbacks.length) { this.dataset.fallbacks=fallbacks.slice(1).join(','); this.src=fallbacks[0]; } else { this.onerror=null; this.closest('.card-thumbnail').classList.add('missing'); this.remove(); }"
|
||||
/>
|
||||
{% elif obj.status == 'started' %}
|
||||
<img src="{% static 'spinner.gif' %}" class="loading-preview" alt="Archiving in progress"/>
|
||||
{% else %}
|
||||
<span class="missing-preview">No preview captured</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{% snapshot_detail_url obj %}" class="card-thumbnail">
|
||||
{% snapshot_thumbnail obj admin=True width="100%" height="245px" %}
|
||||
{% if obj.tags_str %}
|
||||
<div class="card-tags">
|
||||
{% for tag in obj.tags_str|split:',' %}
|
||||
<span>
|
||||
<span title="{{tag}}">
|
||||
{{tag}}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-outputs">
|
||||
{% if obj.status == 'started' %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'admin/css/responsive.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'add.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
Check the server log or the <a href="/admin/core/archiveresult/?o=-1">Log</a> page for detailed progress...
|
||||
</center>
|
||||
</div>
|
||||
<input id="url-file-input" type="file" multiple hidden>
|
||||
<form id="add-form" method="POST" class="p-form">{% csrf_token %}
|
||||
<center>
|
||||
<h1>Create a new Crawl</h1>
|
||||
@@ -69,6 +71,11 @@
|
||||
<pre id="url-highlight-layer" class="url-highlight-layer" aria-hidden="true"></pre>
|
||||
{{ form.url }}
|
||||
</div>
|
||||
<div class="url-upload-controls">
|
||||
<button type="button" id="url-upload-button"><span aria-hidden="true">↥</span> Upload</button>
|
||||
<span>or drag & drop in files containing URLs</span>
|
||||
</div>
|
||||
<div id="url-upload-status" class="help-text" role="status"></div>
|
||||
</div>
|
||||
<aside class="detected-urls-panel" aria-live="polite">
|
||||
<div class="detected-urls-header">
|
||||
@@ -1094,6 +1101,75 @@
|
||||
}
|
||||
|
||||
urlTextarea.addEventListener('input', updateURLPreview);
|
||||
const fileInput = document.getElementById('url-file-input');
|
||||
const uploadButton = document.getElementById('url-upload-button');
|
||||
const uploadStatus = document.getElementById('url-upload-status');
|
||||
const dropZone = document.querySelector('.url-editor-column');
|
||||
let pendingFileReads = 0;
|
||||
let fileReadQueue = Promise.resolve();
|
||||
|
||||
function setUploadStatus(message, warning = false) {
|
||||
uploadStatus.textContent = message;
|
||||
uploadStatus.classList.toggle('url-upload-warning', warning);
|
||||
}
|
||||
|
||||
function importURLFiles(files) {
|
||||
if (!files.length) return;
|
||||
pendingFileReads++;
|
||||
setUploadStatus('Reading files…');
|
||||
const errors = [];
|
||||
fileReadQueue = fileReadQueue.then(async function() {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (/[\u0000-\u0008\u000e-\u001f]/.test(text)) {
|
||||
errors.push(`${file.name}: could not read as text. Use a text or bookmarks export.`);
|
||||
continue;
|
||||
}
|
||||
if (!extractURLs(text).length) {
|
||||
errors.push(`${file.name}: no URLs found.`);
|
||||
}
|
||||
if (!text) continue;
|
||||
urlTextarea.value += (urlTextarea.value && !urlTextarea.value.endsWith('\n') ? '\n' : '') + text;
|
||||
urlTextarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
urlTextarea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: could not import this file. Try selecting it again.`);
|
||||
}
|
||||
}
|
||||
urlTextarea.focus();
|
||||
}).catch(function() {
|
||||
errors.push('File import failed. Try selecting the files again.');
|
||||
}).finally(function() {
|
||||
pendingFileReads--;
|
||||
setUploadStatus(
|
||||
errors.length ? errors.join(' ') : (pendingFileReads ? 'Reading files…' : 'File text added.'),
|
||||
errors.length > 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
uploadButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', function() {
|
||||
const files = Array.from(fileInput.files);
|
||||
fileInput.value = '';
|
||||
importURLFiles(files);
|
||||
});
|
||||
document.addEventListener('dragover', function(event) {
|
||||
if (!Array.from(event.dataTransfer.types).includes('Files')) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
dropZone.classList.add('url-file-dragover');
|
||||
});
|
||||
document.addEventListener('dragleave', function(event) {
|
||||
if (!event.relatedTarget) dropZone.classList.remove('url-file-dragover');
|
||||
});
|
||||
document.addEventListener('drop', function(event) {
|
||||
dropZone.classList.remove('url-file-dragover');
|
||||
if (!Array.from(event.dataTransfer.types).includes('Files')) return;
|
||||
event.preventDefault();
|
||||
importURLFiles(Array.from(event.dataTransfer.files));
|
||||
});
|
||||
urlTextarea.addEventListener('scroll', syncHighlightScroll);
|
||||
window.addEventListener('resize', syncHighlightMetrics);
|
||||
window.addEventListener('resize', syncPreviewPanelHeight);
|
||||
@@ -1307,6 +1383,11 @@
|
||||
|
||||
// Form submission handler
|
||||
document.getElementById('add-form').addEventListener('submit', function(event) {
|
||||
if (pendingFileReads) {
|
||||
event.preventDefault();
|
||||
setUploadStatus('Please wait for the files to finish reading.', true);
|
||||
return;
|
||||
}
|
||||
document.getElementById('in-progress').style.display = 'block'
|
||||
document.getElementById('add-form').style.display = 'none'
|
||||
document.getElementById('delay-warning').style.display = 'block'
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
#table-bookmarks tbody td {
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -198,21 +198,29 @@
|
||||
}
|
||||
|
||||
.snapshot-preview-cell {
|
||||
width: 116px;
|
||||
position: relative;
|
||||
width: 110px;
|
||||
min-width: 116px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-preview-cell a {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 102px;
|
||||
height: 102px;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-preview-cell .snapshot-thumbnail {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
#content .public-snapshot-list .snapshot-preview-cell .snapshot-preview,
|
||||
#content .public-snapshot-list .snapshot-preview-cell img.snapshot-preview.screenshot {
|
||||
box-sizing: border-box;
|
||||
@@ -256,34 +264,29 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.snapshot-favicon-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
#content .snapshot-favicon-link .link-favicon {
|
||||
#content .snapshot-url .link-favicon {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 18px;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
max-width: 1em;
|
||||
flex: 0 0 1em;
|
||||
border-radius: 50%;
|
||||
object-fit: contain; opacity: 0.75; font-size: inherit;
|
||||
}
|
||||
|
||||
#content .snapshot-url .link-favicon-empty { display: none; }
|
||||
.snapshot-url-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.snapshot-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -291,12 +294,17 @@
|
||||
}
|
||||
|
||||
.snapshot-url {
|
||||
display: block;
|
||||
margin-left: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: 0;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: #84909f;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
user-select: all;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
@@ -307,6 +315,16 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snapshot-url .snapshot-url-domain {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.snapshot-url :is(.snapshot-url-prefix, .snapshot-url-suffix) {
|
||||
color: #b0b7c0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.snapshot-tags-cell {
|
||||
width: 190px;
|
||||
}
|
||||
@@ -320,9 +338,10 @@
|
||||
}
|
||||
|
||||
.snapshot-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 160px;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
@@ -378,14 +397,87 @@
|
||||
}
|
||||
|
||||
.snapshot-files-cell {
|
||||
width: 220px;
|
||||
overflow: hidden;
|
||||
width: 132px;
|
||||
overflow: visible;
|
||||
}
|
||||
.snapshot-files-cell:has(.files-icon-pile:hover), .snapshot-files-cell:has(.files-icon-pile:focus-within) { position: relative; z-index: 10000; }
|
||||
|
||||
.snapshot-files-cell .files-icons {
|
||||
max-width: 200px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(5, 22px);
|
||||
grid-template-rows: 18px;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 22px;
|
||||
gap: 0;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .files-icon-pile { position: relative; display: inline-block; width: 16px; height: 18px; isolation: isolate; vertical-align: top; }
|
||||
.snapshot-files-cell .files-icon-pile::before, .snapshot-files-cell .files-icon-pile::after { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border: 1px solid #d9cdd3; border-radius: 3px; background: #f7f2f4; content: ""; pointer-events: none; }
|
||||
.snapshot-files-cell .files-icon-pile::before { transform: translate(2px,-2px) rotate(7deg); z-index: -2; }
|
||||
.snapshot-files-cell .files-icon-pile::after { transform: translate(1px,-1px) rotate(3deg); z-index: -1; background: #ece5e8; }
|
||||
.snapshot-files-cell .files-icon-pile-backplate { position: absolute; top: 20px; left: var(--files-icon-popup-left,0px); z-index: 0; display: none; width: var(--files-icon-card-width,116px); height: calc(var(--files-icon-rows,1) * 18px + 10px); border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile-popup { position: absolute; top: 20px; left: var(--files-icon-popup-left,0px); z-index: 0; box-sizing: border-box; display: block; width: var(--files-icon-card-width,116px); min-height: calc(var(--files-icon-rows,1) * 18px + 10px); margin: 0; padding: 8px 6px 4px; overflow: visible; border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; opacity: 1; }
|
||||
.snapshot-files-cell .files-icon-pile-popup > a, .snapshot-files-cell .files-icon-pile-popup > span { position: absolute; top: var(--files-icon-member-top,8px); left: var(--files-icon-member-left,6px); }
|
||||
.snapshot-files-cell .files-icon-pile-popup::before { position: absolute; top: -5px; left: 0; width: 100%; height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); content: ""; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-backplate, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-backplate { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-hover-bridge { position: absolute; top: 17px; left: var(--files-icon-popup-left,0px); z-index: 1; display: none; width: var(--files-icon-card-width,116px); height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-hover-bridge, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-hover-bridge { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > a:not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile > span:not(.files-icon-pile-popup) { position: absolute; top: 1px; left: min(calc(var(--files-icon-index,0) * 1px),2px); z-index: var(--files-icon-index,0); }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-top { left: var(--files-icon-front-offset,0px); z-index: calc(100 + var(--files-icon-front-index,0)); }
|
||||
.snapshot-files-cell .files-icon-pile > a:hover, .snapshot-files-cell .files-icon-pile > a:focus-visible { z-index: 10000 !important; }
|
||||
.snapshot-files-cell .files-icon-pile:hover, .snapshot-files-cell .files-icon-pile:focus-within { z-index: 9999; overflow: visible; }
|
||||
.snapshot-files-cell .files-icon-pile > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { visibility: hidden; transition: visibility 0s linear 150ms; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-cover { z-index: 200; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]::after { position: absolute; bottom: calc(100% + 7px); left: 0; z-index: 10001; padding: 5px 8px; border: 1px solid #111827; border-radius: 6px; background: #1f2937; box-shadow: 0 4px 12px #0f172a33; color: #fff; content: attr(data-tooltip); font-size: 11px; font-weight: 600; line-height: 1.2; opacity: 0; pointer-events: none; transition: opacity .08s ease,visibility 0s linear .08s; visibility: hidden; white-space: nowrap; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]:hover::after, .snapshot-files-cell .files-icon-pile a[data-tooltip]:focus-visible::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]:hover::after { opacity: 1; transition-delay: .08s,0s; visibility: visible; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile:focus-within > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { top: calc(24px + var(--files-icon-row,0) * 18px); left: var(--files-icon-member-left,3px); visibility: visible; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons:has(.files-icon-pile:hover) .files-icon-pile:not(:hover), .snapshot-files-cell .files-icons:has(.files-icon-pile:focus-within) .files-icon-pile:not(:focus-within) { opacity: .2; }
|
||||
.snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:hover::before, .snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:focus-visible::before { position: absolute; top: 17px; left: 50%; z-index: 210; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); border-top: 1px solid #d9cdd3; border-left: 1px solid #d9cdd3; background: #fff; content: ""; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-popup { top: 20px; left: var(--files-icon-popup-left,0px); visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s ease,visibility 0s linear 350ms; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile > .files-icon-pile-popup:popover-open { top: 20px; left: var(--files-icon-popup-left,0px); visibility: visible; opacity: 1; pointer-events: auto; transition-delay: 0s; }
|
||||
|
||||
.snapshot-files-cell .files-icons a {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon--singlefile { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
|
||||
.snapshot-files-cell .abx-output-icon--archivewebpage { color: #7c3aed; background: #f5f3ff; border-color: #ddd6fe; }
|
||||
.snapshot-files-cell .abx-output-icon--ytdlp { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
||||
.snapshot-files-cell .abx-output-icon--readability { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
||||
.snapshot-files-cell .abx-output-icon--forumdl { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||
.snapshot-files-cell .abx-output-icon--papersdl { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||
.snapshot-files-cell .abx-output-icon--git { color: #c2410c; background: #fff7ed; border-color: #fed7aa; }
|
||||
.snapshot-files-cell .abx-output-icon--gallerydl { color: #be185d; background: #fdf2f8; border-color: #fbcfe8; }
|
||||
.snapshot-files-cell .abx-output-icon--screenshot { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.snapshot-files-cell .files-icon-plugin--screenshot { color: #15803d; }
|
||||
|
||||
@keyframes snapshot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -502,7 +594,8 @@
|
||||
|
||||
.snapshot-preview-cell a {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
}
|
||||
|
||||
#content .public-snapshot-list .snapshot-preview-cell .snapshot-preview,
|
||||
@@ -565,8 +658,8 @@
|
||||
{% if public_search_stream_pending %}
|
||||
<span class="archivebox-search-stream-status" role="status" aria-live="polite">Searching...</span>
|
||||
{% else %}
|
||||
{{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }}
|
||||
<span>(page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }})</span>
|
||||
{{ page_obj.start_index }}-{{ page_obj.end_index }}{% if public_snapshot_count != None %} of {{ public_snapshot_count }}{% endif %}
|
||||
<span>(page {{ page_obj.number }}{% if public_page_count != None %} of {{ public_page_count }}{% endif %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,7 +669,7 @@
|
||||
<tr>
|
||||
<th class="snapshot-time">Saved</th>
|
||||
<th class="snapshot-preview-cell">Preview</th>
|
||||
<th>Snapshot ({{ page_obj.paginator.count }})</th>
|
||||
<th>Snapshot{% if public_snapshot_count != None %} ({{ public_snapshot_count }}){% endif %}</th>
|
||||
<th class="snapshot-tags-cell">Tags</th>
|
||||
<th class="snapshot-status-cell">Status</th>
|
||||
<th class="snapshot-files-cell">Files</th>
|
||||
@@ -604,7 +697,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="public-pagination">
|
||||
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }} total
|
||||
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }}{% if public_snapshot_count != None %} of {{ public_snapshot_count }} total{% endif %}
|
||||
<br>
|
||||
<span class="step-links">
|
||||
{% if page_obj.has_previous %}
|
||||
@@ -613,14 +706,14 @@
|
||||
{% endif %}
|
||||
|
||||
<span class="current">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
Page {{ page_obj.number }}{% if public_page_count != None %} of {{ public_page_count }}{% endif %}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% url_replace page=page_obj.next_page_number %}">next</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% url_replace page=page_obj.paginator.num_pages %}">last »</a>
|
||||
{% if page_obj.has_next and public_page_count != None %}
|
||||
<a href="?{% url_replace page=public_page_count %}">last »</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+560
-1005
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
{% load core_tags %}
|
||||
<div class="snapshot-variants-list">
|
||||
{% for capture in captures %}
|
||||
<a href="{% snapshot_detail_url capture %}" title="{{ capture.url }}"{% if capture.pk == snapshot.pk %} aria-current="page"{% endif %}>
|
||||
{{ capture.bookmarked_at|default:capture.created_at|default:capture.downloaded_at|date:"Y-m-d H:i:s" }} 💾 {{ capture.output_size|filesizeformat }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,74 @@
|
||||
{% load core_tags %}
|
||||
{% for result in archiveresults %}
|
||||
{% with display_path=result.path display_url='' preview_url='' %}
|
||||
{% if display_path %}{% snapshot_url snapshot display_path as display_url %}{% endif %}
|
||||
{% if display_path %}{% if result.direct_preview_path %}{% snapshot_url snapshot result.direct_preview_path as preview_url %}{% else %}{% snapshot_preview_url snapshot display_path result.result as preview_url %}{% endif %}{% endif %}
|
||||
<div class="thumb-card{% if forloop.first %} selected-card{% endif %}" data-plugin-name="{{result.name|plugin_name}}" data-output-group="{{result.output_group}}" data-output-size="{{result.size|default:0}}"{% if preview_url %} data-preview-url="{{preview_url}}"{% endif %}{% if display_path %} data-output-path="{{display_path}}"{% endif %}>
|
||||
<div class="thumb-body">
|
||||
<div class="thumb-actions">
|
||||
{% if not STATIC_EXPORT %}<a href="{% snapshot_files_url snapshot result.folder_path %}" data-no-preview="1" title="Open output folder" target="_blank" rel="noopener">📁</a>{% endif %}
|
||||
{% if display_path %}
|
||||
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
|
||||
{% endif %}
|
||||
{% if can_delete_outputs and result.result %}
|
||||
<button type="button" data-no-preview="1" data-archive-result-ids="{{result.result_ids}}" data-delete-url="{% admin_base_url %}/admin/core/archiveresult/" data-delete-handoff="1" data-delete-snapshot-id="{{snapshot.id}}" title="Delete this output">×</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if display_path %}
|
||||
<a href="{{preview_url}}" target="preview" title="./{{display_path}} (downloaded {{result.ts}})">
|
||||
<h4><span class="card-title-icon">{% plugin_icon result.name %}</span> {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small class="card-title-size">{{result.size|file_size:0}}</small>{% endif %}</h4>
|
||||
</a>
|
||||
{% else %}
|
||||
<h4><span class="card-title-icon">{% plugin_icon result.name %}</span> {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small class="card-title-size">{{result.size|file_size:0}}</small>{% endif %}</h4>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if result.result and display_path %}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
{% comment %}The outer detail UI is trusted and may be on web.*. Every
|
||||
live plugin card uses the same scoped _card document on the
|
||||
snapshot origin in both stack positions. Never run saved
|
||||
HTML/JS/CSS on web.* or share the admin session with snap-*.{% endcomment %}
|
||||
{% if not result.name|plugin_card_is_interactive %}
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}} (downloaded {{result.ts}})" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
{% endif %}
|
||||
{% plugin_card result.result %}
|
||||
</div>
|
||||
{% elif result.is_metadata and display_path %}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}}" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
{% output_card snapshot display_path result.name %}
|
||||
</div>
|
||||
{% elif display_path %}
|
||||
{# Fall back to generic iframe for filesystem-discovered files #}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}}" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
<iframe class="card-img-top" src="{{display_url}}?autoplay=0" allow="autoplay 'none'; fullscreen 'none'; navigation-override 'none'; " sandbox="allow-same-origin allow-top-navigation-by-user-activation allow-scripts allow-forms" scrolling="no" loading="lazy" fetchpriority="low"></iframe>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% if loose_items or not STATIC_EXPORT %}
|
||||
<div class="thumb-card" data-other-files data-output-size="0"{% if not STATIC_EXPORT %} data-discover-files{% endif %}>
|
||||
<div class="thumb-body">
|
||||
<div class="thumb-actions">
|
||||
<a href="{% snapshot_files_url snapshot %}" data-no-preview="1" title="{% if STATIC_EXPORT %}Open snapshot manifest{% else %}Browse all snapshot files{% endif %}" target="_blank" rel="noopener">📁</a>
|
||||
</div>
|
||||
<h4><span class="card-title-icon">📦</span> Other files</h4>
|
||||
</div>
|
||||
<div class="thumbnail-wrapper other-files-preview">
|
||||
<small class="discovery-status" aria-live="polite"></small>
|
||||
<div class="loose-items">
|
||||
{% for item in loose_items %}
|
||||
{% if item.is_dir %}
|
||||
<a href="{% snapshot_files_url snapshot item.path %}" data-output-size="{{item.size|default:0}}" data-no-preview="1" target="_blank" rel="noopener">📁 {{item.name}}</a>
|
||||
{% else %}
|
||||
<a href="{% snapshot_url snapshot item.path %}" data-output-size="{{item.size|default:0}}" data-no-preview="1" target="_blank" rel="noopener">📄 {{item.name}}</a>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<a href="{% snapshot_files_url snapshot %}" data-no-preview="1" target="_blank" rel="noopener">📁 Browse all files</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,347 @@
|
||||
<style>
|
||||
/* Inline so portable exports retain the output browser. */
|
||||
.snapshot-stacks .header-bottom {
|
||||
background: #f5f3f4;
|
||||
padding: 6px 8px 7px;
|
||||
overflow: visible;
|
||||
border-bottom: 1px solid #ded5da;
|
||||
}
|
||||
.snapshot-stacks [hidden] {
|
||||
display: none!important;
|
||||
}
|
||||
.stack-browser {
|
||||
color: #352b30;
|
||||
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||
}
|
||||
.stack-shelf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--stack-columns),minmax(0,1fr));
|
||||
gap: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.output-stack {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stack-heading {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.stack-heading-label,
|
||||
.stack-heading-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.stack-heading-label {
|
||||
min-width: 0;
|
||||
}
|
||||
.stack-heading-right {
|
||||
justify-self: end;
|
||||
}
|
||||
.stack-heading strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stack-size {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 16px;
|
||||
color: #8b8588;
|
||||
font-size: 10px;
|
||||
font-weight: 450;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stack-size[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.stack-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: #e8e1e4;
|
||||
border-radius: 10px;
|
||||
color: #7d6671;
|
||||
font-size: 9px;
|
||||
}
|
||||
.stack-pile {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 158px;
|
||||
margin: 0 6px 0 0;
|
||||
perspective: 500px;
|
||||
}
|
||||
.stack-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid #d9cdd3;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 2px 5px #35202d12;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: transform .2s ease,box-shadow .2s ease;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
.stack-sheet-2 {
|
||||
transform: translate(5px,-5px) rotate(1.6deg);
|
||||
background: #ece5e8;
|
||||
}
|
||||
.stack-sheet-1 {
|
||||
transform: translate(2px,-3px) rotate(.8deg);
|
||||
background: #f7f2f4;
|
||||
}
|
||||
.stack-sheet-0 {
|
||||
z-index: 2;
|
||||
}
|
||||
.output-stack:hover .stack-sheet-0 {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 12px #35202d26;
|
||||
}
|
||||
.output-stack:hover .stack-sheet-1 {
|
||||
transform: translate(3px,-4px) rotate(1.2deg);
|
||||
}
|
||||
.output-stack[aria-expanded=true] .stack-sheet-0 {
|
||||
border-color: #ae2459;
|
||||
box-shadow: 0 0 0 2px #ae245922;
|
||||
}
|
||||
.output-stack[aria-expanded=true] .stack-heading {
|
||||
color: #a51d52;
|
||||
}
|
||||
.output-stack[aria-expanded=true] .stack-chevron {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
.output-stack.has-selection .stack-count {
|
||||
background: #ac2258;
|
||||
color: #fff;
|
||||
}
|
||||
.stack-sheet-label {
|
||||
display: block;
|
||||
background: #faf8f9;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #eee7eb;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #6c5862;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.stack-cover {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
}
|
||||
.stack-cover .thumb-card {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
content-visibility: visible;
|
||||
border: 0;
|
||||
}
|
||||
/* Hide only header chrome on stack covers, including icons with inline display styles. */
|
||||
.stack-cover .thumb-body > .thumb-actions,
|
||||
.stack-cover .thumb-body h4 small,
|
||||
.stack-cover .thumb-body h4 .card-title-icon,
|
||||
.stack-cover .thumb-body h4 .abx-plugin-icon {
|
||||
display: none !important;
|
||||
}
|
||||
.stack-chevron {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 0 3px 0 2px;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid #9c486a;
|
||||
border-bottom: 1px solid #9c486a;
|
||||
color: #9c486a;
|
||||
transition: transform .2s;
|
||||
}
|
||||
.stack-no-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
border: 1px dashed #d1c3ca;
|
||||
color: #a28d97;
|
||||
border-radius: 7px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.stack-file-symbol {
|
||||
display: block;
|
||||
margin: auto;
|
||||
color: #ad8b9a;
|
||||
font-size: 30px;
|
||||
}
|
||||
.stack-tray {
|
||||
margin: 16px 0 0;
|
||||
background: #eae3e6;
|
||||
border: 1px solid #ded0d7;
|
||||
border-radius: 9px;
|
||||
padding: 6px;
|
||||
animation: stack-reveal .18s ease;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.stack-tray-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill,minmax(200px,1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.stack-tray .thumb-card {
|
||||
min-height: 158px;
|
||||
max-height: 158px;
|
||||
height: 158px;
|
||||
border: 1px solid #d5c9ce;
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
content-visibility: visible;
|
||||
grid-row: auto;
|
||||
order: 0;
|
||||
}
|
||||
.stack-tray .thumb-card.selected-card {
|
||||
border: 2px solid #ac2258;
|
||||
box-shadow: 0 0 0 2px #ac225813;
|
||||
}
|
||||
.stack-browser .thumb-card .thumb-body {
|
||||
padding: 3px 5px;
|
||||
background: #fff;
|
||||
}
|
||||
.stack-browser .stack-cover .thumb-body {
|
||||
min-height: 20px;
|
||||
padding: 3px 6px;
|
||||
background: #faf8f9;
|
||||
border-bottom: 1px solid #eee7eb;
|
||||
}
|
||||
.stack-cover {
|
||||
--card-title-size: 10px;
|
||||
--card-title-color: #6c5862;
|
||||
}
|
||||
.stack-browser .thumb-card .thumb-body small {
|
||||
display: inline;
|
||||
font-size: 9px;
|
||||
}
|
||||
.stack-browser .thumb-card .thumbnail-wrapper {
|
||||
display: block;
|
||||
height: 130px!important;
|
||||
min-height: 130px;
|
||||
max-height: 130px!important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stack-browser .thumb-card .thumbnail-wrapper.other-files-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
height: 130px!important;
|
||||
min-height: 130px;
|
||||
max-height: 130px!important;
|
||||
}
|
||||
.stack-browser .thumb-card .thumbnail-wrapper.other-files-preview > .loose-items {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.stack-empty {
|
||||
grid-column: 1/-1;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #947482;
|
||||
}
|
||||
.stack-browser button:focus-visible {
|
||||
outline: 3px solid #b64574;
|
||||
outline-offset: 5px;
|
||||
}
|
||||
.stack-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 5px;
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
color: #795567;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stack-tray .thumb-actions {
|
||||
gap: 2px;
|
||||
}
|
||||
.stack-tray .thumb-actions a,.stack-tray .thumb-actions button {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.stack-tray > .stack-close {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 4px;
|
||||
z-index: 3;
|
||||
width: 18px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: #eae3e6;
|
||||
font-size: 15px;
|
||||
line-height: 14px;
|
||||
}
|
||||
@media (max-width:1200px) {
|
||||
.stack-heading { height: 32px; grid-template-rows: 16px 16px; }
|
||||
.stack-heading-label { display: contents; }
|
||||
.stack-heading strong { grid-column: 1 / -1; grid-row: 1; }
|
||||
.stack-size { grid-column: 1; grid-row: 2; }
|
||||
.stack-heading-right { grid-column: 2; grid-row: 2; }
|
||||
}
|
||||
@media (max-width:1000px) {
|
||||
.stack-shelf { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.stack-pile { height:126px; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.stack-shelf { grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 8px; }
|
||||
.stack-pile { height:88px; }
|
||||
.stack-tray-cards { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.snapshot-stacks .header-bottom { padding:5px 6px 6px; }
|
||||
}
|
||||
@keyframes stack-reveal {
|
||||
from { opacity:0; transform:translateY(-5px); }
|
||||
to { opacity:1; transform:translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.stack-browser * { animation:none!important; transition:none!important; }
|
||||
}
|
||||
|
||||
.other-files-preview { padding: 6px; }
|
||||
.other-files-preview .discovery-status:empty { display: none; }
|
||||
.other-files-preview .loose-items {
|
||||
height: auto;
|
||||
max-height: 116px;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
function initializeOutputStacks() {
|
||||
const grid = document.querySelector('.thumb-grid');
|
||||
const data = document.getElementById('snapshot-output-groups');
|
||||
if (!grid || !data) return;
|
||||
const cards = [...grid.children];
|
||||
const groups = JSON.parse(data.textContent).map(group => ({...group, cards: []}));
|
||||
for (const card of cards) {
|
||||
(groups.find(group => group.id === card.dataset.outputGroup) || groups.find(group => group.id === 'other')).cards.push(card);
|
||||
}
|
||||
const name = card => card.dataset.pluginName || 'Files';
|
||||
const formatStackSize = bytes => {
|
||||
if (!bytes || bytes <= 0) return '';
|
||||
const kb = 1024;
|
||||
const mb = kb * 1024;
|
||||
const gb = mb * 1024;
|
||||
if (bytes < mb) return `${Math.max(1, Math.ceil(bytes / kb))} KB`;
|
||||
if (bytes < gb) return `${Math.ceil(bytes / mb)} MB`;
|
||||
return `${(Math.ceil(bytes / gb * 10) / 10).toFixed(1)} GB`;
|
||||
};
|
||||
const groupSize = group => group.cards.reduce((sum, card) => {
|
||||
const cardSize = Number.parseInt(card.dataset.outputSize, 10) || 0;
|
||||
const looseSize = [...card.querySelectorAll('.loose-items a[data-output-size]')]
|
||||
.reduce((total, link) => total + (Number.parseInt(link.dataset.outputSize, 10) || 0), 0);
|
||||
return sum + cardSize + looseSize;
|
||||
}, 0);
|
||||
const updateGroupSize = group => {
|
||||
const size = buttons.get(group.id)?.querySelector('.stack-size');
|
||||
if (!size) return;
|
||||
size.textContent = formatStackSize(groupSize(group));
|
||||
size.hidden = !size.textContent;
|
||||
};
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
const panel = el('section', 'stack-browser');
|
||||
panel.setAttribute('aria-label', 'Snapshot output groups');
|
||||
const shelf = el('div', 'stack-shelf');
|
||||
shelf.style.setProperty('--stack-columns', groups.filter(group => group.id !== 'other' || group.cards.length).length);
|
||||
const tray = el('section', 'stack-tray');
|
||||
tray.id = 'stack-tray';
|
||||
tray.hidden = true;
|
||||
const close = el('button', 'stack-close', '⌃');
|
||||
close.title = 'Collapse stack';
|
||||
close.setAttribute('aria-label', 'Collapse stack');
|
||||
close.type = 'button';
|
||||
const trayCards = el('div', 'stack-tray-cards');
|
||||
tray.append(close, trayCards);
|
||||
const parked = el('div', 'stack-parked');
|
||||
parked.hidden = true;
|
||||
function movePreviewNode(node, parent, before = null) {
|
||||
if (parent.moveBefore && node.isConnected && parent.isConnected) parent.moveBefore(node, before);
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
let active = null;
|
||||
const buttons = new Map();
|
||||
function collapse() {
|
||||
if (!active) return;
|
||||
active.cards.forEach(card => movePreviewNode(card, parked));
|
||||
const button = buttons.get(active.id);
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
tray.hidden = true;
|
||||
active = null;
|
||||
}
|
||||
let discoveryState = 'idle';
|
||||
let pendingPreviewHash = '';
|
||||
async function discoverOtherFiles(group) {
|
||||
const other = group.cards.find(card => card.hasAttribute('data-discover-files'));
|
||||
if (!other || discoveryState === 'loading' || discoveryState === 'done') return;
|
||||
const status = other.querySelector('.discovery-status');
|
||||
discoveryState = 'loading';
|
||||
status.textContent = 'Scanning for orphan files…';
|
||||
status.removeAttribute('title');
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = '';
|
||||
url.search = '?discover_outputs=1';
|
||||
const response = await fetch(url, {cache: 'no-store'});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const fragment = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
if (!fragment.querySelector('[data-other-files]')) throw new Error('Invalid discovery response');
|
||||
const knownPaths = new Set(cards.map(card => card.dataset.outputPath).filter(Boolean));
|
||||
for (const card of fragment.querySelectorAll('.thumb-card[data-output-path]')) {
|
||||
if (knownPaths.has(card.dataset.outputPath)) continue;
|
||||
knownPaths.add(card.dataset.outputPath);
|
||||
card.classList.remove('selected-card');
|
||||
card.dataset.outputGroup = 'other';
|
||||
group.cards.push(card);
|
||||
cards.push(card);
|
||||
setPreviewResourcePriority(card, false);
|
||||
observer.observe(card, {attributes: true, attributeFilter: ['class']});
|
||||
movePreviewNode(card, active === group ? trayCards : parked);
|
||||
}
|
||||
const existingItems = other.querySelector('.loose-items');
|
||||
const knownLinks = new Set([...existingItems.querySelectorAll('a')].map(link => link.href));
|
||||
for (const link of fragment.querySelectorAll('[data-other-files] .loose-items a')) {
|
||||
if (!knownLinks.has(link.href)) {
|
||||
knownLinks.add(link.href);
|
||||
existingItems.append(link);
|
||||
}
|
||||
}
|
||||
buttons.get(group.id).querySelector('.stack-count').textContent = String(group.cards.length);
|
||||
buttons.get(group.id).setAttribute('aria-label', `${group.label}, ${group.cards.length} outputs, expand stack`);
|
||||
updateGroupSize(group);
|
||||
status.textContent = '';
|
||||
const coverItems = buttons.get(group.id).querySelector('.stack-cover .loose-items');
|
||||
if (coverItems) coverItems.replaceChildren(...[...existingItems.children].map(item => item.cloneNode(true)));
|
||||
discoveryState = 'done';
|
||||
// Resolve the current hash again; navigation may have changed during the scan.
|
||||
if (pendingPreviewHash && pendingPreviewHash === window.location.hash) selectInitialPreview(active === group);
|
||||
} catch (error) {
|
||||
discoveryState = 'error';
|
||||
status.textContent = 'Could not scan files. ';
|
||||
status.title = error.message;
|
||||
const retry = el('button', '', 'Retry');
|
||||
retry.type = 'button';
|
||||
retry.dataset.noPreview = '1';
|
||||
retry.addEventListener('click', () => discoverOtherFiles(group));
|
||||
status.append(retry);
|
||||
}
|
||||
}
|
||||
function expand(group) {
|
||||
if (active === group) { collapse(); return; }
|
||||
collapse();
|
||||
active = group;
|
||||
buttons.get(group.id).setAttribute('aria-expanded', 'true');
|
||||
trayCards.replaceChildren();
|
||||
if (!group.cards.length) {
|
||||
const empty = el('div', 'stack-empty', 'No captures');
|
||||
trayCards.append(empty);
|
||||
}
|
||||
for (const card of group.cards) {
|
||||
movePreviewNode(card, trayCards);
|
||||
}
|
||||
tray.setAttribute('aria-label', group.label);
|
||||
tray.hidden = false;
|
||||
if (group.id === 'other') discoverOtherFiles(group);
|
||||
}
|
||||
for (const group of groups) {
|
||||
if (group.id === 'other' && !group.cards.length) continue;
|
||||
const button = el('button', `output-stack output-stack-${group.id}`);
|
||||
button.type = 'button';
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.setAttribute('aria-controls', tray.id);
|
||||
button.setAttribute('aria-label', `${group.label}, ${group.cards.length} outputs, expand stack`);
|
||||
const heading = el('span', 'stack-heading');
|
||||
const headingLabel = el('span', 'stack-heading-label');
|
||||
headingLabel.append(el('strong', '', group.label));
|
||||
const size = el('span', 'stack-size', formatStackSize(groupSize(group)));
|
||||
size.hidden = !size.textContent;
|
||||
headingLabel.append(size);
|
||||
const headingRight = el('span', 'stack-heading-right');
|
||||
const chevron = el('span', 'stack-chevron');
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
headingRight.append(el('span', 'stack-count', String(group.cards.length)), chevron);
|
||||
heading.append(headingLabel, headingRight);
|
||||
const pile = el('span', 'stack-pile');
|
||||
pile.setAttribute('aria-hidden', 'true');
|
||||
const top = group.cards[0];
|
||||
const availableLayers = Math.min(3, group.cards.length);
|
||||
for (let index = availableLayers - 1; index >= 0; index--) {
|
||||
const layer = el('span', `stack-sheet stack-sheet-${index}`);
|
||||
if (index === 0) {
|
||||
const cover = el('div', 'stack-cover');
|
||||
const card = top.cloneNode(true);
|
||||
card.classList.remove('selected-card');
|
||||
card.inert = true;
|
||||
for (const key of Object.keys(card.dataset)) delete card.dataset[key];
|
||||
card.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
|
||||
// Covers stay visible, but native lazy loading defers offscreen
|
||||
// iframe work. The expanded tray has its own copy of this card.
|
||||
setPreviewResourcePriority(card, false);
|
||||
cover.append(card);
|
||||
layer.append(cover);
|
||||
} else {
|
||||
layer.append(el('span', 'stack-sheet-label', name(group.cards[index])));
|
||||
}
|
||||
pile.append(layer);
|
||||
}
|
||||
if (!top) pile.append(el('span', 'stack-no-output', 'No captures'));
|
||||
button.append(heading, pile);
|
||||
button.addEventListener('click', () => {
|
||||
const opening = active !== group;
|
||||
expand(group);
|
||||
const first = group.cards[0];
|
||||
if (opening && first) {
|
||||
activateCardPreview(first, first.querySelector('a[target=preview]') || first.querySelector('a'));
|
||||
}
|
||||
});
|
||||
buttons.set(group.id, button);
|
||||
shelf.append(button);
|
||||
|
||||
}
|
||||
|
||||
function syncSelection() {
|
||||
const selected = cards.find(card => card.classList.contains('selected-card'));
|
||||
for (const group of groups) buttons.get(group.id)?.classList.toggle('has-selection', group.cards.includes(selected));
|
||||
}
|
||||
document.addEventListener('snapshot-preview-selected', event => {
|
||||
const {card, discover, revealStack} = event.detail;
|
||||
pendingPreviewHash = discover ? window.location.hash : '';
|
||||
if (!revealStack) return;
|
||||
const group = groups.find(group => group.cards.includes(card)) || (discover && groups.find(group => group.id === 'other'));
|
||||
if (!group || !buttons.has(group.id)) return;
|
||||
if (active !== group) expand(group);
|
||||
else if (discover) discoverOtherFiles(group);
|
||||
});
|
||||
close.addEventListener('click', () => {
|
||||
const button = buttons.get(active?.id);
|
||||
collapse();
|
||||
button?.focus();
|
||||
});
|
||||
panel.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && active) { close.click(); event.preventDefault(); }
|
||||
});
|
||||
// Existing card event handlers continue to drive the real snapshot viewer and URL hash.
|
||||
const observer = new MutationObserver(syncSelection);
|
||||
cards.forEach(card => observer.observe(card, {attributes: true, attributeFilter: ['class']}));
|
||||
panel.append(shelf, tray, parked);
|
||||
grid.before(panel);
|
||||
cards.forEach(card => movePreviewNode(card, parked));
|
||||
grid.hidden = true;
|
||||
document.body.classList.add('snapshot-stacks');
|
||||
syncSelection();
|
||||
const otherGroup = groups.find(group => group.id === 'other');
|
||||
if (otherGroup && buttons.has('other')) discoverOtherFiles(otherGroup);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% load core_tags %}
|
||||
<span class="snapshot-thumbnail" role="img" aria-label="Preview of {{ host }}" style="display:inline-flex;position:relative;vertical-align:middle;align-items:center;justify-content:center;overflow:hidden;border-radius:4px;box-shadow:4px 4px 4px rgba(0,0,0,0.02);width:{{ width }};height:{{ height }};background:hsl({{ hue }} 35% 92%);color:hsl({{ hue }} 35% 30%);">
|
||||
<span style="display:flex;flex-direction:column;align-items:center;gap:4px;max-width:90%;">
|
||||
<span aria-hidden="true" style="font:bold 28px sans-serif;">{{ initial }}</span>
|
||||
<span style="font:10px sans-serif;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ host }}</span>
|
||||
<span aria-hidden="true" style="font-size:12px;">▤</span>
|
||||
</span>
|
||||
{% if card_result %}
|
||||
<span aria-hidden="true" style="position:absolute;inset:0;display:block;pointer-events:none;background:#f8fafc;">
|
||||
{% plugin_card card_result %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -19,7 +19,7 @@
|
||||
.table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
|
||||
table { width: 100%; min-width: 980px; table-layout: fixed; border-collapse: collapse; }
|
||||
th { padding: 9px 10px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; color: #475569; font-size: 11px; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
|
||||
td { padding: 10px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.snapshot-time { width: 102px; white-space: nowrap; }
|
||||
@@ -30,11 +30,15 @@
|
||||
.snapshot-preview { display: block; width: 100px; height: 100px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; object-fit: cover; object-position: top; }
|
||||
.snapshot-preview-spinner { display: grid; place-items: center; }
|
||||
.snapshot-preview-spinner::after { width: 20px; height: 20px; border: 2px solid #cbd5e1; border-top-color: #aa1e55; border-radius: 50%; content: ""; animation: spin .8s linear infinite; }
|
||||
.snapshot-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 2px; }
|
||||
.snapshot-favicon-link { display: inline-flex; width: 22px; min-width: 22px; height: 22px; align-items: center; justify-content: center; }
|
||||
.link-favicon { display: block; width: 18px; height: 18px; border-radius: 4px; object-fit: contain; }
|
||||
.snapshot-title { overflow: hidden; color: #0f172a; font-size: 14px; font-weight: 650; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url { display: block; margin-left: 30px; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 1px; }
|
||||
.snapshot-url .link-favicon { display: block; width: 1em; height: 1em; flex: 0 0 1em; border-radius: 50%; object-fit: contain; opacity: 0.75; font-size: inherit; }
|
||||
.snapshot-url .link-favicon-empty { display: none; }
|
||||
.snapshot-url-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.snapshot-title { overflow: hidden; color: #0f172a; font-size: 15px; font-weight: 600; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url { display: flex; align-items: center; gap: 5px; margin-left: 0; overflow: hidden; color: #84909f; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 11.5px; font-weight: 400; line-height: 1.25; user-select: all; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url .snapshot-url-domain { color: #64748b; font-weight: 400; }
|
||||
.snapshot-url :is(.snapshot-url-prefix, .snapshot-url-suffix) { color: #b0b7c0; font-weight: 400; }
|
||||
.snapshot-url:hover, .snapshot-title:hover { text-decoration: underline; }
|
||||
.snapshot-mobile-saved { display: none; }
|
||||
.snapshot-tags-cell { width: 180px; }
|
||||
.snapshot-tags { display: flex; flex-wrap: wrap; gap: 4px; max-height: 46px; overflow: hidden; }
|
||||
@@ -45,8 +49,46 @@
|
||||
.status-started .snapshot-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-queued .snapshot-status, .status-backoff .snapshot-status { background: #fef3c7; color: #b45309; }
|
||||
.status-failed .snapshot-status { background: #fee2e2; color: #b91c1c; }
|
||||
.snapshot-files-cell { width: 220px; overflow: hidden; }
|
||||
.snapshot-files-cell .files-icons { max-width: 200px; }
|
||||
.snapshot-files-cell { width: 132px; overflow: visible; }
|
||||
.snapshot-files-cell:has(.files-icon-pile:hover), .snapshot-files-cell:has(.files-icon-pile:focus-within) { position: relative; z-index: 10000; }
|
||||
.snapshot-files-cell .files-icons { display: inline-grid; grid-template-columns: repeat(5, 22px); grid-template-rows: 18px; grid-auto-flow: column; grid-auto-columns: 22px; gap: 0; max-width: 100%; vertical-align: middle; }
|
||||
.snapshot-files-cell .files-icon-pile { position: relative; display: inline-block; width: 16px; height: 18px; isolation: isolate; vertical-align: top; }
|
||||
.snapshot-files-cell .files-icon-pile::before, .snapshot-files-cell .files-icon-pile::after { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border: 1px solid #d9cdd3; border-radius: 3px; background: #f7f2f4; content: ""; pointer-events: none; }
|
||||
.snapshot-files-cell .files-icon-pile::before { transform: translate(2px,-2px) rotate(7deg); z-index: -2; }
|
||||
.snapshot-files-cell .files-icon-pile::after { transform: translate(1px,-1px) rotate(3deg); z-index: -1; background: #ece5e8; }
|
||||
.snapshot-files-cell .files-icon-pile-backplate { position: absolute; top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 0; display: none; width: var(--files-icon-card-width,116px); height: calc(var(--files-icon-rows,1) * 18px + 10px); border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile-popup { position: absolute; top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 0; box-sizing: border-box; display: block; width: var(--files-icon-card-width,116px); min-height: calc(var(--files-icon-rows,1) * 18px + 10px); margin: 0; padding: 8px 6px 4px; overflow: visible; border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; opacity: 1; }
|
||||
.snapshot-files-cell .files-icon-pile-popup > a, .snapshot-files-cell .files-icon-pile-popup > span { position: absolute; top: var(--files-icon-member-top,8px); left: var(--files-icon-member-left,6px); }
|
||||
.snapshot-files-cell .files-icon-pile-popup::before { position: absolute; top: -5px; left: 0; width: 100%; height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); content: ""; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-backplate, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-backplate { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-hover-bridge { position: absolute; top: 17px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 1; display: none; width: var(--files-icon-card-width,116px); height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,calc(var(--files-icon-bridge-start,0px) + 16px) 0,100% 100%,0 100%); pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-hover-bridge, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-hover-bridge { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > a:not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile > span:not(.files-icon-pile-popup) { position: absolute; top: 1px; left: min(calc(var(--files-icon-index,0) * 1px),2px); z-index: var(--files-icon-index,0); }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-top { left: calc(var(--files-icon-front-index,0) * 2px); z-index: calc(100 + var(--files-icon-front-index,0)); }
|
||||
.snapshot-files-cell .files-icon-pile > a:hover, .snapshot-files-cell .files-icon-pile > a:focus-visible { z-index: 10000 !important; }
|
||||
.snapshot-files-cell .files-icon-pile:hover, .snapshot-files-cell .files-icon-pile:focus-within { z-index: 9999; overflow: visible; }
|
||||
.snapshot-files-cell .files-icon-pile > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { visibility: hidden; transition: visibility 0s linear 150ms; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-cover { z-index: 200; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]::after { position: absolute; bottom: calc(100% + 7px); left: 0; z-index: 10001; padding: 5px 8px; border: 1px solid #111827; border-radius: 6px; background: #1f2937; box-shadow: 0 4px 12px #0f172a33; color: #fff; content: attr(data-tooltip); font-size: 11px; font-weight: 600; line-height: 1.2; opacity: 0; pointer-events: none; transition: opacity .08s ease,visibility 0s linear .08s; visibility: hidden; white-space: nowrap; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]:hover::after, .snapshot-files-cell .files-icon-pile a[data-tooltip]:focus-visible::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]:hover::after { opacity: 1; transition-delay: .08s,0s; visibility: visible; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile:focus-within > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { top: calc(24px + var(--files-icon-row,0) * 18px); left: calc(3px + var(--files-icon-row-offset,3px) + var(--files-icon-column,0) * 22px - var(--files-icon-pile-column,0) * 22px); visibility: visible; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons:has(.files-icon-pile:hover) .files-icon-pile:not(:hover), .snapshot-files-cell .files-icons:has(.files-icon-pile:focus-within) .files-icon-pile:not(:focus-within) { opacity: .2; }
|
||||
.snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:hover::before, .snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:focus-visible::before { position: absolute; top: 17px; left: 50%; z-index: 210; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); border-top: 1px solid #d9cdd3; border-left: 1px solid #d9cdd3; background: #fff; content: ""; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-popup { top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s ease,visibility 0s linear 350ms; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile > .files-icon-pile-popup:popover-open { top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); visibility: visible; opacity: 1; pointer-events: auto; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons a { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; }
|
||||
.snapshot-files-cell .abx-output-icon { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e2e8f0; border-radius: 3px; background: #f8fafc; color: #475569; font-size: 7px; font-weight: 700; pointer-events: none; }
|
||||
.snapshot-files-cell .abx-output-icon svg { display: block; width: 16px; height: 16px; }
|
||||
.snapshot-files-cell .abx-output-icon--singlefile { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
|
||||
.snapshot-files-cell .abx-output-icon--archivewebpage { color: #7c3aed; background: #f5f3ff; border-color: #ddd6fe; }
|
||||
.snapshot-files-cell .abx-output-icon--ytdlp { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
||||
.snapshot-files-cell .abx-output-icon--readability { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
||||
.snapshot-files-cell .abx-output-icon--forumdl { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||
.snapshot-files-cell .abx-output-icon--papersdl { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||
.snapshot-files-cell .abx-output-icon--git { color: #c2410c; background: #fff7ed; border-color: #fed7aa; }
|
||||
.snapshot-files-cell .abx-output-icon--gallerydl { color: #be185d; background: #fdf2f8; border-color: #fbcfe8; }
|
||||
.snapshot-files-cell .abx-output-icon--screenshot { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.snapshot-files-cell .files-icon-plugin--screenshot { color: #15803d; }
|
||||
.snapshot-size-cell { width: 102px; white-space: nowrap; }
|
||||
.snapshot-size-cell a { display: block; color: #334155; font-size: 12px; font-weight: 700; text-decoration: none; }
|
||||
.snapshot-size-cell small, .empty-value { color: #94a3b8; font-size: 10px; }
|
||||
|
||||
@@ -33,17 +33,13 @@
|
||||
— the host is close to OOM. Consider stopping crawls or scaling up.
|
||||
{% elif mode == "high_load" %}
|
||||
15-min loadavg <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_15 }}</code>
|
||||
exceeds <code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ load_threshold }}</code>
|
||||
({{ cpu_count }} cores × 3) — recent CPU or I/O contention may slow crawls. This average can stay elevated after load subsides; it does not measure queue growth.
|
||||
{% elif mode == "unsafe" %}
|
||||
ArchiveBox single-domain mode — archived pages share an origin with this site
|
||||
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Modes" target="_blank" rel="noopener noreferrer" style="color:#fff;text-decoration:underline;margin-left:6px;">why?</a>
|
||||
{% elif mode == "base_url_mismatch" %}
|
||||
Browser URL:
|
||||
<code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ browser_url }}</code>
|
||||
· Configured BASE_URL:
|
||||
<code style="background:#000;color:#fff;padding:1px 6px;border-radius:3px;font:700 11px/1.3 ui-monospace,Menlo,Consolas,monospace;text-transform:none;">{{ configured_base_url }}</code>
|
||||
— GET access is allowed here, but state-changing requests are accepted only on BASE_URL.
|
||||
Editing disabled from unrecognized URL. Visit
|
||||
<a href="{{ configured_base_url }}" style="color:#fff;text-decoration:underline;text-transform:none;">{{ configured_base_url }}</a>
|
||||
to make changes.
|
||||
{% else %}
|
||||
Ask an ArchiveBox superuser to finish server setup and pin the canonical URL.
|
||||
{% endif %}
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
if (!deleteUrl) return window.alert('Delete failed: missing admin request data')
|
||||
deleteOutputButtons.forEach((button) => button.disabled = true)
|
||||
if (button.dataset.deleteHandoff) {
|
||||
// web.* and snap-* only get the one-bit login hint, never the
|
||||
// admin session or CSRF cookie. Navigate to admin.* so its
|
||||
// host-only cookies authorize the standard confirmation page;
|
||||
// the queued GET cannot delete anything by itself.
|
||||
deleteUrl = new URL(deleteUrl, window.location.href)
|
||||
deleteUrl.searchParams.set('action', 'delete_selected')
|
||||
deleteUrl.searchParams.set('snapshot', button.dataset.deleteSnapshotId)
|
||||
|
||||
@@ -348,6 +348,35 @@ select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.url-file-dragover {
|
||||
outline: 2px dashed #417690;
|
||||
outline-offset: 6px;
|
||||
}
|
||||
|
||||
.url-upload-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.url-upload-controls button {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f8f8f8;
|
||||
color: #333;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#url-upload-status.url-upload-warning {
|
||||
color: #ba2121;
|
||||
}
|
||||
|
||||
.url-editor-shell {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1020,3 +1049,17 @@ input:focus, select:focus, textarea:focus, button:focus {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
html.archivebox-native-app #container {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html.archivebox-native-app #content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.archivebox-native-app .add-page .crawl-explanation:has(.crawl-tip) {
|
||||
display: none !important;
|
||||
}
|
||||
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user