Compare commits

...
970 Commits
Author SHA1 Message Date
ArchiveBox Release Bot 6f83a86bcc Bump release version to 0.9.64 2026-09-26 18:02:26 +00:00
Nick Sweeting 17d1caaff4 Promote search command environment and listener verification fixes 2026-09-26 11:01:57 -07:00
ArchiveBox Release Bot fa146980ed Bump release version to 0.9.56rc41 2026-09-26 18:01:20 +00:00
Nick Sweeting 2f9f56fc63 Preserve runtime paths for search commands and portable socket checks 2026-09-26 11:00:26 -07:00
ArchiveBox Release Bot 35813466d2 Bump release version to 0.9.56rc40 2026-09-26 17:52:20 +00:00
ArchiveBox Release Bot 205f632f84 Bump release version to 0.9.63 2026-09-26 17:52:16 +00:00
Nick Sweeting 676c76d521 Merge stable release history with test cleanup fix 2026-09-26 10:51:43 -07:00
Nick Sweeting 43d2aac510 Retain process identity before signalling test cleanup 2026-09-26 10:51:27 -07:00
ArchiveBox Release Bot 4363ae2e3f Bump release version to 0.9.62 2026-09-26 17:22:12 +00:00
ArchiveBox Release Bot dcca7f9146 Bump release version to 0.9.56rc39 2026-09-26 17:22:11 +00:00
Nick Sweeting acf005a292 Promote machine identity and CI runner fixes to main 2026-09-26 10:21:38 -07:00
ArchiveBox Release Bot 02a45660f6 Bump release version to 0.9.56rc38 2026-09-26 17:20:30 +00:00
Nick Sweeting 5826e4df87 Fall back to hosted CI when NAS runners are unavailable 2026-09-26 10:19:04 -07:00
Nick Sweeting 8cf19c829e Reuse stable machine identity when OS machine ID is unavailable 2026-09-26 10:19:04 -07:00
ArchiveBox Release Bot 2b327ab2ff Bump release version to 0.9.56rc37 2026-09-26 17:15:53 +00:00
Nick Sweeting cdf7ac9dee Run trusted CI discovery and status checks on spare NAS capacity 2026-09-26 10:09:40 -07:00
ArchiveBox Release Bot 9298b8908c Bump release version to 0.9.56rc36 2026-09-26 17:08:55 +00:00
Nick Sweeting ab5f3ba097 Allow single-file runner acceptance checks through Actions 2026-09-26 10:07:40 -07:00
Nick Sweeting 9f94967660 Use ugNAS for opted-in trusted CI test shards 2026-09-26 10:04:54 -07:00
ArchiveBox Release Bot 2e0157a466 Bump release version to 0.9.61 2026-09-26 16:58:09 +00:00
ArchiveBox Release Bot 5c459b7ebd Bump release version to 0.9.56rc35 2026-09-26 16:57:45 +00:00
Nick Sweeting e236549917 Merge remote-tracking branch 'origin/dev' into dev 2026-09-26 09:57:02 -07:00
Nick Sweeting 9b45afd288 Promote stable Docker publication guard to main 2026-09-26 09:56:57 -07:00
ArchiveBox Release Bot aaf31d3634 Bump release version to 0.9.56rc34 2026-09-26 16:56:53 +00:00
Nick Sweeting 8ccae998af Guard latest Docker tags with stable main release provenance 2026-09-26 09:56:32 -07:00
ArchiveBox Release Bot 7de9d1c83c Bump release version to 0.9.56rc33 2026-09-26 16:56:10 +00:00
ArchiveBox Release Bot 6b3e87f19a Bump release version to 0.9.60 2026-09-26 16:56:04 +00:00
Nick Sweeting 7816dc357b Promote current fixes from dev to main 2026-09-26 09:55:36 -07:00
Nick Sweeting b83ea21a04 Merge remote-tracking branch 'origin/dev' into dev 2026-09-26 09:55:36 -07:00
Nick Sweeting b5246ccb49 Handle deleted release notes in release generator 2026-09-26 09:55:20 -07:00
ArchiveBox Release Bot 64255dab8b Bump release version to 0.9.56rc32 2026-09-26 16:51:35 +00:00
Nick Sweeting 26fa964dd7 Fix ArchiveResult output summary and view link 2026-09-26 09:49:56 -07:00
Nick Sweeting f67f5bb254 Merge remote-tracking branch 'origin/dev' into dev 2026-09-26 09:48:22 -07:00
Nick Sweeting dbdd782d22 Remove release notes draft 2026-09-26 09:47:27 -07:00
ArchiveBox Release Bot e3f8ade165 Bump release version to 0.9.59 2026-09-26 16:43:08 +00:00
ArchiveBox Release Bot e31c51df16 Bump release version to 0.9.56rc31 2026-09-26 16:43:01 +00:00
Nick Sweeting 9fe980b540 Promote updated release notes from dev to main 2026-09-26 09:42:40 -07:00
Nick Sweeting 32fc0a894c Document migration and saved-output fixes in release notes 2026-09-26 09:42:40 -07:00
ArchiveBox Release Bot 326b134bc1 Bump release version to 0.9.56rc30 2026-09-26 16:37:39 +00:00
ArchiveBox Release Bot 03a124fbd1 Bump release version to 0.9.58 2026-09-26 16:37:30 +00:00
Nick Sweeting 52256c6f42 Merge current dev into main for stable release 2026-09-26 09:37:03 -07:00
ArchiveBox Release Bot 0ec0634843 Bump release version to 0.9.56rc29 2026-09-26 16:22:22 +00:00
ArchiveBox Release Bot f9919c1777 Bump for abx-dl 1.13.49 2026-09-26 16:22:04 +00:00
ArchiveBox Release Bot 85c5c7695a Bump release version to 0.9.56rc28 2026-09-26 10:20:32 +00:00
ArchiveBox Release Bot ff0b1276a5 Bump for abx-dl 1.13.48 2026-09-26 10:20:14 +00:00
ArchiveBox Release Bot 36c485bcf4 Bump release version to 0.9.56rc27 2026-09-26 07:48:18 +00:00
Nick Sweeting f3f192f34e Wait for visible plugin card images before gallery captures 2026-09-26 00:48:03 -07:00
ArchiveBox Release Bot 15b112b6fa Bump release version to 0.9.56rc26 2026-09-26 06:45:02 +00:00
ArchiveBox Release Bot 5631c7c296 Bump for abx-dl 1.13.47 2026-09-26 06:44:42 +00:00
ArchiveBox Release Bot 51244c54ad Bump release version to 0.9.56rc25 2026-09-26 06:00:13 +00:00
Nick Sweeting 0b9dd005e3 fix: preserve deferred fields for delete webhooks 2026-09-25 22:59:38 -07:00
ArchiveBox Release Bot 807356a4fd Bump release version to 0.9.56rc24 2026-09-26 05:53:25 +00:00
Nick Sweeting 925c197396 fix: preserve index-only snapshot export previews 2026-09-25 22:53:05 -07:00
ArchiveBox Release Bot d96cab7f99 Bump release version to 0.9.56rc23 2026-09-26 05:10:24 +00:00
Nick Sweeting 1c2187a696 fix: show site names for untitled snapshot grid cards 2026-09-25 22:10:04 -07:00
Nick Sweeting 5582961895 fix: keep static archive cards when hash manifests are incomplete 2026-09-25 22:10:03 -07:00
ArchiveBox Release Bot a6b67c0b68 Bump release version to 0.9.56rc22 2026-09-26 04:28:50 +00:00
Nick Sweeting 6364ee6583 test: verify retention worker pins the installed executable 2026-09-25 21:28:33 -07:00
ArchiveBox Release Bot 89aa93a50d Bump release version to 0.9.56rc21 2026-09-26 04:09:31 +00:00
Nick Sweeting 3f2c6141a2 Install Chromium for the real extract preview regression 2026-09-25 21:09:13 -07:00
ArchiveBox Release Bot f2657b2e03 Bump release version to 0.9.56rc20 2026-09-26 04:02:26 +00:00
Nick Sweeting 3cd6639fd9 Preserve encoded output paths when selecting snapshot previews 2026-09-25 21:02:06 -07:00
ArchiveBox Release Bot f835fab44e Bump release version to 0.9.56rc19 2026-09-26 03:47:22 +00:00
ArchiveBox Release Bot ef426fc80f Bump for abx-dl 1.13.45 2026-09-26 03:46:47 +00:00
ArchiveBox Release Bot bf8f2358a8 Bump release version to 0.9.56rc18 2026-09-26 03:41:34 +00:00
Nick Sweeting 3666efd1b3 Keep Daphne in the calling ArchiveBox runtime 2026-09-25 20:40:57 -07:00
ArchiveBox Release Bot 4f66494626 Bump release version to 0.9.56rc17 2026-09-26 03:19:18 +00:00
Nick Sweeting 0dc192e9bc Quote literal percent signs in snapshot output URLs 2026-09-25 20:18:41 -07:00
ArchiveBox Release Bot 45288b8269 Bump release version to 0.9.56rc16 2026-09-26 03:03:46 +00:00
ArchiveBox Release Bot ef978e96dd Bump for abx-dl 1.13.44 2026-09-26 03:03:19 +00:00
ArchiveBox Release Bot 4fd10488b8 Bump release version to 0.9.56rc15 2026-09-26 03:02:20 +00:00
Nick Sweeting ad9404c6c6 Keep ArchiveBox worker hooks in their selected runtime 2026-09-25 20:01:49 -07:00
ArchiveBox Release Bot 5de1d8c329 Bump release version to 0.9.56rc14 2026-09-26 02:32:52 +00:00
ArchiveBox Release Bot aed7badfa3 Bump for abx-dl 1.13.43 2026-09-26 02:32:32 +00:00
ArchiveBox Release Bot 9846670179 Bump release version to 0.9.56rc13 2026-09-26 00:48:51 +00:00
ArchiveBox Release Bot aab13f4bc7 Bump for abx-dl 1.13.39 2026-09-26 00:48:31 +00:00
ArchiveBox Release Bot 0f3f4eaa44 Bump release version to 0.9.56rc12 2026-09-25 23:54:50 +00:00
Nick Sweeting 768aaaa6e3 Fix migrated snapshot rendering with non-UTC capture dates 2026-09-25 16:54:35 -07:00
ArchiveBox Release Bot e2c6ffa114 Bump release version to 0.9.56rc11 2026-09-25 21:46:34 +00:00
ArchiveBox Release Bot d8d8f0416c Bump for abx-dl 1.13.38 2026-09-25 21:46:18 +00:00
ArchiveBox Release Bot 7de25e92ba Bump release version to 0.9.56rc10 2026-09-25 20:22:37 +00:00
Nick Sweeting 5256c9eee7 Test deferred Agent startup through iframe proxy 2026-09-25 13:21:48 -07:00
ArchiveBox Release Bot efef024d05 Bump release version to 0.9.56rc9 2026-09-25 19:59:01 +00:00
ArchiveBox Release Bot 68ba348d61 Bump for abx-dl 1.13.37 2026-09-25 12:57:49 -07:00
ArchiveBox Release Bot d04474b6c8 Bump release version to 0.9.57 2026-09-25 19:56:34 +00:00
ArchiveBox Release Bot 716f66a0c0 Bump for abx-dl 1.13.37 2026-09-25 19:56:13 +00:00
ArchiveBox Release Bot 0be949796c Bump release version to 0.9.56rc8 2026-09-25 19:51:29 +00:00
Nick Sweeting e69c719869 Scope native progress layout to activity pages 2026-09-25 12:50:37 -07:00
ArchiveBox Release Bot 9bfee57a40 Bump release version to 0.9.56rc7 2026-09-25 18:01:39 +00:00
Nick Sweeting 6050d80f1d Merge generated abx-dl 1.13.36 dependency pin into dev 2026-09-25 11:01:09 -07:00
Nick Sweeting a88a700052 Capture replay gallery previews in browser directly 2026-09-25 11:00:48 -07:00
ArchiveBox Release Bot 2dd935bad4 Bump release version to 0.9.56 2026-09-25 18:00:25 +00:00
ArchiveBox Release Bot bf24f513d3 Bump for abx-dl 1.13.36 2026-09-25 17:59:49 +00:00
ArchiveBox Release Bot 9cff842298 Bump release version to 0.9.56rc6 2026-09-25 17:44:04 +00:00
Nick Sweeting 8793a1505b Keep complete gallery record config after adding snapshot paths 2026-09-25 10:42:48 -07:00
ArchiveBox Release Bot 4489a67852 Bump release version to 0.9.56rc5 2026-09-25 17:24:10 +00:00
Nick Sweeting 1151d61cd4 Let plugin full templates own replay rendering 2026-09-25 10:22:46 -07:00
ArchiveBox Release Bot b61c0d5f62 Bump release version to 0.9.56rc4 2026-09-25 17:19:13 +00:00
Nick Sweeting b3fc391ab7 Use files URL builder in screenshot gallery 2026-09-25 10:17:34 -07:00
ArchiveBox Release Bot ce8f295690 Bump release version to 0.9.56rc3 2026-09-25 17:16:33 +00:00
Nick Sweeting 367666b338 Use canonical snapshot URLs in screenshot gallery 2026-09-25 10:16:13 -07:00
ArchiveBox Release Bot f48e67b2e6 Bump release version to 0.9.56rc2 2026-09-25 16:58:11 +00:00
Nick Sweeting 6a59490370 Avoid waiting on snapshot card iframes in admin captures 2026-09-25 09:57:53 -07:00
ArchiveBox Release Bot a1684b0267 Bump release version to 0.9.56rc1 2026-09-25 09:32:51 +00:00
Nick Sweeting 5feab49350 Merge generated package cascade into dev 2026-09-25 02:30:53 -07:00
ArchiveBox Release Bot 2963fa9939 Bump release version to 0.9.55 2026-09-25 09:29:12 +00:00
ArchiveBox Release Bot cb50f9d848 Bump for abx-dl 1.13.35 2026-09-25 09:28:58 +00:00
ArchiveBox Release Bot 98458630c3 Bump release version to 0.9.55rc7 2026-09-25 09:09:21 +00:00
Nick Sweeting 8473aea817 Merge remote-tracking branch 'origin/dev' into dev 2026-09-25 02:09:03 -07:00
Nick Sweeting 6b9829a62a Reopen Agent page to verify persisted browser state 2026-09-25 02:09:01 -07:00
ArchiveBox Release Bot dcda70e697 Bump release version to 0.9.55rc6 2026-09-25 08:36:16 +00:00
Nick Sweeting cc92820629 Merge remote-tracking branch 'origin/dev' into dev 2026-09-25 01:35:53 -07:00
Nick Sweeting 12aae53385 Log browser and server state when WACZ preview probe fails 2026-09-25 01:35:34 -07:00
ArchiveBox Release Bot e5cfa17b3f Bump release version to 0.9.55rc5 2026-09-25 08:13:52 +00:00
Nick Sweeting 1d39291f48 Assert canonical web redirects in Save Page Now tests 2026-09-25 01:13:16 -07:00
ArchiveBox Release Bot 9d84018687 Bump release version to 0.9.55rc4 2026-09-25 08:12:31 +00:00
Nick Sweeting 602b966f83 Merge remote-tracking branch 'origin/dev' into dev 2026-09-25 01:12:15 -07:00
Nick Sweeting e8a3202bb2 Use real request transactions in favicon cache integration test 2026-09-25 01:11:58 -07:00
ArchiveBox Release Bot 2081895606 Bump release version to 0.9.55rc3 2026-09-25 07:39:19 +00:00
Nick Sweeting da539992f7 Simplify plugin replay dispatch 2026-09-25 00:38:22 -07:00
ArchiveBox Release Bot 6927dc86e3 Bump release version to 0.9.55rc2 2026-09-25 07:31:59 +00:00
Nick Sweeting b0a329dfed Generalize snapshot presentation and URL paths 2026-09-25 00:29:55 -07:00
ArchiveBox Release Bot 0d10852fb8 Bump release version to 0.9.55rc1 2026-09-25 07:24:35 +00:00
Nick Sweeting dcac00d1a9 Refresh authored documentation codeblock inventory 2026-09-25 00:24:11 -07:00
Nick Sweeting 25e49637e3 Merge remote-tracking branch 'origin/main' into dev 2026-09-25 00:20:46 -07:00
ArchiveBox Release Bot c6faa4cc2f Bump release version to 0.9.54 2026-09-25 07:19:02 +00:00
ArchiveBox Release Bot b67d71707d Bump for abx-dl 1.13.34 2026-09-25 07:18:47 +00:00
Nick Sweeting 03a7cdc4f3 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	etc/package.json
#	pyproject.toml
#	uv.lock
2026-09-25 00:02:22 -07:00
Nick Sweeting 544bc01506 Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	etc/package.json
#	pyproject.toml
#	uv.lock
2026-09-25 00:01:34 -07:00
ArchiveBox Release Bot b6c67ed1bd Bump release version to 0.9.52rc7 2026-09-25 07:00:16 +00:00
Nick Sweeting 184ba064cd Verify obsolete Docker listener is closed 2026-09-24 23:56:46 -07:00
Nick Sweeting ece444796c Assert exact snapshot path fallback in resolver test 2026-09-24 23:52:35 -07:00
Nick Sweeting 684bbdaffa Serve exact legacy snapshot paths without duplicate preflight 2026-09-24 23:52:17 -07:00
Nick Sweeting 3a912fee44 Restore snapshot cards and routing across security modes 2026-09-24 23:48:36 -07:00
ArchiveBox Release Bot ad141780c2 Bump release version to 0.9.53 2026-09-25 06:14:34 +00:00
ArchiveBox Release Bot 45ee3f291a Bump for abx-dl 1.13.33 2026-09-25 06:14:17 +00:00
Nick Sweeting 2f8d991bbd Include crawl reliability improvements in release notes 2026-09-24 07:57:56 -07:00
Nick Sweeting c814fba6d5 Use reviewed user-facing notes for the next stable release 2026-09-24 07:57:13 -07:00
Nick Sweeting a50356862c Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	etc/package.json
#	pyproject.toml
#	uv.lock
2026-09-24 07:53:24 -07:00
Nick Sweeting 43c5c2ce5b Merge remote-tracking branch 'origin/dev' into dev 2026-09-24 07:52:58 -07:00
Nick Sweeting e6164ca52a Improve add-page layout and responsive output cards 2026-09-24 07:52:57 -07:00
ArchiveBox Release Bot 637c3ec039 Bump release version to 0.9.52 2026-09-24 04:05:44 +00:00
ArchiveBox Release Bot f455d75e60 Bump for abx-dl 1.13.30 2026-09-24 04:05:28 +00:00
ArchiveBox Release Bot 5a0fb68cf1 Bump release version to 0.9.52rc6 2026-09-24 03:38:34 +00:00
Nick Sweeting 4c0dc81030 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 20:37:43 -07:00
Nick Sweeting f7ab65e7a7 Avoid duplicate dependency bump entries in release notes 2026-09-23 20:37:42 -07:00
ArchiveBox Release Bot 0eb82d3e03 Bump release version to 0.9.52rc5 2026-09-24 03:37:09 +00:00
Nick Sweeting 80def3957c Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 20:35:49 -07:00
Nick Sweeting 6346392f49 Generate patch release notes from the previous stable release 2026-09-23 20:35:47 -07:00
ArchiveBox Release Bot 04ccd9420d Bump release version to 0.9.52rc4 2026-09-24 03:33:35 +00:00
Nick Sweeting d0cf7507a7 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 20:33:15 -07:00
Nick Sweeting 27965e2762 Refine snapshot navigation and favicon cache behavior 2026-09-23 20:32:25 -07:00
ArchiveBox Release Bot 1082e21535 Bump release version to 0.9.52rc3 2026-09-24 03:00:30 +00:00
Nick Sweeting aabae0f56b Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 20:00:10 -07:00
Nick Sweeting df9e47f987 Refine snapshot navigation and serve SVG favicons correctly 2026-09-23 20:00:00 -07:00
ArchiveBox Release Bot d29cfa7291 Bump release version to 0.9.52rc2 2026-09-24 02:18:57 +00:00
Nick Sweeting 5c298b8ec0 Finish snapshot listing template refinements 2026-09-23 19:17:52 -07:00
Nick Sweeting ca207173a1 Clarify responsive navbar regression assertion 2026-09-23 19:17:22 -07:00
Nick Sweeting e0f0548dc8 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 19:17:01 -07:00
Nick Sweeting 3ab9ae5d43 Polish icon pile controls across snapshot listings 2026-09-23 19:16:45 -07:00
ArchiveBox Release Bot 1519aa4b0a Bump release version to 0.9.52rc1 2026-09-24 02:16:26 +00:00
Nick Sweeting 41f8788600 Refine snapshot grid controls and output card presentation 2026-09-23 19:15:47 -07:00
Nick Sweeting eb7c08eff2 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-23 19:15:26 -07:00
Nick Sweeting 0b2367106c Improve snapshot cards, capture navigation, and runner lifecycle 2026-09-23 19:15:25 -07:00
ArchiveBox Release Bot b5646cc6e8 Bump release version to 0.9.51 2026-09-23 20:01:26 +00:00
ArchiveBox Release Bot b6ac5d232e Bump for abx-dl 1.13.28 2026-09-23 20:01:07 +00:00
ArchiveBox Release Bot 1f142fcdde Bump release version to 0.9.50 2026-09-23 04:01:57 +00:00
Nick Sweeting daf964ecf2 Fix shipped hook test dependency environment closure 2026-09-22 21:01:33 -07:00
ArchiveBox Release Bot dd7b3008e4 Bump release version to 0.9.49 2026-09-23 03:36:49 +00:00
Nick Sweeting 6438403b15 Preflight shipped hook exec plan before timed result tests 2026-09-22 20:36:28 -07:00
ArchiveBox Release Bot 3c7f247cb9 Bump release version to 0.9.48 2026-09-23 03:35:58 +00:00
ArchiveBox Release Bot bbeb15e3ad Bump for abx-dl 1.13.27 2026-09-23 03:35:45 +00:00
ArchiveBox Release Bot cb6ad96ddd Bump release version to 0.9.47 2026-09-23 03:10:03 +00:00
Nick Sweeting fe1a3d8a32 Observe live Chrome processes in takeover tests 2026-09-22 20:09:27 -07:00
ArchiveBox Release Bot 06b822a0e1 Bump release version to 0.9.46 2026-09-23 02:52:11 +00:00
Nick Sweeting 9ee55416c8 Preserve daemon signal exits and drain active hooks 2026-09-22 19:51:54 -07:00
ArchiveBox Release Bot ddd6c49f3a Bump release version to 0.9.45 2026-09-23 02:51:05 +00:00
ArchiveBox Release Bot 6d05c2ef0a Bump for abx-dl 1.13.26 2026-09-23 02:50:48 +00:00
ArchiveBox Release Bot d8e38bec32 Bump release version to 0.9.44 2026-09-23 02:09:54 +00:00
Nick Sweeting 77f5221644 Test candidate dependency for Docker release promotion 2026-09-22 19:09:30 -07:00
ArchiveBox Release Bot aa1183662a Bump release version to 0.9.43 2026-09-23 02:08:48 +00:00
ArchiveBox Release Bot 413919ae2b Bump for abx-dl 1.13.25 2026-09-23 02:08:11 +00:00
ArchiveBox Release Bot 34d68554d7 Bump release version to 0.9.42 2026-09-23 01:09:33 +00:00
Nick Sweeting ffb48bc0ca Scope release gate runs to exact source branch 2026-09-22 18:07:52 -07:00
ArchiveBox Release Bot cf4b37ae3d Bump release version to 0.9.41 2026-09-23 01:05:52 +00:00
Nick Sweeting 64248a7084 Merge remote-tracking branch 'origin/main' into dev 2026-09-22 18:05:31 -07:00
Nick Sweeting 2224f1579b Ignore skipped releases in readiness gate 2026-09-22 18:05:30 -07:00
ArchiveBox Release Bot 168e3ebdc4 Bump release version to 0.9.40 2026-09-23 01:02:54 +00:00
Nick Sweeting fe26a4bd73 Merge remote-tracking branch 'origin/main' into dev 2026-09-22 18:01:51 -07:00
Nick Sweeting 125682a71f Resolve release readiness from completed candidate and CI runs 2026-09-22 18:01:50 -07:00
ArchiveBox Release Bot 0dd9042033 Bump release version to 0.9.39 2026-09-23 01:01:14 +00:00
Nick Sweeting ba52d12301 Merge remote-tracking branch 'origin/main' into dev 2026-09-22 17:55:55 -07:00
Nick Sweeting e0bfb9dc7c Only register runner SIGINT handler on main thread 2026-09-22 17:55:32 -07:00
ArchiveBox Release Bot 067ca0d711 Bump release version to 0.9.38 2026-09-23 00:54:27 +00:00
Nick Sweeting ca84a102c8 Merge remote-tracking branch 'origin/main' into dev 2026-09-22 17:53:46 -07:00
Nick Sweeting 543f96510d Register release announcement examples in documentation inventory 2026-09-22 17:53:45 -07:00
ArchiveBox Release Bot 0f20d8cb8f Bump release version to 0.9.37 2026-09-23 00:46:35 +00:00
Nick Sweeting 16eacfafc6 Merge remote-tracking branch 'origin/main' into dev 2026-09-22 17:45:47 -07:00
Nick Sweeting bcac032bf1 Finalize crawl limits, hook lifecycle, abort handling, and automatic releases 2026-09-22 17:44:39 -07:00
ArchiveBox Release Bot 82b48f1c82 Bump for abx-dl 1.13.19 2026-09-22 22:53:50 +00:00
Nick Sweeting ee8682bd6b Keep synchronized dev from republishing stable releases 2026-09-22 15:40:24 -07:00
Nick Sweeting da754487dc Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	pyproject.toml
#	uv.lock
2026-09-22 15:24:18 -07:00
Nick Sweeting 56309da4e2 Merge remote-tracking branch 'origin/dev' into dev 2026-09-22 15:23:59 -07:00
Nick Sweeting 70d56cfa7f Finish interruption, takeover, replay, and automatic stable release fixes 2026-09-22 15:23:52 -07:00
ArchiveBox Release Bot 29a76b89a3 Bump for abx-dl 1.13.15 2026-09-22 22:16:28 +00:00
ArchiveBox Release Bot 0ad98c8f02 Bump release version to 0.9.37rc1 2026-09-22 21:57:19 +00:00
ArchiveBox Release Bot c902d9d1de Bump for abx-dl 1.13.14 2026-09-22 21:57:05 +00:00
Nick Sweeting 6dc33dd5e9 Synchronize dev and main at 0.9.36 and repair stable image publication 2026-09-22 14:01:27 -07:00
ArchiveBox Release Bot d621cae682 Bump release version to 0.9.35rc657 2026-09-22 18:25:13 +00:00
Nick Sweeting 19d3dc9daa Simplify output formats section in README
Removed redundant explanation of output formats in README.
2026-09-22 14:24:52 -04:00
Nick Sweeting a75bb7fea0 ArchiveBox 0.9.36 release merge
Merge latest dev release state into main with stable 0.9.36 metadata.
2026-09-22 11:24:16 -07:00
ArchiveBox Release Bot 4fdf0e147b Bump release version to 0.9.35rc656 2026-09-22 18:00:58 +00:00
Nick Sweeting 1f279e7080 Regenerate Playwright dependency edges for clean CI installs 2026-09-22 11:00:37 -07:00
ArchiveBox Release Bot 1fac3a45ab Bump release version to 0.9.35rc655 2026-09-22 17:44:37 +00:00
Nick Sweeting 448ecda16e Align routing regressions with complete preview cards 2026-09-22 10:44:18 -07:00
Nick Sweeting 5abfa46c35 Fix release CI browser fixtures and downloader image selection 2026-09-22 10:37:22 -07:00
Nick Sweeting 71e308ed7b ArchiveBox 0.9.36 release merge
Merge origin/dev release state into origin/main with the stable 0.9.36 version metadata and published dependency pins.
2026-09-21 19:59:22 -07:00
ArchiveBox Release Bot 5eaa6f6998 Bump release version to 0.9.35rc654 2026-09-22 02:57:04 +00:00
ArchiveBox Release Bot efb2a042f3 Bump for abx-dl 1.13.12 2026-09-22 02:56:48 +00:00
ArchiveBox Release Bot bc057afc17 Bump release version to 0.9.35rc653 2026-09-22 02:45:19 +00:00
ArchiveBox Release Bot 1d1086f105 Bump for abx-dl 1.13.11 2026-09-22 02:44:51 +00:00
ArchiveBox Release Bot 79614ed4de Bump release version to 0.9.35rc652 2026-09-22 02:26:00 +00:00
ArchiveBox Release Bot 34eb14bee5 Bump for abx-dl 1.13.10 2026-09-22 02:25:25 +00:00
ArchiveBox Release Bot 5ad350a0f3 Bump release version to 0.9.35rc651 2026-09-22 02:06:25 +00:00
Nick Sweeting 12e8f23ee4 Use complete responsive cards for snapshot stack covers 2026-09-21 19:04:57 -07:00
ArchiveBox Release Bot 7e0a52a33c Bump release version to 0.9.35rc650 2026-09-22 02:02:56 +00:00
Nick Sweeting 31938144e1 Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 19:02:17 -07:00
Nick Sweeting 2d4ac1c2ac Show Readability first in the article text stack 2026-09-21 19:01:20 -07:00
ArchiveBox Release Bot 31972e2543 Bump release version to 0.9.35rc649 2026-09-22 01:49:48 +00:00
ArchiveBox Release Bot d59bab6143 Bump for abx-dl 1.13.8 2026-09-22 01:49:20 +00:00
ArchiveBox Release Bot b63671158d Bump release version to 0.9.35rc648 2026-09-22 01:34:35 +00:00
Nick Sweeting 716e1914d9 Update platform links on all translated homepages 2026-09-21 18:34:03 -07:00
ArchiveBox Release Bot 73e66ac7c3 Bump for abx-dl 1.13.7 2026-09-22 01:31:42 +00:00
ArchiveBox Release Bot 805b5fcba9 Bump release version to 0.9.35rc647 2026-09-22 01:28:56 +00:00
Nick Sweeting f19b97295a Fix homepage platform links and install anchor scrolling 2026-09-21 18:28:19 -07:00
ArchiveBox Release Bot fba6b0ba00 Bump release version to 0.9.35rc646 2026-09-22 01:12:46 +00:00
ArchiveBox Release Bot 56be00b47c Bump for abx-dl 1.13.6 2026-09-22 01:12:16 +00:00
Nick Sweeting 8bea7f63c8 Account for indexed bulk preview query in admin list test 2026-09-21 18:11:41 -07:00
Nick Sweeting d50211e951 Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 18:11:37 -07:00
Nick Sweeting d9281ca3e0 Announce v0.9 release above homepage terminal 2026-09-21 18:11:27 -07:00
ArchiveBox Release Bot 3a2414e8d6 Bump release version to 0.9.35rc645 2026-09-22 01:07:14 +00:00
Nick Sweeting 18ed3c3baa Unify output thumbnails and preserve preview context 2026-09-21 18:06:23 -07:00
ArchiveBox Release Bot c5e66f0754 Bump release version to 0.9.35rc644 2026-09-22 00:59:05 +00:00
Nick Sweeting 6460625491 Restore fixed screenshot sizes for all plugin outputs 2026-09-21 17:58:07 -07:00
ArchiveBox Release Bot 9288276bae Bump release version to 0.9.35rc643 2026-09-22 00:54:33 +00:00
Nick Sweeting cfb233d6d1 Use capture viewport dimensions in gallery validation and labels 2026-09-21 17:54:08 -07:00
ArchiveBox Release Bot 7afc41ab9e Bump for abx-dl 1.13.4 2026-09-22 00:54:06 +00:00
ArchiveBox Release Bot e5be511741 Bump release version to 0.9.35rc642 2026-09-22 00:53:49 +00:00
Nick Sweeting d94590f652 Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 17:53:23 -07:00
Nick Sweeting 78847a1ad5 Move parsed outputs into article and embedded media stacks 2026-09-21 17:52:53 -07:00
ArchiveBox Release Bot 32ea249b12 Bump release version to 0.9.35rc641 2026-09-22 00:40:33 +00:00
ArchiveBox Release Bot 0fba2c4d38 Bump for abx-dl 1.13.3 2026-09-22 00:40:13 +00:00
ArchiveBox Release Bot 6bc4f414b3 Bump release version to 0.9.35rc640 2026-09-22 00:20:28 +00:00
Nick Sweeting 0ec9bb32a8 Capture actual output anchors without preview layout assertions 2026-09-21 17:20:06 -07:00
ArchiveBox Release Bot 6396d6a387 Bump release version to 0.9.35rc639 2026-09-21 23:57:21 +00:00
ArchiveBox Release Bot 04e084f911 Bump for abx-dl 1.13.2 2026-09-21 23:57:03 +00:00
ArchiveBox Release Bot 49391ac78e Bump release version to 0.9.35rc638 2026-09-21 23:48:07 +00:00
Nick Sweeting c54ce1fd73 Capture live gallery page without progress UI assertions 2026-09-21 16:46:57 -07:00
ArchiveBox Release Bot a0ec6c8055 Bump release version to 0.9.35rc637 2026-09-21 23:42:54 +00:00
Nick Sweeting f9d75fb0f4 Use current dependency releases in installer and Docker defaults 2026-09-21 16:41:04 -07:00
ArchiveBox Release Bot f96971536f Bump release version to 0.9.35rc636 2026-09-21 23:39:32 +00:00
Nick Sweeting 7eae2fd5c3 Capture plugin anchors without plugin-specific UI assertions 2026-09-21 16:39:00 -07:00
Nick Sweeting ecad318b80 Wait for verified proof previews before gallery captures 2026-09-21 16:28:55 -07:00
ArchiveBox Release Bot 4bc28cdb54 Bump release version to 0.9.35rc635 2026-09-21 23:27:44 +00:00
Nick Sweeting 61dc8ebd74 Bootstrap installs with published abxpkg 1.13.0 2026-09-21 16:26:49 -07:00
ArchiveBox Release Bot 6a8d6057af Bump release version to 0.9.35rc634 2026-09-21 23:23:47 +00:00
Nick Sweeting f7ce1be391 Keep personas under data and verify current proof previews 2026-09-21 16:23:18 -07:00
Nick Sweeting d829c3f804 Assert current Git hook diagnostics in snapshot lifecycle test 2026-09-21 16:23:18 -07:00
ArchiveBox Release Bot 9aeb0400f1 Bump release version to 0.9.35rc633 2026-09-21 22:36:34 +00:00
Nick Sweeting 4f9e89a390 Support current plugin previews and refresh installer bootstrap pin 2026-09-21 15:35:06 -07:00
Nick Sweeting 7a808bf149 Document shared website navbar and footer sync boundaries 2026-09-21 15:33:11 -07:00
ArchiveBox Release Bot 3af2ee222c Bump release version to 0.9.35rc632 2026-09-21 22:17:00 +00:00
ArchiveBox Release Bot 3f252f7573 Bump for abx-dl 1.12.386 2026-09-21 22:16:17 +00:00
ArchiveBox Release Bot a33217ccea Bump release version to 0.9.35rc631 2026-09-21 22:00:06 +00:00
ArchiveBox Release Bot 4504bab7e3 Bump for abx-dl 1.12.385 2026-09-21 21:58:31 +00:00
ArchiveBox Release Bot 83439dfe17 Bump release version to 0.9.35rc630 2026-09-21 21:51:21 +00:00
Nick Sweeting 761455008c Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 14:49:49 -07:00
Nick Sweeting 7cfdd4c18f Preserve synced persona preferences and explicit auth clearing 2026-09-21 14:49:40 -07:00
Nick Sweeting 5fa08d6c14 Move historical notes to the monorepo old directory 2026-09-21 14:48:55 -07:00
ArchiveBox Release Bot a093ebb929 Bump release version to 0.9.35rc629 2026-09-21 21:45:21 +00:00
ArchiveBox Release Bot 000455f0fe Bump for abx-dl 1.12.384 2026-09-21 21:44:40 +00:00
ArchiveBox Release Bot 0948bd3498 Bump release version to 0.9.35rc628 2026-09-21 21:26:05 +00:00
ArchiveBox Release Bot 82d82a3227 Bump for abx-dl 1.12.383 2026-09-21 21:24:53 +00:00
Nick Sweeting 5b06e9178c Validate stable screenshot names and all gallery embeds before publishing 2026-09-21 14:24:36 -07:00
ArchiveBox Release Bot 4228d5e77a Bump release version to 0.9.35rc627 2026-09-21 21:18:12 +00:00
Nick Sweeting 4a89e73c7d Use stable screenshot filenames independent of capture order 2026-09-21 14:17:36 -07:00
ArchiveBox Release Bot 6168cecdb3 Bump release version to 0.9.35rc626 2026-09-21 21:09:38 +00:00
ArchiveBox Release Bot b8b2c34137 Bump for abx-dl 1.12.382 2026-09-21 21:07:00 +00:00
ArchiveBox Release Bot 23192028df Bump release version to 0.9.35rc625 2026-09-21 21:04:53 +00:00
Nick Sweeting 2002dcc1e0 Fix localized gallery links and publish discoverable sitemaps 2026-09-21 14:04:24 -07:00
ArchiveBox Release Bot f8e94649bf Bump release version to 0.9.35rc624 2026-09-21 21:03:50 +00:00
Nick Sweeting 480c97a560 Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 14:01:44 -07:00
Nick Sweeting d395de326a Unify snapshot thumbnails across archive views and select expanded outputs 2026-09-21 14:01:24 -07:00
Nick Sweeting e0645f96f4 Render archived Markdown images and retain expanded stack previews 2026-09-21 14:00:56 -07:00
ArchiveBox Release Bot b51f9240e8 Bump release version to 0.9.35rc623 2026-09-21 20:57:50 +00:00
Nick Sweeting 54ae9b3a2f Complete remaining translated page text 2026-09-21 13:55:58 -07:00
ArchiveBox Release Bot 19cd801f2a Bump release version to 0.9.35rc622 2026-09-21 20:53:34 +00:00
Nick Sweeting 0ee5ddd8cc Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 13:52:38 -07:00
Nick Sweeting 875f932670 Preserve HTML rewriting for synthetic Responses card 2026-09-21 13:52:28 -07:00
ArchiveBox Release Bot 788d6615e5 Bump release version to 0.9.35rc621 2026-09-21 20:51:44 +00:00
Nick Sweeting 6c5a35dc0f Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 13:50:55 -07:00
Nick Sweeting 2ece14592e Add Responses HTML card exception to snapshot stacks 2026-09-21 13:50:33 -07:00
ArchiveBox Release Bot 711371b3e2 Bump release version to 0.9.35rc620 2026-09-21 20:50:20 +00:00
Nick Sweeting 588d67545b Translate navigation and footer labels in all site languages 2026-09-21 13:48:57 -07:00
ArchiveBox Release Bot 875c540548 Bump release version to 0.9.35rc619 2026-09-21 20:39:53 +00:00
ArchiveBox Release Bot 97bf399311 Bump for abx-dl 1.12.379 2026-09-21 20:38:09 +00:00
ArchiveBox Release Bot ae171e129f Bump release version to 0.9.35rc618 2026-09-21 20:31:15 +00:00
Nick Sweeting 807289e2aa Add standalone translated public landing pages 2026-09-21 13:30:42 -07:00
ArchiveBox Release Bot f0baeda72f Bump release version to 0.9.35rc617 2026-09-21 20:20:33 +00:00
ArchiveBox Release Bot 2a8b22854c Bump for abx-dl 1.12.378 2026-09-21 20:20:13 +00:00
ArchiveBox Release Bot cae4ad8fe5 Bump release version to 0.9.35rc616 2026-09-21 20:03:16 +00:00
Nick Sweeting a408f9cfd1 Serve JSONL files inline as plain text 2026-09-21 13:00:26 -07:00
ArchiveBox Release Bot 997a2eaabd Bump release version to 0.9.35rc615 2026-09-21 19:58:07 +00:00
Nick Sweeting 583006c3ab Merge remote-tracking branch 'origin/dev' into dev 2026-09-21 12:56:45 -07:00
Nick Sweeting 7e38856270 Create static export destination before output discovery 2026-09-21 12:56:36 -07:00
ArchiveBox Release Bot 324568351e Bump release version to 0.9.35rc614 2026-09-21 19:52:26 +00:00
ArchiveBox Release Bot 26c6b57f94 Bump for abx-dl 1.12.376 2026-09-21 19:51:25 +00:00
Nick Sweeting 7f46b7a208 Allow file downloads from snapshot preview frame 2026-09-21 12:42:43 -07:00
ArchiveBox Release Bot c93450a4d5 Bump release version to 0.9.35rc613 2026-09-21 19:32:40 +00:00
Nick Sweeting c13e153cb4 Expand selected output stacks in gallery captures 2026-09-21 12:31:01 -07:00
Nick Sweeting 69129ff433 Preserve snapshot output anchors across lazy discovery 2026-09-21 12:31:01 -07:00
ArchiveBox Release Bot 3b85a8bef9 Bump release version to 0.9.35rc612 2026-09-21 19:24:27 +00:00
Nick Sweeting 02b10f7cdf Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-21 12:20:32 -07:00
Nick Sweeting 2b6c84c995 Group captured responses with embedded media 2026-09-21 12:20:21 -07:00
ArchiveBox Release Bot d767a58f1c Bump release version to 0.9.35rc611 2026-09-21 19:19:01 +00:00
Nick Sweeting f428d9dc2c Keep snapshot HTML DB-only and discover orphan files on demand (#1885)
Defer snapshot filesystem discovery and hidden preview requests
2026-09-21 12:12:28 -07:00
ArchiveBox Release Bot 841f91bb37 Bump release version to 0.9.35rc610 2026-09-21 19:04:43 +00:00
Nick Sweeting 314bef9881 Resolve snapshot host suffixes through the covering ID index 2026-09-21 12:00:55 -07:00
ArchiveBox Release Bot 53f3e33c9a Bump release version to 0.9.35rc609 2026-09-21 18:53:10 +00:00
Nick Sweeting 67347f3881 Avoid duplicate preview iframe loads in snapshot stacks (#1884)
Stack covers cloned live thumbnail iframes, creating extra requests for
archived output. Reuse each original iframe between its cover and
expanded card, preserving browsing state with moveBefore where
supported. No additional live iframe is created for a cover. Verified
against a real exported capture in Brave: total iframe count stays at
six through expansion and collapse, with the article frame moving
between the two locations. Existing grouping tests and JavaScript syntax
checks pass.
2026-09-21 11:48:48 -07:00
Nick Sweeting 7650ab9269 Share thumbnail frames between stack covers and expanded cards 2026-09-21 11:48:25 -07:00
ArchiveBox Release Bot 7f564ab7f8 Bump release version to 0.9.35rc608 2026-09-21 10:34:30 +00:00
ArchiveBox Release Bot a23d68bfea Bump for abx-dl 1.12.375 2026-09-21 10:34:09 +00:00
ArchiveBox Release Bot ebc06be775 Bump release version to 0.9.35rc607 2026-09-21 10:11:38 +00:00
Nick Sweeting 3ed300f1c1 Group snapshot outputs into compact expandable stacks (#1883)
Snapshot detail pages now group output cards into compact, expandable
category stacks. A central taxonomy defines plugin preference order;
media, metadata, and unlisted plugins sort by output size, with unlisted
plugins falling back to Other files. Expanded trays preserve preview
links, folder/download actions, and authorized delete controls without a
duplicate category heading.

The same layout is embedded in portable static exports. Validation: 94
targeted snapshot/export/deletion tests passed before rebasing, all four
grouping tests passed again on current dev, and real captures were
checked in Brave for selection/hash navigation, delete countdown
cancellation, keyboard collapse, mobile wrapping, and static export
expansion.
2026-09-21 03:07:34 -07:00
Nick Sweeting 2a9ca3cd4f Group snapshot output cards into expandable category stacks 2026-09-21 03:06:12 -07:00
ArchiveBox Release Bot d0634661cf Bump release version to 0.9.35rc606 2026-09-21 09:44:16 +00:00
ArchiveBox Release Bot d1790b9e85 Bump for abx-dl 1.12.374 2026-09-21 09:44:02 +00:00
ArchiveBox Release Bot 21447fec2f Bump release version to 0.9.35rc605 2026-09-21 09:17:03 +00:00
ArchiveBox Release Bot 956422e158 Bump for abx-dl 1.12.373 2026-09-21 09:16:32 +00:00
ArchiveBox Release Bot 95aa25e422 Bump release version to 0.9.35rc604 2026-09-21 09:02:07 +00:00
ArchiveBox Release Bot 597efebf91 Bump for abx-dl 1.12.372 2026-09-21 09:01:17 +00:00
ArchiveBox Release Bot c9aa76c1b6 Bump release version to 0.9.35rc603 2026-09-21 08:34:06 +00:00
ArchiveBox Release Bot ccb175eec7 Bump for abx-dl 1.12.370 2026-09-21 08:33:33 +00:00
ArchiveBox Release Bot aaf104107e Bump release version to 0.9.35rc602 2026-09-21 08:08:03 +00:00
ArchiveBox Release Bot 109326dcec Bump for abx-dl 1.12.369 2026-09-21 08:07:46 +00:00
ArchiveBox Release Bot 56bb67a4da Bump release version to 0.9.35rc601 2026-09-21 07:52:11 +00:00
ArchiveBox Release Bot c400c7caf1 Bump for abx-dl 1.12.368 2026-09-21 07:51:46 +00:00
ArchiveBox Release Bot 9b78a0a221 Bump release version to 0.9.35rc600 2026-09-21 07:25:44 +00:00
Nick Sweeting 220637ae9b Test plugin grid completeness and OpenTimestamps ordering 2026-09-21 00:23:42 -07:00
ArchiveBox Release Bot 03d8bf9b39 Bump release version to 0.9.35rc599 2026-09-21 06:25:18 +00:00
Nick Sweeting 8c60511da1 Show inline warnings for crawl file import failures 2026-09-20 23:25:02 -07:00
ArchiveBox Release Bot 2ac4167c27 Bump release version to 0.9.35rc598 2026-09-21 06:14:51 +00:00
Nick Sweeting df570ae4fb Add client-side file import to crawl URL form 2026-09-20 23:14:19 -07:00
ArchiveBox Release Bot 2df6515185 Bump release version to 0.9.35rc597 2026-09-21 06:12:31 +00:00
ArchiveBox Release Bot 193e4183c1 Bump for abx-dl 1.12.362 2026-09-21 06:12:11 +00:00
ArchiveBox Release Bot 848c191d50 Bump release version to 0.9.35rc596 2026-09-21 05:46:22 +00:00
Nick Sweeting 3aa1f9a340 Verify console cards and raw previews through their current routes 2026-09-20 22:46:02 -07:00
ArchiveBox Release Bot ca3d5f5653 Bump release version to 0.9.35rc595 2026-09-20 22:51:39 +00:00
ArchiveBox Release Bot 4315a2aa55 Bump for abx-dl 1.12.359 2026-09-20 22:51:10 +00:00
ArchiveBox Release Bot 9741f97392 Bump release version to 0.9.35rc594 2026-09-20 22:26:57 +00:00
Nick Sweeting cece0309be Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 15:26:31 -07:00
Nick Sweeting da1aeb7bfe Keep snapshot archive links within narrow headers 2026-09-20 15:26:18 -07:00
ArchiveBox Release Bot 9b1bcc4b93 Bump release version to 0.9.35rc593 2026-09-20 22:12:54 +00:00
Nick Sweeting d63af05e85 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 15:12:33 -07:00
Nick Sweeting 41b23b1211 Raise complete ArchiveBox image size budget to 1025 MiB 2026-09-20 15:12:32 -07:00
ArchiveBox Release Bot 079ea29529 Bump release version to 0.9.35rc592 2026-09-20 22:09:49 +00:00
Nick Sweeting b2b7a8b700 Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 15:09:02 -07:00
Nick Sweeting da52ab5ee9 Require gallery coverage of metadata and document previews 2026-09-20 15:08:53 -07:00
ArchiveBox Release Bot 49fb004ed6 Bump release version to 0.9.35rc591 2026-09-20 21:57:42 +00:00
Nick Sweeting f94c76078d Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:56:43 -07:00
Nick Sweeting b3f8e3f714 Clarify image completeness and runtime installation CI requirements 2026-09-20 14:56:30 -07:00
ArchiveBox Release Bot 9978649bcb Bump release version to 0.9.35rc590 2026-09-20 21:54:35 +00:00
Nick Sweeting 6e82266e93 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:54:04 -07:00
Nick Sweeting 5a83ff351a Keep dependency installation on the normal runtime-user path 2026-09-20 14:54:03 -07:00
ArchiveBox Release Bot a7b69fa2fa Bump release version to 0.9.35rc589 2026-09-20 21:51:54 +00:00
Nick Sweeting 72fb4bcdb0 Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:50:48 -07:00
Nick Sweeting 9c196be557 Install missing system dependencies during the image build 2026-09-20 14:50:13 -07:00
ArchiveBox Release Bot 97fe7da914 Bump release version to 0.9.35rc588 2026-09-20 21:46:36 +00:00
Nick Sweeting 702924dd2b Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:45:58 -07:00
Nick Sweeting 30453378dc Keep the sticky site header attached to the viewport 2026-09-20 14:45:44 -07:00
ArchiveBox Release Bot 069d180841 Bump release version to 0.9.35rc587 2026-09-20 21:42:23 +00:00
Nick Sweeting 006845645d Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:41:10 -07:00
Nick Sweeting 27dd467259 Frame screenshot cards as clipped floating windows 2026-09-20 14:40:54 -07:00
ArchiveBox Release Bot e1c7a8de49 Bump release version to 0.9.35rc586 2026-09-20 21:35:11 +00:00
Nick Sweeting 0790d2566d Merge branch 'dev' of https://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 14:34:43 -07:00
ArchiveBox Release Bot 319f72ee74 Bump release version to 0.9.35rc585 2026-09-20 21:34:41 +00:00
Nick Sweeting 1a166e39f0 Use simple screenshot shadows without filter compositing 2026-09-20 14:34:30 -07:00
Nick Sweeting 3586fe9aed Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 14:33:50 -07:00
Nick Sweeting 44450dbfac Preserve original text bytes for explicit raw file requests 2026-09-20 14:33:23 -07:00
ArchiveBox Release Bot 7098b97ef4 Bump release version to 0.9.35rc584 2026-09-20 21:29:20 +00:00
Nick Sweeting a3f9541535 Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 14:28:52 -07:00
Nick Sweeting 19cb7df303 Use the same full-width screenshot strips on every product site 2026-09-20 14:27:20 -07:00
Nick Sweeting a1261abe94 Check real timestamp output structure before screenshot capture 2026-09-20 14:27:11 -07:00
Nick Sweeting 7e0985b54d Render dedicated metadata cards and preserve explicit raw file previews 2026-09-20 14:25:01 -07:00
ArchiveBox Release Bot f1e49da2c8 Bump release version to 0.9.35rc583 2026-09-20 21:04:29 +00:00
Nick Sweeting f6b011039b Preinstall and verify every ArchiveBox plugin dependency 2026-09-20 14:03:53 -07:00
Nick Sweeting abab45754c Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 14:03:03 -07:00
Nick Sweeting a5105b16dd Capture setup wizard through its canonical admin origin after validation 2026-09-20 14:01:00 -07:00
ArchiveBox Release Bot eb68405145 Bump release version to 0.9.35rc582 2026-09-20 20:59:56 +00:00
Nick Sweeting 781f27757b Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 13:59:26 -07:00
Nick Sweeting ecc756b044 ui: add subtle screenshot shadows and feature archive views first 2026-09-20 13:58:55 -07:00
ArchiveBox Release Bot 9743c31239 Bump release version to 0.9.35rc581 2026-09-20 20:42:19 +00:00
Nick Sweeting e50265768e Match OpenTimestamps screenshot readiness to updated wording 2026-09-20 13:42:03 -07:00
ArchiveBox Release Bot 335798f16a Bump release version to 0.9.35rc580 2026-09-20 14:45:20 +00:00
Nick Sweeting 85a6d3d311 Clean up permission test snapshots before later routing tests 2026-09-20 07:45:04 -07:00
ArchiveBox Release Bot 818ca9a801 Bump release version to 0.9.35rc579 2026-09-20 14:23:41 +00:00
Nick Sweeting a5a2ff66bf Verify canonical origin link in simplified status banner 2026-09-20 07:23:21 -07:00
ArchiveBox Release Bot 3bfb27d0fd Bump release version to 0.9.35rc578 2026-09-20 14:22:27 +00:00
ArchiveBox Release Bot d4ab858b3f Bump for abx-dl 1.12.342 2026-09-20 14:22:06 +00:00
ArchiveBox Release Bot 2e29df5b8d Bump release version to 0.9.35rc577 2026-09-20 14:12:09 +00:00
ArchiveBox Release Bot 9228ba3740 Bump for abx-dl 1.12.341 2026-09-20 14:11:36 +00:00
ArchiveBox Release Bot 24ee93d3b4 Bump release version to 0.9.35rc576 2026-09-20 13:56:06 +00:00
Nick Sweeting 9293ac248b Verify hashes completion checksum in archive result tests 2026-09-20 06:55:37 -07:00
ArchiveBox Release Bot 1501eb9531 Bump release version to 0.9.35rc575 2026-09-20 13:36:47 +00:00
Nick Sweeting c20eb4cfce Keep screenshot marquees free of labels and controls 2026-09-20 06:36:00 -07:00
Nick Sweeting ce1108c275 Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 06:34:49 -07:00
Nick Sweeting e32f95bf31 fix: keep the page grid behind all content 2026-09-20 06:34:39 -07:00
ArchiveBox Release Bot 7ecec0e9d7 Bump release version to 0.9.35rc574 2026-09-20 13:33:04 +00:00
Nick Sweeting 2a6e2f226e Verify centered short galleries without unnecessary autoplay controls 2026-09-20 06:32:13 -07:00
ArchiveBox Release Bot ea02473dce Bump release version to 0.9.35rc573 2026-09-20 13:30:32 +00:00
Nick Sweeting 0221227d0a Remove trailing period from save tabs headline 2026-09-20 06:29:16 -07:00
Nick Sweeting 9ed08e262f Let capture completion events rebuild Pages from trusted screenshots 2026-09-20 06:29:15 -07:00
ArchiveBox Release Bot e5f149db41 Bump release version to 0.9.35rc572 2026-09-20 13:27:42 +00:00
Nick Sweeting dcd4382e21 Publish verified Pages builds without release-bot starvation 2026-09-20 06:27:20 -07:00
Nick Sweeting 073410cc74 Apply quiet screenshot setting to every capture runtime owner 2026-09-20 06:26:43 -07:00
Nick Sweeting 510319b0a1 ui: simplify server status warning text 2026-09-20 06:26:42 -07:00
Nick Sweeting 623768177b fix: scope live progress process queries to displayed crawls 2026-09-20 06:26:42 -07:00
ArchiveBox Release Bot 9dd58a4368 Bump release version to 0.9.35rc571 2026-09-20 13:23:09 +00:00
ArchiveBox Release Bot 94402250e0 Bump for abx-dl 1.12.336 2026-09-20 13:22:48 +00:00
ArchiveBox Release Bot 5eba10ddab Bump release version to 0.9.35rc570 2026-09-20 13:22:36 +00:00
Nick Sweeting 7162af4fb8 Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 06:22:06 -07:00
Nick Sweeting 58cd6a6df9 fix: complete shared site assembly and gallery-fed marquees 2026-09-20 06:21:59 -07:00
ArchiveBox Release Bot 286ef28d5a Bump release version to 0.9.35rc569 2026-09-20 13:09:15 +00:00
Nick Sweeting 863882fa93 refactor: separate copied Pages base from product-specific content 2026-09-20 06:07:51 -07:00
Nick Sweeting f111c193b5 Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 06:07:22 -07:00
Nick Sweeting ad854323a9 Honor CHROME_BINARY when collecting gallery screenshots 2026-09-20 06:07:04 -07:00
ArchiveBox Release Bot 0178c5cbe4 Bump release version to 0.9.35rc568 2026-09-20 13:03:51 +00:00
Nick Sweeting 6d00ab590c ci: standardize independent Pages publication and stale-build checks 2026-09-20 05:59:14 -07:00
ArchiveBox Release Bot 4d263f208d Bump release version to 0.9.35rc567 2026-09-20 12:56:37 +00:00
Nick Sweeting 8fae69725c docs: define independent Pages and screenshot conventions 2026-09-20 05:56:05 -07:00
ArchiveBox Release Bot 436689fd29 Bump release version to 0.9.35rc566 2026-09-20 12:54:38 +00:00
Nick Sweeting 26b2120d21 Capture OpenTimestamps output in the public screenshot gallery 2026-09-20 05:54:08 -07:00
ArchiveBox Release Bot 8b3caa2773 Bump release version to 0.9.35rc565 2026-09-20 12:34:21 +00:00
Nick Sweeting e761f76420 Update README.md 2026-09-20 08:33:57 -04:00
ArchiveBox Release Bot a20075ef90 Bump release version to 0.9.35rc564 2026-09-20 12:25:01 +00:00
Nick Sweeting 8ebcb131fb Clarify support for login and JS during archiving
Updated the README to clarify support for archiving content requiring login and running JS during archiving.
2026-09-20 08:24:30 -04:00
ArchiveBox Release Bot 8b6b9efbb9 Bump release version to 0.9.35rc563 2026-09-20 12:22:00 +00:00
Nick Sweeting 2c146ae20b Refine save and retain feature heading 2026-09-20 05:21:41 -07:00
ArchiveBox Release Bot 2dcfc93b1f Bump release version to 0.9.35rc562 2026-09-20 11:51:43 +00:00
Nick Sweeting a192a16314 Clarify offline reading card heading 2026-09-20 04:51:26 -07:00
ArchiveBox Release Bot dc4f3ba12c Bump release version to 0.9.35rc561 2026-09-20 11:42:44 +00:00
Nick Sweeting 0b61d05f14 Use fresh Mac connection settings screenshot in Quickstart 2026-09-20 04:42:07 -07:00
ArchiveBox Release Bot d9414487d2 Bump release version to 0.9.35rc560 2026-09-20 11:40:00 +00:00
Nick Sweeting 21aeea3a66 Expand homepage output formats and access methods into lists 2026-09-20 04:39:40 -07:00
ArchiveBox Release Bot 810a5e4f59 Bump release version to 0.9.35rc559 2026-09-20 11:24:33 +00:00
Nick Sweeting 372d06fe81 Refresh homepage feature cards and mobile share screenshots 2026-09-20 04:24:15 -07:00
ArchiveBox Release Bot 98b3b67f36 Bump release version to 0.9.35rc558 2026-09-20 10:50:58 +00:00
ArchiveBox Release Bot 193b699b83 Bump for abx-dl 1.12.333 2026-09-20 10:50:42 +00:00
ArchiveBox Release Bot 273c5d6f3c Bump release version to 0.9.35rc557 2026-09-20 10:24:52 +00:00
Nick Sweeting 21ed8ea960 Add demo link alongside hero screenshots link 2026-09-20 03:23:44 -07:00
ArchiveBox Release Bot 716178290a Bump release version to 0.9.35rc556 2026-09-20 10:19:00 +00:00
Nick Sweeting 3177596156 Point extension menu name and Safari to the extension site 2026-09-20 03:18:20 -07:00
ArchiveBox Release Bot 0c1d0f561c Bump release version to 0.9.35rc555 2026-09-20 10:16:43 +00:00
Nick Sweeting 746d487390 Document legacy Docker port compatibility in one place 2026-09-20 03:16:26 -07:00
Nick Sweeting be35cb45b0 Merge branch 'dev' of git+ssh://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 03:16:06 -07:00
ArchiveBox Release Bot afd7576ad8 Bump for abx-dl 1.12.330 2026-09-20 10:15:58 +00:00
Nick Sweeting aba8f1342a Highlight platform download choices for the visitor OS 2026-09-20 03:15:57 -07:00
ArchiveBox Release Bot b50f1e7692 Bump release version to 0.9.35rc554 2026-09-20 10:11:48 +00:00
Nick Sweeting a056d5daf7 Use standard port 5797 in route examples and gallery links 2026-09-20 03:11:28 -07:00
ArchiveBox Release Bot 4384d09e3f Bump release version to 0.9.35rc553 2026-09-20 10:07:41 +00:00
Nick Sweeting 3f3166cfea Show real Android screenshot in Quickstart phone frame 2026-09-20 03:07:20 -07:00
ArchiveBox Release Bot 3eb01ddd25 Bump release version to 0.9.35rc552 2026-09-20 10:00:32 +00:00
Nick Sweeting 4b52eed55a Merge branch 'dev' of git+ssh://github.com/ArchiveBox/ArchiveBox into dev 2026-09-20 02:59:18 -07:00
Nick Sweeting 105c1e793e Merge remote-tracking branch 'origin/dev' into dev 2026-09-20 02:59:06 -07:00
Nick Sweeting f4b9634863 Add pip alternative instructions and fit app CTAs on mobile 2026-09-20 02:59:06 -07:00
ArchiveBox Release Bot 35a2e9c9c6 Bump release version to 0.9.35rc551 2026-09-20 09:57:38 +00:00
Nick Sweeting d76083d26c Polish platform icon colors and app download labels 2026-09-20 02:57:32 -07:00
ArchiveBox Release Bot f5b2f6a030 Bump for abx-dl 1.12.328 2026-09-20 09:56:46 +00:00
ArchiveBox Release Bot 83c83e53c3 Bump release version to 0.9.35rc550 2026-09-20 09:54:52 +00:00
Nick Sweeting 0a4f328fa6 Link Decomposerize from the raw Docker quickstart 2026-09-20 02:54:25 -07:00
Nick Sweeting c4388d2435 Split Quickstart platform tabs and combine Docker install options 2026-09-20 02:53:56 -07:00
ArchiveBox Release Bot 7af15501f8 Bump release version to 0.9.35rc549 2026-09-20 09:42:43 +00:00
Nick Sweeting b9c9daaa65 Improve native anchor positioning with CSS scroll spacing 2026-09-20 02:41:01 -07:00
ArchiveBox Release Bot c3f9c1a9ef Bump release version to 0.9.35rc548 2026-09-20 09:31:15 +00:00
Nick Sweeting e03db54bca Polish shared Apps dropdown with platform and integration badges 2026-09-20 02:30:57 -07:00
ArchiveBox Release Bot 23a5c6bba9 Bump release version to 0.9.35rc547 2026-09-20 08:26:48 +00:00
Nick Sweeting 7a5ad4c99a Scope snapshot caches to effective permissions while preserving artifact caching 2026-09-20 01:26:31 -07:00
ArchiveBox Release Bot 1a93058585 Bump release version to 0.9.35rc546 2026-09-20 08:07:29 +00:00
Nick Sweeting 85033aa622 Show real Linux desktop CI screenshot in quickstart 2026-09-20 01:06:57 -07:00
ArchiveBox Release Bot 922bcc05ba Bump release version to 0.9.35rc545 2026-09-20 08:05:10 +00:00
Nick Sweeting 070b8ae9c5 Reject dot directory names in ArchiveResult uploads 2026-09-20 01:04:34 -07:00
ArchiveBox Release Bot f77ac105a1 Bump release version to 0.9.35rc544 2026-09-20 08:01:41 +00:00
ArchiveBox Release Bot f41f0afa03 Bump for abx-dl 1.12.326 2026-09-20 08:01:20 +00:00
ArchiveBox Release Bot 31062043fc Bump release version to 0.9.35rc543 2026-09-20 07:52:28 +00:00
Nick Sweeting eeb06148ad Serve admin thumbnails without replay auth redirects 2026-09-20 00:52:11 -07:00
ArchiveBox Release Bot 80d3804e56 Bump release version to 0.9.35rc542 2026-09-20 07:43:58 +00:00
Nick Sweeting 3a4696165d Authorize scoped progress from private replay sessions 2026-09-20 00:43:11 -07:00
ArchiveBox Release Bot 28b3bda674 Bump release version to 0.9.35rc541 2026-09-20 07:37:21 +00:00
Nick Sweeting 8de094435e Replace obsolete Electron installers with current downloads 2026-09-20 00:36:54 -07:00
ArchiveBox Release Bot 4c993242dc Bump release version to 0.9.35rc540 2026-09-20 07:35:19 +00:00
Nick Sweeting c3e6f62d0f Link Electron quickstart to desktop setup guide 2026-09-20 00:34:42 -07:00
ArchiveBox Release Bot f141cb841a Bump release version to 0.9.35rc539 2026-09-20 07:27:59 +00:00
Nick Sweeting 10152bce2a Label desktop setup links for the canonical guide 2026-09-20 00:27:36 -07:00
Nick Sweeting d6fe46d327 Prioritize recently queued capture work 2026-09-20 00:27:20 -07:00
ArchiveBox Release Bot a7d4245c36 Bump release version to 0.9.35rc538 2026-09-20 07:26:46 +00:00
Nick Sweeting 8b634f9568 Use electron.archivebox.io as the desktop site canonical URL 2026-09-20 00:26:15 -07:00
ArchiveBox Release Bot 9cddb2b2dc Bump release version to 0.9.35rc537 2026-09-20 07:21:45 +00:00
Nick Sweeting 7e7af50eff Add direct desktop downloads to the quickstart 2026-09-20 00:21:31 -07:00
ArchiveBox Release Bot e019a2b07a Bump release version to 0.9.35rc536 2026-09-20 06:48:10 +00:00
Nick Sweeting 0d2a4bf393 Center snapshot grid dates within each card 2026-09-19 23:47:39 -07:00
ArchiveBox Release Bot 8914d95e83 Bump release version to 0.9.35rc535 2026-09-20 06:37:37 +00:00
Nick Sweeting 0f965972e3 Wait for both Docker server ports 2026-09-19 23:37:22 -07:00
ArchiveBox Release Bot 7a66e8ef6e Bump release version to 0.9.35rc534 2026-09-20 06:26:57 +00:00
Nick Sweeting be857415ad Accept chunked multipart uploads from Safari clients 2026-09-19 23:26:44 -07:00
ArchiveBox Release Bot bfd6b3c2a7 Bump release version to 0.9.35rc533 2026-09-20 06:06:00 +00:00
Nick Sweeting 69667e5cdb Prioritize new submissions and keep live still browser frames visible 2026-09-19 23:05:27 -07:00
ArchiveBox Release Bot fffbdf0226 Bump release version to 0.9.35rc532 2026-09-20 05:58:12 +00:00
ArchiveBox Release Bot d44422fccb Bump for abx-dl 1.12.325 2026-09-20 05:57:58 +00:00
ArchiveBox Release Bot 9c7a3f1f16 Bump release version to 0.9.35rc531 2026-09-20 05:29:59 +00:00
ArchiveBox Release Bot 52328ad841 Bump for abx-dl 1.12.324 2026-09-20 05:29:42 +00:00
ArchiveBox Release Bot 92008db9c4 Bump release version to 0.9.35rc530 2026-09-20 05:14:07 +00:00
Nick Sweeting e2d3b74dcd Keep global runner recovery off historical archive payload storage 2026-09-19 22:13:47 -07:00
Nick Sweeting 0b74322ea3 Require full CI before releases and fix Docker cache permissions 2026-09-19 22:13:03 -07:00
Nick Sweeting 4d888e94d0 Add opt-in resumable filesystem rescan with portable snapshot identities 2026-09-19 22:11:11 -07:00
ArchiveBox Release Bot 43c6dc2ea7 Bump release version to 0.9.35rc529 2026-09-19 06:49:37 +00:00
Nick Sweeting eeb057433a Restore release checks for documentation and setup visibility guidance 2026-09-18 23:49:04 -07:00
ArchiveBox Release Bot f31727e157 Bump release version to 0.9.35rc528 2026-09-19 06:48:12 +00:00
ArchiveBox Release Bot 03c7074f4e Bump for abx-dl 1.12.323 2026-09-19 06:47:51 +00:00
ArchiveBox Release Bot 845822f7ae Bump release version to 0.9.35rc527 2026-09-19 06:40:47 +00:00
Nick Sweeting 592c79a52e Resolve Bash for Docker release candidate port validation 2026-09-18 23:40:22 -07:00
ArchiveBox Release Bot d0e01fe00f Bump release version to 0.9.35rc526 2026-09-19 06:12:07 +00:00
Nick Sweeting e90aa46d73 Migrate ArchiveBox websites and links to custom domains 2026-09-18 23:10:56 -07:00
ArchiveBox Release Bot deddb4d415 Bump release version to 0.9.35rc525 2026-09-19 06:02:20 +00:00
Nick Sweeting 9a6adf146c Improve site social metadata and ArchiveBox icons 2026-09-18 23:01:53 -07:00
ArchiveBox Release Bot 2b568939ec Bump release version to 0.9.35rc524 2026-09-18 23:36:48 +00:00
ArchiveBox Release Bot 8b98009cb0 Bump for abx-dl 1.12.319 2026-09-18 23:36:12 +00:00
ArchiveBox Release Bot 01f2af413a Bump release version to 0.9.35rc523 2026-09-18 23:17:07 +00:00
ArchiveBox Release Bot 27d243742c Bump for abx-dl 1.12.317 2026-09-18 23:15:55 +00:00
ArchiveBox Release Bot de595545ba Bump release version to 0.9.35rc522 2026-09-18 22:57:23 +00:00
ArchiveBox Release Bot 22f0009a79 Bump for abx-dl 1.12.316 2026-09-18 22:56:12 +00:00
ArchiveBox Release Bot ab5458e22f Bump release version to 0.9.35rc521 2026-09-18 22:37:05 +00:00
Nick Sweeting 7eddcd6e3c Preserve legacy Docker port mappings alongside the new default 2026-09-18 15:36:12 -07:00
Nick Sweeting 9b2fc58e5c Use port 5797 consistently 2026-09-18 15:29:02 -07:00
ArchiveBox Release Bot e239984415 Bump release version to 0.9.35rc520 2026-09-18 22:24:39 +00:00
Nick Sweeting a7baeae476 Link snapshot list previews to snapshot details 2026-09-18 15:24:16 -07:00
ArchiveBox Release Bot b4f9633831 Bump release version to 0.9.35rc519 2026-09-18 22:15:06 +00:00
Nick Sweeting 16b76cac0d Remove redundant public index warning from setup wizard 2026-09-18 15:13:55 -07:00
ArchiveBox Release Bot 48ced005a8 Bump release version to 0.9.35rc518 2026-09-18 22:12:54 +00:00
Nick Sweeting 498d7715b2 Add PostgreSQL to homepage integration icons 2026-09-18 15:11:31 -07:00
ArchiveBox Release Bot 736a93cb92 Bump release version to 0.9.35rc517 2026-09-18 22:05:27 +00:00
Nick Sweeting 5c99cdf7c9 Clarify public and private data separation copy 2026-09-18 15:04:59 -07:00
Nick Sweeting 8da338a887 Update homepage archive file access description 2026-09-18 15:03:45 -07:00
ArchiveBox Release Bot 155097ce6f Bump release version to 0.9.35rc516 2026-09-18 22:01:57 +00:00
Nick Sweeting eb9bb069d6 Start macOS quickstart with ArchiveBox app setup wizard 2026-09-18 15:00:08 -07:00
ArchiveBox Release Bot 53523a6a1e Bump release version to 0.9.35rc515 2026-09-18 21:59:37 +00:00
Nick Sweeting ffdc16fc22 Add download icon to macOS quickstart button 2026-09-18 14:58:07 -07:00
ArchiveBox Release Bot 8d14775759 Bump release version to 0.9.35rc514 2026-09-18 21:57:17 +00:00
Nick Sweeting cd5f96c26a Make macOS server download a prominent quickstart button 2026-09-18 14:56:25 -07:00
ArchiveBox Release Bot 877e5f3104 Bump release version to 0.9.35rc513 2026-09-18 21:54:57 +00:00
Nick Sweeting d91ea053a2 Add integration logo stack and TLSNotary authenticity feature 2026-09-18 14:54:41 -07:00
ArchiveBox Release Bot f326097d33 Bump release version to 0.9.35rc512 2026-09-18 21:41:51 +00:00
ArchiveBox Release Bot efa29074f8 Bump for abx-dl 1.12.315 2026-09-18 21:41:30 +00:00
ArchiveBox Release Bot 99fcb02963 Bump release version to 0.9.35rc511 2026-09-18 21:41:16 +00:00
Nick Sweeting e4981957d4 Simplify homepage configuration copy 2026-09-18 14:40:54 -07:00
ArchiveBox Release Bot 8c83947331 Bump release version to 0.9.35rc510 2026-09-18 21:40:50 +00:00
Nick Sweeting 13f9564fa4 Show featured plugin icons beside configuration heading 2026-09-18 14:39:09 -07:00
ArchiveBox Release Bot 4d8b162099 Bump release version to 0.9.35rc509 2026-09-18 21:37:19 +00:00
Nick Sweeting b8d7669c6c Link macOS quickstart artwork to latest Server app download 2026-09-18 14:36:47 -07:00
ArchiveBox Release Bot d08b483c3b Bump release version to 0.9.35rc508 2026-09-18 21:28:21 +00:00
Nick Sweeting f3f7d564e3 Use plus signs in Docker quickstart graphics 2026-09-18 14:27:11 -07:00
Nick Sweeting 38b65fee63 Clarify homepage workflows and link wiki configuration guides 2026-09-18 14:26:44 -07:00
ArchiveBox Release Bot 6580ed01f3 Bump release version to 0.9.35rc507 2026-09-18 21:23:22 +00:00
Nick Sweeting 8cc3d78b3d Illustrate quickstart tabs and add browser extension setup 2026-09-18 14:22:11 -07:00
ArchiveBox Release Bot 1dd74f3ec8 Bump release version to 0.9.35rc506 2026-09-18 21:21:28 +00:00
Nick Sweeting bacd3d053e Link homepage integration items to usage and export guides 2026-09-18 14:20:21 -07:00
ArchiveBox Release Bot 287e792d29 Bump release version to 0.9.35rc505 2026-09-18 21:13:00 +00:00
Nick Sweeting 383adc4e35 Show TLSNotary and crawl options in audience cards 2026-09-18 14:11:21 -07:00
ArchiveBox Release Bot ba740b4568 Bump release version to 0.9.35rc504 2026-09-18 21:09:51 +00:00
Nick Sweeting e7c419ce78 Refine homepage feature headings and card order 2026-09-18 14:08:49 -07:00
Nick Sweeting 363b79e13f Verify OpenCode Home toggles between mounted chats and overview 2026-09-18 14:01:43 -07:00
ArchiveBox Release Bot 05b1c8b829 Bump release version to 0.9.35rc503 2026-09-18 21:00:45 +00:00
ArchiveBox Release Bot cf03d6c8b3 Bump for abx-dl 1.12.314 2026-09-18 20:55:40 +00:00
ArchiveBox Release Bot 6cfdbbd68b Bump release version to 0.9.35rc502 2026-09-18 20:54:41 +00:00
Nick Sweeting 2c0d5584e2 Add compact screenshot thumbnails to homepage cards 2026-09-18 13:54:21 -07:00
ArchiveBox Release Bot 288af543a6 Bump release version to 0.9.35rc501 2026-09-18 20:19:03 +00:00
ArchiveBox Release Bot afeb937db7 Bump for abx-dl 1.12.313 2026-09-18 20:18:01 +00:00
ArchiveBox Release Bot c8a699da0b Bump release version to 0.9.35rc500 2026-09-18 20:08:14 +00:00
Nick Sweeting 6e49cf279f Check OpenCode shutdown releases OAuth and API listeners 2026-09-18 13:07:33 -07:00
ArchiveBox Release Bot 631378a80d Bump release version to 0.9.35rc499 2026-09-18 20:05:50 +00:00
Nick Sweeting 1ef37a02dd Refresh cached homepage styles after mobile layout fix 2026-09-18 13:04:49 -07:00
ArchiveBox Release Bot b618e20fbf Bump release version to 0.9.35rc498 2026-09-18 20:03:23 +00:00
Nick Sweeting 6be2bd85e5 Verify fresh OpenCode drafts promote through real shell submission 2026-09-18 13:02:54 -07:00
Nick Sweeting 8149cc0320 Fix mobile homepage alignment and image overflow 2026-09-18 13:00:44 -07:00
ArchiveBox Release Bot 72b5439137 Bump release version to 0.9.35rc497 2026-09-18 19:45:10 +00:00
Nick Sweeting 8f87aa9e43 Refresh public homepage copy, visuals, and install tabs 2026-09-18 12:44:35 -07:00
ArchiveBox Release Bot e83f2c1808 Bump release version to 0.9.35rc496 2026-09-18 19:30:33 +00:00
ArchiveBox Release Bot a6d56657b2 Bump for abx-dl 1.12.312 2026-09-18 19:30:14 +00:00
ArchiveBox Release Bot 67af2f3a95 Bump release version to 0.9.35rc495 2026-09-18 19:09:45 +00:00
Nick Sweeting b47fc056e3 Install OpenCode only in ArchiveBox application image layers 2026-09-18 12:08:40 -07:00
ArchiveBox Release Bot 007ecaea80 Bump release version to 0.9.35rc494 2026-09-18 19:05:17 +00:00
Nick Sweeting d2eea2e0f1 Allow 900 MiB ArchiveBox images with bundled OpenCode 2026-09-18 12:04:25 -07:00
ArchiveBox Release Bot a88fa72b16 Bump release version to 0.9.35rc493 2026-09-18 19:01:26 +00:00
Nick Sweeting fead132b5c Verify OpenCode provider flows and proxy transports across domain modes 2026-09-18 12:01:10 -07:00
ArchiveBox Release Bot bfef7285f6 Bump release version to 0.9.35rc492 2026-09-18 18:34:44 +00:00
Nick Sweeting 35efcea657 Gate releases on bundled OpenCode and mounted browser tests 2026-09-18 11:34:26 -07:00
ArchiveBox Release Bot 9b58aa30e5 Bump release version to 0.9.35rc491 2026-09-18 18:28:14 +00:00
Nick Sweeting 38dce9b358 Wait for OpenCode draft navigation before checking session links 2026-09-18 11:27:57 -07:00
ArchiveBox Release Bot bc8daf18c0 Bump release version to 0.9.35rc490 2026-09-18 17:52:44 +00:00
ArchiveBox Release Bot 4cd5d170ea Bump for abx-dl 1.12.306 2026-09-18 17:52:28 +00:00
ArchiveBox Release Bot 4ec52a646b Bump release version to 0.9.35rc489 2026-09-18 10:55:18 +00:00
ArchiveBox Release Bot ee78c2c58b Bump for abx-dl 1.12.305 2026-09-18 10:55:03 +00:00
ArchiveBox Release Bot bdfa7f45fc Bump release version to 0.9.35rc488 2026-09-18 10:35:07 +00:00
ArchiveBox Release Bot 1ec5582527 Bump for abx-dl 1.12.304 2026-09-18 10:34:53 +00:00
ArchiveBox Release Bot 75697e4ebf Bump release version to 0.9.35rc487 2026-09-18 10:07:57 +00:00
ArchiveBox Release Bot 7a19792be8 Bump for abx-dl 1.12.302 2026-09-18 10:07:36 +00:00
ArchiveBox Release Bot 601b2db35b Bump release version to 0.9.35rc486 2026-09-18 09:30:14 +00:00
Nick Sweeting 16506ed55a Respect automatic server setup and import private host personas 2026-09-18 02:29:56 -07:00
ArchiveBox Release Bot cc218245c3 Bump release version to 0.9.35rc485 2026-09-18 09:02:15 +00:00
ArchiveBox Release Bot b625cf4958 Bump for abx-dl 1.12.301 2026-09-18 09:01:50 +00:00
ArchiveBox Release Bot 92c0f52324 Bump release version to 0.9.35rc484 2026-09-18 08:43:52 +00:00
Nick Sweeting 880ab87da2 Improve persona import layout and support Chrome Beta profiles 2026-09-18 01:43:19 -07:00
ArchiveBox Release Bot 7e34e43cf5 Bump release version to 0.9.35rc483 2026-09-18 08:31:33 +00:00
Nick Sweeting 4a1e06fda7 Prepare XDG config directory for mounted browser profiles 2026-09-18 01:31:14 -07:00
ArchiveBox Release Bot e1647d8f93 Bump release version to 0.9.35rc482 2026-09-18 08:22:03 +00:00
ArchiveBox Release Bot 0badc70c4d Bump for abx-dl 1.12.300 2026-09-18 08:21:44 +00:00
ArchiveBox Release Bot 9fbddc5b99 Bump release version to 0.9.35rc481 2026-09-18 08:02:08 +00:00
ArchiveBox Release Bot eb7e6f2ba2 Bump for abx-dl 1.12.299 2026-09-18 08:01:47 +00:00
ArchiveBox Release Bot a7eb82ffda Bump release version to 0.9.35rc480 2026-09-18 07:48:43 +00:00
ArchiveBox Release Bot d3f2509c30 Bump for abx-dl 1.12.298 2026-09-18 07:48:16 +00:00
ArchiveBox Release Bot 3e1a31755c Bump release version to 0.9.35rc479 2026-09-18 07:30:56 +00:00
ArchiveBox Release Bot 70bce92bf3 Bump for abx-dl 1.12.297 2026-09-18 07:29:28 +00:00
ArchiveBox Release Bot 80e4a1c4f8 Bump release version to 0.9.35rc478 2026-09-18 06:40:57 +00:00
Nick Sweeting ceb3c04aff Fix explicit DISPLAY handling for Docker noVNC persona setup (#1882)
An explicit `DISPLAY=novnc:0.0` was silently removed by the Docker
entrypoint whenever `xdpyinfo` failed. In the reported `0.9.35rc381`
image, `xdpyinfo` is missing, so `docker compose run --rm` clears the
display even with a healthy noVNC server and Chromium exits with
“Missing X server or $DISPLAY”.

Preserve explicitly supplied `DISPLAY`, including an empty value to opt
out. Probe only the implicit `novnc:0.0` default, retaining automatic
headless fallback when no display was configured. Current dev already
includes `x11-utils`; this change prevents probe failure from overriding
user configuration.

Document the one-off `persona create` / `persona open` Compose flow.
`persona open` resolves Chromium automatically, avoiding the obsolete
`/data/lib/env/bin/chromium` and architecture/version-specific
Playwright paths.

Validation:
- Reproduced persona creation followed by the exact Chromium X11 failure
on the published `0.9.35rc381` image with real, healthy `theasp/novnc`.
- Mounted only the patched entrypoint into that affected image: explicit
DISPLAY survived despite the missing `xdpyinfo`, and the same Chromium
command opened a window visible through noVNC.
- Built the complete branch Docker image using the Dockerfile's default
abx-dl base. Real `docker compose run --rm archivebox persona create
personal` and `persona open personal` succeeded; viewed and controlled
Chromium through noVNC, closed it normally, and reopened in another
one-off container. A bookmark created in the UI persisted across both
runs.
- Added `IMAGE=<built-image> ./bin/validate_docker_display.sh`: six real
Compose checks cover working implicit/explicit displays, unavailable
explicit display, explicit empty opt-out, and implicit/explicit behavior
with noVNC stopped. Passed on the rebuilt image; failed on the unpatched
image at the explicit unavailable-display assertion.
- `bash -n`, `git diff --check`, and pre-commit hooks passed. No mocks
or fake binaries.

Fixes #1872.


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the Docker entrypoint silently dropping an explicit `DISPLAY` when
`xdpyinfo` fails, so Chromium can connect to a healthy noVNC server in
images like `0.9.35rc381` where `xdpyinfo` is missing. The entrypoint
previously unset the display on probe failure, causing Chromium to exit
with "Missing X server or $DISPLAY"; now only the implicit `novnc:0.0`
default is probed, an explicit value (including empty to opt out) is
preserved, and headless fallback still applies when no display was
configured. Fixes #1872.

**Validation**
- Adds `bin/validate_docker_display.sh` with six real Compose checks
covering implicit/explicit displays, unavailable display, empty opt-out,
and noVNC stopped behavior.
- Documents the one-off `persona create` / `persona open` Compose flow
and that `persona open` resolves the Chromium binary automatically.

<sup>Written for commit 953f29b65e.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1882?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-18 02:39:44 -04:00
Nick Sweeting 953f29b65e Preserve explicit Docker DISPLAY for noVNC persona setup 2026-09-17 23:36:08 -07:00
ArchiveBox Release Bot 404f2efc04 Bump release version to 0.9.35rc477 2026-09-18 06:32:59 +00:00
Nick Sweeting c23389c0a8 Prioritize ArchiveBox projects and TLSNotary in shared footers 2026-09-17 23:27:22 -07:00
ArchiveBox Release Bot 7837373d81 Bump release version to 0.9.35rc476 2026-09-18 06:18:47 +00:00
Nick Sweeting 3600ac54ea docs: remove unnecessary app launch command 2026-09-17 23:17:24 -07:00
ArchiveBox Release Bot ec47877277 Bump release version to 0.9.35rc475 2026-09-18 06:14:01 +00:00
Nick Sweeting 9a33749eca docs: add native app setup and client usage to README 2026-09-17 23:12:38 -07:00
Nick Sweeting c104931341 Align Apps menu inline code and add breathing room 2026-09-17 23:12:09 -07:00
ArchiveBox Release Bot 6a245d70d9 Bump release version to 0.9.35rc474 2026-09-18 06:11:08 +00:00
Nick Sweeting 4ca3b180a8 Preserve inline spacing around abx-dl in the Apps menu 2026-09-17 23:10:11 -07:00
ArchiveBox Release Bot a520e25cbb Bump release version to 0.9.35rc473 2026-09-18 06:07:19 +00:00
ArchiveBox Release Bot 271a4c1c40 Bump for abx-dl 1.12.289 2026-09-18 06:07:01 +00:00
ArchiveBox Release Bot c935a2b801 Bump release version to 0.9.35rc472 2026-09-18 06:06:47 +00:00
Nick Sweeting d1c692f044 Use the six shared ArchiveBox Apps menu destinations 2026-09-17 23:06:20 -07:00
ArchiveBox Release Bot f6f7be2ddc Bump release version to 0.9.35rc471 2026-09-18 05:58:51 +00:00
Nick Sweeting f38bb4c2e4 Let in-flight screenshot captures finish independently of newer site pushes 2026-09-17 22:57:21 -07:00
Nick Sweeting a577ea147f Merge remote-tracking branch 'origin/dev' into dev 2026-09-17 22:56:45 -07:00
Nick Sweeting 89007da097 Keep failed capture events out of the site publishing queue 2026-09-17 22:56:12 -07:00
ArchiveBox Release Bot ea243e1bcb Bump release version to 0.9.35rc470 2026-09-18 05:48:05 +00:00
Nick Sweeting f85e8d5dc0 Publish publicsite independently of screenshot capture 2026-09-17 22:47:35 -07:00
ArchiveBox Release Bot 400de11916 Bump release version to 0.9.35rc469 2026-09-18 05:28:49 +00:00
Nick Sweeting fce31ac965 Fix native snapshot action dropdown clicks and label padding 2026-09-17 22:28:07 -07:00
Nick Sweeting 014e193e6b Point project navigation to the published DigestBox Pages site 2026-09-17 22:27:46 -07:00
ArchiveBox Release Bot 505f53c733 Bump release version to 0.9.35rc468 2026-09-18 05:26:46 +00:00
Nick Sweeting fe9b59eb63 Unify ArchiveBox website navigation and screenshot galleries 2026-09-17 22:26:24 -07:00
ArchiveBox Release Bot b097af5417 Bump release version to 0.9.35rc467 2026-09-18 04:52:02 +00:00
ArchiveBox Release Bot 27ac1cc66f Bump for abx-dl 1.12.285 2026-09-18 04:51:42 +00:00
ArchiveBox Release Bot b65151dbbe Bump release version to 0.9.35rc466 2026-09-18 04:44:50 +00:00
Nick Sweeting 5c0a065242 Remove unused helpers and simplify equivalent tests (#1880)
Remove unused Python helpers and simplify equivalent test code without
changing active CLI, REST, model, runner, or UI behavior. This is the
mechanical first part of the simplification split; #1879 contains the
architectural and behavior-sensitive remainder. **Merge this PR first.**

- Delete unused hashing helpers, JSONL file loading, legacy TOML/INI
conversion helpers, and plugin-model discovery. Caller searches found no
active consumers; retained production definitions are AST-identical to
`dev`.
- Remove redundant result aliases, share identical POST payloads, and
parameterize equivalent test pairs while retaining their cases.
- Address Cubic's reviews: parse CSV and assert the exact header, URL
values, and sorted order; qualify the TLSNotary result lookup by its
full snapshot/plugin/hook identity.
- Correct the all-plugins HTTP fixture's TLSNotary expectation: every
snapshot must produce the specific HTTPS-only refusal, exit 1, and no
captured output. Other plugin statuses, output files, crawl topology,
and concurrent execution assertions remain enforced.
- Complete real parser dependency installation before public-add tests
deliberately stop the server. This prevents interrupting virtualenv
creation while preserving the crawl restart and metadata assertions.

Based on `dev` at `1fa2d4dc9`. **550 net lines removed**, excluding
`old/` and `docs/`; neither directory changes here. Runtime routing,
models, schemas, UI, dependencies, and architecture remain in #1879.

Validation:

- All-files `uv run --no-sync prek run --all-files` passes.
- The initial equivalent cleanup passed AST checks and 85 targeted
tests. Review follow-ups additionally pass both exact CSV tests, both
affected public-add runtime tests with a fresh dependency directory and
`--no-cache --no-sources`, and the full real depth-two all-plugins
crawl.
- [Full CI at
`42ed15b73`](https://github.com/ArchiveBox/ArchiveBox/actions/runs/35300660816)
**passed**, including every required lane. The subsequent one-line
TLSNotary lookup correction passed the full real all-plugins crawl
locally (131 seconds). All Cubic review threads are resolved.
- [Fresh CI at
`50a462e78`](https://github.com/ArchiveBox/ArchiveBox/actions/runs/35304078254)
is running.
2026-09-18 00:44:25 -04:00
ArchiveBox Release Bot a908323133 Bump release version to 0.9.35rc465 2026-09-18 04:33:33 +00:00
Nick Sweeting f5d4645f41 Move project links into Apps navigation and remove redundant menu items 2026-09-17 21:32:56 -07:00
ArchiveBox Release Bot 422fc5dfef Bump release version to 0.9.35rc464 2026-09-18 04:08:52 +00:00
ArchiveBox Release Bot d8e6a56738 Bump for abx-dl 1.12.284 2026-09-18 04:04:15 +00:00
Nick Sweeting 50a462e786 test: qualify TLSNotary result lookup by plugin 2026-09-17 20:40:14 -07:00
ArchiveBox Release Bot d7f67ba5bc Bump release version to 0.9.35rc463 2026-09-18 03:28:41 +00:00
ArchiveBox Release Bot 563ca151f5 Bump for abx-dl 1.12.283 2026-09-18 03:28:26 +00:00
ArchiveBox Release Bot 9f6535006a Bump release version to 0.9.35rc462 2026-09-18 02:51:40 +00:00
Nick Sweeting 0497d4ff0c Add shared project navigation to public website 2026-09-17 19:46:11 -07:00
Nick Sweeting 42ed15b733 Verify TLS-only refusals and complete installs before restart tests 2026-09-17 19:45:50 -07:00
Nick Sweeting 15f6f7260e Assert exact CSV search rows and ordering 2026-09-17 19:27:11 -07:00
Nick Sweeting cd159cf7cf Remove unused helpers and mechanically simplify equivalent tests 2026-09-17 19:00:43 -07:00
ArchiveBox Release Bot 1fa2d4dc96 Bump release version to 0.9.35rc461 2026-09-17 23:42:11 +00:00
ArchiveBox Release Bot 00a8105b6c Bump for abx-dl 1.12.282 2026-09-17 23:41:54 +00:00
ArchiveBox Release Bot d96092d76d Bump release version to 0.9.35rc460 2026-09-17 23:27:21 +00:00
ArchiveBox Release Bot 3f54139240 Bump for abx-dl 1.12.281 2026-09-17 23:27:02 +00:00
ArchiveBox Release Bot 673a1fb4ed Bump release version to 0.9.35rc459 2026-09-17 23:16:00 +00:00
Nick Sweeting a711ae7da0 Fix trusted snapshot previews across server security modes 2026-09-17 16:15:17 -07:00
ArchiveBox Release Bot 680ef683ee Bump release version to 0.9.35rc458 2026-09-17 23:02:50 +00:00
ArchiveBox Release Bot 3350aba3f3 Bump for abx-dl 1.12.279 2026-09-17 23:02:24 +00:00
ArchiveBox Release Bot 83ed0e9a4c Bump release version to 0.9.35rc457 2026-09-17 22:36:53 +00:00
Nick Sweeting d3aa90b060 Persist TLSNotary opt-in before starting screenshot workers 2026-09-17 15:36:30 -07:00
ArchiveBox Release Bot e3a5f77981 Bump release version to 0.9.35rc456 2026-09-17 22:30:00 +00:00
Nick Sweeting 786a9d07df Report capture diagnostics when gallery verification output is missing 2026-09-17 15:29:46 -07:00
ArchiveBox Release Bot 510365a282 Bump release version to 0.9.35rc455 2026-09-17 22:21:13 +00:00
Nick Sweeting 4d12bfd650 Configure screenshot services before starting the server 2026-09-17 15:20:52 -07:00
Nick Sweeting 0f1a89b199 Preserve downloader layers and verify TLSNotary in the screenshot gallery 2026-09-17 15:20:51 -07:00
ArchiveBox Release Bot c73884e3d8 Bump release version to 0.9.35rc454 2026-09-17 22:20:36 +00:00
ArchiveBox Release Bot 06572f2e38 Bump for abx-dl 1.12.278 2026-09-17 22:20:14 +00:00
ArchiveBox Release Bot d7e9504a52 Bump release version to 0.9.35rc453 2026-09-17 21:40:09 +00:00
Nick Sweeting c09fa8ffd3 Cache collection totals outside progress requests 2026-09-17 14:39:55 -07:00
ArchiveBox Release Bot d00ffe9f3b Bump release version to 0.9.35rc452 2026-09-17 21:03:43 +00:00
Nick Sweeting 29d40addf4 Issue browser sessions from administrator API keys for native apps 2026-09-17 14:03:24 -07:00
ArchiveBox Release Bot b512553d75 Bump release version to 0.9.35rc451 2026-09-17 20:05:26 +00:00
ArchiveBox Release Bot a2f8d81b60 Bump for abx-dl 1.12.276 2026-09-17 20:05:09 +00:00
ArchiveBox Release Bot 63326f501a Bump release version to 0.9.35rc450 2026-09-17 19:41:39 +00:00
Nick Sweeting 79d864915d Bound crawl admission and fix server, search, and schedule contention 2026-09-17 12:40:48 -07:00
ArchiveBox Release Bot 8c5ea7974c Bump release version to 0.9.35rc449 2026-09-16 22:32:20 +00:00
Nick Sweeting 9a00ac6929 Fix Machine.current startup after weekly host refresh
Machine.save intentionally invalidates the process cache so config edits are visible immediately. Keep a local instance through host refresh, sanitation, and file reconciliation, then publish the completed instance to the cache. Explain why invalidation must remain and why reading the global after save can return None.

Add a real CLI regression that ages persisted machine rows, runs archivebox status, and verifies successful refresh without changing machine identities. Confirmed failing before the fix; 13 targeted tests and Ruff checks pass.
2026-09-16 15:31:50 -07:00
ArchiveBox Release Bot 5d6fc0cc3c Bump release version to 0.9.35rc448 2026-09-16 22:04:08 +00:00
ArchiveBox Release Bot 86b81452db Bump for abx-dl 1.12.274 2026-09-16 22:03:45 +00:00
ArchiveBox Release Bot 30a1ec8fa3 Bump release version to 0.9.35rc447 2026-09-16 22:00:09 +00:00
Nick Sweeting 1f3f3b6e0f Serve log and shell script files as inline plain text 2026-09-16 14:59:27 -07:00
ArchiveBox Release Bot e81802c91b Bump release version to 0.9.35rc446 2026-09-16 21:54:09 +00:00
Nick Sweeting 57240f8798 Self-heal Docker permissions and wait for Sonic search readiness (#1877)
Fixes #1876.

## Root cause

`restart: unless-stopped` repeated the startup failure; it did not
create it. The Docker entrypoint created required directories as
container root first and swallowed failures before checking them as the
application user. On NFS `root_squash` and filesystems with synthetic
ownership, root can be unable to create or change a path that the
intended non-root identity can use. That led to the reported `cannot
write to /data/logs` failure.

## What changed

- Honor Docker `PUID`/`PGID` when supplied, otherwise retain
collection-owner detection and the `911:911` fallback. ArchiveBox and
Chrome remain non-root; `PGID=0` remains supported.
- Check and create required paths as the final non-root identity first.
If a collection path is inaccessible, repair only that path's own
metadata as root and recheck. Runtime/cache trees can receive one-time
recursive repair only after their access check fails.
- Use real create/delete probes for logs, archive output, and the Chrome
profile. Writable NFS/synthetic-permission mounts avoid repeated
metadata operations.
- Never recursively scan or change `data/archive`, regardless of byte
size. The cost is driven by file count and filesystem latency, so even
measuring a 2 GiB threshold would be undesirable at startup.
- Clean up only known browser lock files at persona profile roots,
without traversing large profiles.
- Keep the shipped Compose restart policy, and test fresh
initialization, non-root execution, restarts, root-squashed NFS, and
explicit `PGID=0` with real containers.
- Wait for a newly started Sonic worker to answer on its search port
before querying. An explicit `SONIC_BINARY` (absolute path or relative
executable name) now takes precedence over a persisted Machine config
value before plugin hydration; absolute paths are pinned in Supervisor
rather than routed through a mutable `lib/env/bin/sonic` projection.
Sonic releases have incompatible on-disk KV encodings. A real CLI
regression test checks both override forms, the generated Supervisor
command, and the listener guard. Backend failures now include child
stderr and the Sonic worker log in test output.

## Verification

- `uv run pytest archivebox/tests/test_permissions.py -q` (15 passed)
- `bash -n bin/docker_entrypoint.sh bin/validate_docker_uid_gid.sh`
- Targeted pre-commit hooks for all changed files
- Real Docker checks for distinct PUID/PGID, explicit `PGID=0`, PUID 0
fallback, shallow collection repair, and unchanged nested archive
ownership
- Fresh Compose initialization and non-root writes succeeded; a restart
returned healthy
- Five warm starts on the same Docker Desktop host: previous entrypoint
1.48s, changed entrypoint 1.50s (within run-to-run noise)
- Live Sonic search matrix passed locally on Linux ARM and in native
x86_64 CI
- Real CLI regressions for absolute and relative Sonic binary overrides
over persisted config passed locally on Linux ARM
- Full CI workflow passed on the final PR commit
2026-09-16 17:51:32 -04:00
Nick Sweeting a9b5fe0911 Honor relative Sonic binary environment overrides 2026-09-16 14:23:41 -07:00
Nick Sweeting 72f011c734 Honor explicit Sonic binary over stored machine config 2026-09-16 13:47:46 -07:00
Nick Sweeting af73e93d51 Preserve Sonic listener guard for custom binary paths 2026-09-16 13:30:16 -07:00
Nick Sweeting 9b93a55fcb Keep Sonic E2E binary outside managed install tree 2026-09-16 13:25:58 -07:00
Nick Sweeting 09fb7270c5 Honor explicit Sonic binary in supervisor worker 2026-09-16 13:08:02 -07:00
Nick Sweeting b9833c962c Pin Sonic executable across search index lifecycle 2026-09-16 12:41:40 -07:00
Nick Sweeting 77ce497ad7 Include Sonic daemon logs in search test failures 2026-09-16 12:24:01 -07:00
Nick Sweeting d3275f445d Cover Sonic index writes and finish Docker review fixes 2026-09-16 12:19:52 -07:00
Nick Sweeting 8977cdde4e Bound startup cache work and surface Sonic plugin failures 2026-09-16 11:57:48 -07:00
Nick Sweeting c1e030919a Wait for Sonic listener before running search queries 2026-09-16 11:40:53 -07:00
Nick Sweeting cd5202bce0 Auto-repair Docker mount permissions safely 2026-09-15 20:07:11 -07:00
ArchiveBox Release Bot 3a32fe9404 Bump release version to 0.9.35rc445 2026-09-15 23:39:10 +00:00
Nick Sweeting f973e0a353 Honor Chrome sandbox config during persona imports 2026-09-15 16:38:47 -07:00
ArchiveBox Release Bot 7265eb0569 Bump release version to 0.9.35rc444 2026-09-15 23:21:24 +00:00
Nick Sweeting 4ea7acd3c6 Fix pre-commit violations in persona startup changes 2026-09-15 16:20:56 -07:00
ArchiveBox Release Bot deb44ea972 Bump release version to 0.9.35rc443 2026-09-15 23:16:36 +00:00
Nick Sweeting 9e5fbb1374 Refresh documentation snippet inventory 2026-09-15 16:16:16 -07:00
ArchiveBox Release Bot 6854990fdf Bump release version to 0.9.35rc442 2026-09-15 23:09:02 +00:00
Nick Sweeting 9dffbb9d6e docs: normalize release note whitespace 2026-09-15 16:07:26 -07:00
Nick Sweeting dd5566bf7a Keep snapshot content out of hook environments 2026-09-15 16:07:26 -07:00
Nick Sweeting 39424e50f9 docs: add v0.9 release notes 2026-09-15 16:07:26 -07:00
Nick Sweeting d74101d6fa Hide base URL warning on the public web alias 2026-09-15 16:07:26 -07:00
Nick Sweeting f9d82715c0 Clarify collection config ownership 2026-09-15 16:07:26 -07:00
ArchiveBox Release Bot 23966521e3 Bump release version to 0.9.35rc441 2026-09-15 23:06:55 +00:00
ArchiveBox Release Bot fb79e3f80a Bump for abx-dl 1.12.273 2026-09-15 23:06:36 +00:00
ArchiveBox Release Bot 18f69e642f Bump release version to 0.9.35rc440 2026-09-14 07:22:57 +00:00
Nick Sweeting 936bbc3d47 Open imported personas automatically in VNC and simplify profile setup 2026-09-14 00:22:34 -07:00
ArchiveBox Release Bot da9b867daf Bump release version to 0.9.35rc439 2026-09-11 21:03:28 +00:00
ArchiveBox Release Bot 3de162548a Bump for abx-dl 1.12.271 2026-09-11 21:03:04 +00:00
ArchiveBox Release Bot 0057e64320 Bump release version to 0.9.35rc438 2026-09-11 20:46:35 +00:00
Nick Sweeting 0d061392f8 Import Chromium personas with portable authenticated browser state 2026-09-11 13:45:02 -07:00
ArchiveBox Release Bot 2a33e1c46b Bump release version to 0.9.35rc437 2026-09-07 09:19:11 +00:00
ArchiveBox Release Bot 2066f6636b Bump for abx-dl 1.12.269 2026-09-07 09:18:49 +00:00
ArchiveBox Release Bot 04755292df Bump release version to 0.9.35rc436 2026-09-07 09:00:36 +00:00
ArchiveBox Release Bot d1240c1fb3 Bump for abx-dl 1.12.268 2026-09-07 09:00:03 +00:00
ArchiveBox Release Bot 558a7c75e6 Bump release version to 0.9.35rc435 2026-09-07 08:50:04 +00:00
Nick Sweeting f0d7947874 Explain why agent adapters reuse request configuration 2026-09-07 01:49:36 -07:00
ArchiveBox Release Bot ae955386dc Bump release version to 0.9.35rc434 2026-09-07 08:42:22 +00:00
Nick Sweeting 6f8e3b75a3 Reuse request configuration in optional agent adapters 2026-09-07 01:39:34 -07:00
Nick Sweeting 974cb91a22 Assert OpenCode defaults do not pin a model that can leave the catalog 2026-09-07 01:18:02 -07:00
Nick Sweeting 434a78a02d Test OpenCode private asset caching and honor isolated test package paths 2026-09-07 01:03:33 -07:00
ArchiveBox Release Bot 603bc01fa8 Bump release version to 0.9.35rc433 2026-09-04 02:11:33 +00:00
Nick Sweeting a59742940b Test immediate OpenCode events and real terminal ticket handshakes (#1871)
* Stop initializing Git repositories for OpenCode sessions

* Isolate optional AI failures behind a lazy plugin adapter

* Keep explicit plugin enable flags boolean after config resolution

* Use Django login redirect encoding at the optional agent boundary

* Cover cold-start failures and real browser storage isolation

* Verify persisted dismissal and native storage denial variants

* Keep headless browser interaction independent of display timing

* Install optional OpenCode clients through the plugin extra

* Authenticate and route optional OpenCode WebSockets with real browser coverage

* Apply control-plane policy and proxy authentication to agent sockets

* Require immediate delivery of the first real OpenCode event

* Exercise browser same-origin headers in event regression

* Exercise OpenCode terminal tickets in proxy and browser regressions

* Preserve primary terminal failure while verifying cleanup
2026-09-03 19:09:25 -07:00
ArchiveBox Release Bot 5989ddfa41 Bump release version to 0.9.35rc432 2026-09-04 02:03:07 +00:00
ArchiveBox Release Bot 544f67f16c Bump for abx-dl 1.12.267 2026-09-04 02:02:29 +00:00
ArchiveBox Release Bot 7259c8cd06 Bump release version to 0.9.35rc431 2026-09-04 01:24:34 +00:00
ArchiveBox Release Bot 042f725ef2 Bump for abx-dl 1.12.266 2026-09-04 01:24:20 +00:00
ArchiveBox Release Bot 5b3bd229c8 Bump release version to 0.9.35rc430 2026-09-04 00:35:01 +00:00
Nick Sweeting fb66ca15d6 Route authenticated OpenCode WebSockets and verify mounted browser navigation (#1870)
* Stop initializing Git repositories for OpenCode sessions

* Isolate optional AI failures behind a lazy plugin adapter

* Keep explicit plugin enable flags boolean after config resolution

* Use Django login redirect encoding at the optional agent boundary

* Cover cold-start failures and real browser storage isolation

* Verify persisted dismissal and native storage denial variants

* Keep headless browser interaction independent of display timing

* Install optional OpenCode clients through the plugin extra

* Authenticate and route optional OpenCode WebSockets with real browser coverage

* Apply control-plane policy and proxy authentication to agent sockets
2026-09-03 17:33:32 -07:00
ArchiveBox Release Bot 95441477cf Bump release version to 0.9.35rc429 2026-09-04 00:25:10 +00:00
ArchiveBox Release Bot b142c2e62d Bump for abx-dl 1.12.265 2026-09-04 00:24:56 +00:00
ArchiveBox Release Bot a85fe33f27 Bump release version to 0.9.35rc428 2026-09-03 23:07:41 +00:00
Nick Sweeting d044f5ee0e Isolate OpenCode in its plugin and stop forced Git initialization (#1869)
* Stop initializing Git repositories for OpenCode sessions

* Isolate optional AI failures behind a lazy plugin adapter

* Keep explicit plugin enable flags boolean after config resolution

* Use Django login redirect encoding at the optional agent boundary

* Cover cold-start failures and real browser storage isolation

* Verify persisted dismissal and native storage denial variants

* Keep headless browser interaction independent of display timing

* Install optional OpenCode clients through the plugin extra
2026-09-03 16:05:30 -07:00
ArchiveBox Release Bot b960bfb93f Bump release version to 0.9.35rc427 2026-09-03 22:52:16 +00:00
ArchiveBox Release Bot 5b9c9f9b8a Bump for abx-dl 1.12.264 2026-09-03 22:51:54 +00:00
ArchiveBox Release Bot 775b7f4575 Bump release version to 0.9.35rc426 2026-09-03 20:07:29 +00:00
ArchiveBox Release Bot cfd71387f0 Bump for abx-dl 1.12.263 2026-09-03 20:07:12 +00:00
ArchiveBox Release Bot eb1822ea55 Bump release version to 0.9.35rc425 2026-09-03 19:29:04 +00:00
ArchiveBox Release Bot 968de79f60 Bump for abx-dl 1.12.262 2026-09-03 19:28:42 +00:00
ArchiveBox Release Bot 2ad4fb5eb4 Bump release version to 0.9.35rc424 2026-09-03 18:53:31 +00:00
Nick Sweeting 4ae076e0fb Disable OpenCode filesystem snapshots (#1868)
OpenCode's own snapshot tracker should not `git add` an ArchiveBox
collection. On a real `/data` project it traversed archived nested
repositories, failed on their Git state, and left the agent request busy
after the model had replied.

Generate fresh ArchiveBox-owned OpenCode config with `snapshot: false`.
Existing OpenCode config remains entirely user-owned and untouched.

Verification:
- real config/server tests pass
- DigestBox `/data` smoke selected `opencode/big-pickle`, replied
correctly, exited cleanly in 79s, and logged no snapshot
traversal/failure.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
OpenCode's snapshot tracker was `git add`-ing the ArchiveBox collection,
failing on nested archived repos and leaving agent requests busy after
the model replied. Disables filesystem snapshots for generated OpenCode
configs by setting `snapshot: false`, and updates tests to assert the
new config. Existing user-owned configs are left untouched.

<sup>Written for commit e7ff7cdad2.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1868?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-03 11:53:00 -07:00
Nick Sweeting e7ff7cdad2 Disable OpenCode filesystem snapshots 2026-09-03 11:50:09 -07:00
ArchiveBox Release Bot cde3542961 Bump release version to 0.9.35rc423 2026-09-03 18:22:59 +00:00
Nick Sweeting c28d470f9a Handle existing OpenCode JSONC defaults (#1867)
Follow-up to #1866 addressing both Cubic findings before release:

- recognize schema-only OpenCode JSONC with comments and trailing commas
- exercise the intended preservation branch with valid JSON
- also verify administrator-selected JSONC remains byte-for-byte
unchanged

Verification: focused config tests pass (5 passed).

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes detection of schema-only OpenCode JSONC configs so comments,
trailing commas, and escaped keys no longer prevent the default model
from being added.

- Replaces strict JSON parsing with a JSONC-aware parser that strips
comments and trailing commas before checking whether a config only sets
`$schema`.
- Writes the default config as JSONC text instead of JSON.
- Expands tests to cover plain JSON, JSONC with comments and trailing
commas, and escaped schema keys.

<sup>Written for commit c1358a2726298e20db1a8697647338baa21e555f.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1867?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-03 11:22:33 -07:00
Nick Sweeting 27c2a947b4 Handle existing OpenCode JSONC defaults 2026-09-03 11:22:07 -07:00
ArchiveBox Release Bot 955f30ae0d Bump release version to 0.9.35rc422 2026-09-03 18:09:11 +00:00
Nick Sweeting 0b08388e97 Fix OpenCode defaults and false health failures (#1866)
Fixes the DigestBox agent path end to end:

- use the ArchiveBox collection root as the default OpenCode workdir
- configure OpenCode's free `opencode/big-pickle` model for
fresh/schema-only state while preserving administrator config
- remove the redundant two-second version gate and three-second browser
health poll that falsely hid a working server and generated continuous
traffic
- retire the private health endpoint and its tests

Verification: `uv run pytest archivebox/tests/test_opencode_agent.py -q`
(20 passed)

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes OpenCode agent startup so fresh installs get working defaults and
a healthy server no longer appears unavailable.

- Uses the ArchiveBox collection root as the default OpenCode workdir
instead of an unused subdirectory.
- Writes the `opencode/big-pickle` default model only when
`opencode.jsonc` is missing or contains only `$schema`; existing admin
config is left untouched.
- Removes the two-second version gate and three-second browser health
poll that falsely marked healthy servers unavailable and generated
continuous traffic.
- Deletes the private `/_archivebox/health` endpoint and its tests.

<sup>Written for commit 9a3f058f73.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1866?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-03 11:07:37 -07:00
Nick Sweeting 9a3f058f73 Fix OpenCode defaults and false health failures 2026-09-03 10:56:43 -07:00
ArchiveBox Release Bot c2566e9b4a Bump release version to 0.9.35rc421 2026-09-03 16:36:51 +00:00
Nick Sweeting 9b1f155507 Remove duplicate issue detection workflow (#1865)
Removes the issue-opened duplicate detection workflow entirely. It is
not part of product CI and currently depends on separately funded
Anthropic API credits. No replacement, fallback, or failure-suppression
path is added.\n\nValidation: `git diff --check`; `uv run prek run
--all-files`.
2026-09-03 09:36:38 -07:00
Nick Sweeting 747f4a07ac Remove duplicate issue detection workflow 2026-09-03 09:36:12 -07:00
ArchiveBox Release Bot ca55f7f8d6 Bump release version to 0.9.35rc420 2026-09-03 08:18:57 +00:00
Nick Sweeting 79fc5c34d2 Trust ready OpenCode processes (#1862)
The rc419 DigestBox test showed that health-based replacement still
killed OpenCode during legitimate initial UI load: health became
temporarily unavailable for more than five seconds while the child was
busy, causing repeated process replacement and 502s.

This removes the health-latency heuristic entirely. ArchiveBox now
distinguishes ready from merely running, trusts a ready live owned PID,
serializes cold starts until readiness, and only replaces children that
exited or never became ready. Stale tabs still recover after an
ArchiveBox/container restart because process state resets and the
detached health wake starts a new child.

This is also a net cleanup: 38 lines removed from the prior lifecycle
implementation.

Verification:
- `uv run pytest archivebox/tests/test_opencode_agent.py -q` (19 passed)
- focused real startup/readiness/proxy/shutdown tests (5 passed)
- `uv run prek run --all-files`
- independent gpt-5.6-medium lifecycle review clean

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Removes the health-latency heuristic that replaced OpenCode processes
during legitimate initial UI loads, causing 502s. Ready owned processes
are now trusted without probing, and startup health checks are bounded
so they can't run past the startup deadline.

- Only replaces children that exited or never became ready.
- Cold starts are serialized until readiness; stale tabs still recover
after a restart.

<sup>Written for commit 57e7820a0b.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1862?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-03 01:18:37 -07:00
Nick Sweeting 57e7820a0b Bound OpenCode startup health probes 2026-09-03 01:13:40 -07:00
Nick Sweeting 57ee874d6e Trust ready OpenCode processes 2026-09-03 00:59:38 -07:00
ArchiveBox Release Bot f5234360d7 Bump release version to 0.9.35rc419 2026-09-03 07:24:54 +00:00
Nick Sweeting e5ac2709f3 Avoid restarting OpenCode on transient health misses (#1861)
The rc418 live DigestBox restart test exposed OpenCode process flapping
under iframe load: a single 2-second health timeout caused
`_ensure_opencode` to kill an otherwise live owned process, after which
the reloading iframe repeated the cycle.

This gives an owned process a bounded 5-second health recovery window
before replacing it. A real SIGSTOP/SIGCONT test proves that a
transiently unavailable process keeps the same PID, while the existing
real unhealthy-process test still proves eventual replacement.

Verification:
- `uv run pytest archivebox/tests/test_opencode_agent.py -q` (18 passed)
- `uv run prek run --all-files`

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Stops OpenCode from restarting on a single 2-second health timeout,
which caused process flapping under iframe load. An owned process now
gets a bounded 5-second health recovery window before being replaced, so
a transiently unavailable process keeps the same PID. Proxy and
event-stream requests no longer block on the recovery check when the
process is already running.

- Adds a SIGSTOP/SIGCONT test proving a transiently stopped process
keeps its PID, while the existing unhealthy-process test still verifies
eventual replacement.
- Adds tests proving proxy requests skip the recovery check while the
process is running and wait for readiness when it is not.

<sup>Written for commit 650697f3c2.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1861?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-03 00:23:27 -07:00
Nick Sweeting 650697f3c2 Make OpenCode readiness checks race-safe 2026-09-03 00:17:53 -07:00
Nick Sweeting 91aa70cc73 Track OpenCode readiness separately from liveness 2026-09-03 00:14:34 -07:00
Nick Sweeting 8a12e3399c Honor the OpenCode recovery deadline 2026-09-02 23:57:59 -07:00
Nick Sweeting 944907bfee Keep OpenCode proxy traffic out of recovery 2026-09-02 23:56:46 -07:00
Nick Sweeting 7f1767a2db Avoid restarting OpenCode on transient health misses 2026-09-02 23:49:58 -07:00
ArchiveBox Release Bot 3327edba24 Bump release version to 0.9.35rc418 2026-09-03 06:24:45 +00:00
Nick Sweeting a71ef00b79 Reload stale OpenCode frames after server recovery (#1860)
## Summary

- monitor the proxied OpenCode health endpoint from the stable
ArchiveBox parent page
- reload the iframe once after an unavailable -> healthy transition
- trigger an immediate check again when the browser comes online or the
tab becomes visible

## Why

A real RC416 -> RC417 DigestBox restart left the already-open OpenCode
1.17.14 SPA showing its fatal error page against the newly started
OpenCode 1.17.15 server. A full page reload recovered immediately. The
parent ArchiveBox document survives the container restart, so it can
perform that one iframe reload automatically after the server is healthy
again.

## Verification

- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor
- focused pre-commit hooks pass
- real stale-tab restart reproduction will be repeated on DigestBox
after release

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Reloads stale OpenCode iframes after the ArchiveBox server recovers from
a restart, so the previously fatal error page is replaced automatically.
The parent page polls the OpenCode health endpoint every 3 seconds,
reloading the iframe once when the server transitions from unavailable
to healthy or when the server version changes, and also re-checks
immediately when the browser comes online or the tab becomes visible.

- Health monitoring uses a separate endpoint that reports status without
starting the server; recovery restarts a running but unhealthy OpenCode
process, and polling skips while hidden when the server is down.

**Verification**
- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor.
- Focused pre-commit hooks pass.

<sup>Written for commit a33b333b6a.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1860?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 23:23:56 -07:00
Nick Sweeting a33b333b6a Keep OpenCode recovery nonblocking 2026-09-02 23:19:30 -07:00
Nick Sweeting 00e867d949 Separate OpenCode health monitoring from recovery 2026-09-02 23:02:59 -07:00
Nick Sweeting 69ca67b37d Make OpenCode recovery detect server changes 2026-09-02 22:47:15 -07:00
Nick Sweeting 4149b6f578 Reload stale OpenCode frames after server recovery 2026-09-02 22:37:53 -07:00
ArchiveBox Release Bot 2d23264bd6 Bump release version to 0.9.35rc417 2026-09-03 05:27:30 +00:00
Nick Sweeting e9feb8162a Keep OpenCode SSE reconnects off blocked ASGI request threads (#1859)
## Summary

- return OpenCode SSE headers before waiting for a stopped server to
restart
- run reconnect-driven OpenCode startup in a non-thread-sensitive worker
inside the async stream
- remove the obsolete global `ThreadSensitiveContext` monkeypatch now
that asgiref 3.12.1 implements non-blocking shutdown upstream
- add a real ASGI regression test that holds the real startup lock and
verifies headers arrive before restart can finish

## Why

After deploying RC416, a stale `/admin/agent` tab successfully recovered
across the container restart, but Daphne logged three
`CurrentThreadExecutor already quit or is broken` exceptions while early
EventSource reconnects were cancelled during synchronous OpenCode
startup. Normal proxy traffic subsequently returned 200 and no data was
lost, but the request cleanup path was not clean.

## Verification

- `uv run pytest archivebox/tests/test_opencode_agent.py -q` — 15 passed
- focused SSE tests after final refactor — 2 passed
- `uv run prek run --all-files` — passed
- no OpenCode server remained after fixture teardown

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Makes OpenCode SSE reconnects return headers before the stopped server
finishes restarting, so early EventSource reconnects no longer cancel
while startup runs on a blocked ASGI request thread. This removes the
`CurrentThreadExecutor already quit or is broken` exceptions Daphne
logged after container restarts.

- Runs reconnect-driven OpenCode startup in a non-thread-sensitive
worker inside the async stream instead of blocking the request thread.
- Surfaces OpenCode startup failures as an error event on the SSE stream
instead of returning an error response.
- Removes the obsolete global `ThreadSensitiveContext` monkeypatch from
`archivebox/core/asgi.py`; asgiref 3.12.1 handles non-blocking shutdown
upstream.
- Adds an ASGI regression test that holds the real startup lock,
verifies headers arrive before restart can finish, avoids starting a
background server, and joins the startup worker before teardown.

<sup>Written for commit 7d7e219962.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1859?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 22:26:39 -07:00
Nick Sweeting 7d7e219962 Join the SSE startup worker before test teardown 2026-09-02 22:21:52 -07:00
Nick Sweeting 24470ca551 Surface OpenCode startup failures on the event stream 2026-09-02 22:11:40 -07:00
Nick Sweeting ff2766c007 Keep SSE disconnect test from starting a background server 2026-09-02 22:10:42 -07:00
Nick Sweeting 75cf3f1261 Keep OpenCode SSE startup off ASGI request threads 2026-09-02 22:02:03 -07:00
ArchiveBox Release Bot b44ab723d2 Bump release version to 0.9.35rc416 2026-09-03 04:33:09 +00:00
Nick Sweeting ce529842e3 Stop the full OpenCode process group (#1858)
* Stop the full OpenCode process group

* Fall back when process-group signaling fails

* Guarantee fallback test process cleanup
2026-09-02 21:31:03 -07:00
ArchiveBox Release Bot 378133e44c Bump release version to 0.9.35rc415 2026-09-03 03:43:52 +00:00
Nick Sweeting 3954119a0e Keep embedded OpenCode proxy resilient (#1857)
* Keep embedded OpenCode proxy resilient

* Serialize OpenCode startup readiness

* Re-run review after dependency rebase
2026-09-02 20:43:21 -07:00
ArchiveBox Release Bot 1e53928ed7 Bump release version to 0.9.35rc414 2026-09-03 03:39:41 +00:00
ArchiveBox Release Bot 6241102ea7 Bump for abx-dl 1.12.261 2026-09-03 03:38:39 +00:00
ArchiveBox Release Bot 9a00c2f0b4 Bump release version to 0.9.35rc413 2026-09-03 02:49:30 +00:00
Nick Sweeting 4a31c409ca Avoid OpenCode proxy startup stalls (#1856)
* Avoid OpenCode proxy startup stalls

* Handle concurrent OpenCode startup and read failures

* Hide OpenCode upstream errors from clients
2026-09-02 19:49:01 -07:00
ArchiveBox Release Bot 72f6c70ffa Bump for abx-dl 1.12.260 2026-09-03 02:47:35 +00:00
ArchiveBox Release Bot 08f8baabbd Bump release version to 0.9.35rc412 2026-09-03 01:46:21 +00:00
Nick Sweeting 31bdeb4534 Allow same-origin OpenCode agent framing (#1855)
* Allow same-origin OpenCode agent framing

* Reuse OpenCode proxy prefix for framing policy
2026-09-02 18:45:51 -07:00
ArchiveBox Release Bot e7ab7b087b Bump release version to 0.9.35rc411 2026-09-03 00:25:50 +00:00
Nick Sweeting 480ad08cf0 Fix snapshot backfill orchestration regressions (#1854)
* fix snapshot backfill orchestration regressions

* address snapshot orchestration review

* fix admin process projection fixture

* fix noresult projection fixture
2026-09-02 17:25:31 -07:00
ArchiveBox Release Bot b799742f51 Bump release version to 0.9.35rc410 2026-09-02 22:19:25 +00:00
ArchiveBox Release Bot 5c34a68ad8 Bump for abx-dl 1.12.259 2026-09-02 22:19:08 +00:00
ArchiveBox Release Bot d6106a456f Bump release version to 0.9.35rc409 2026-09-02 22:07:06 +00:00
Nick Sweeting ebaeb9ea3f refactor: simplify snapshot maintenance orchestration (#1853)
## Summary

- remove archivebox update and internal pseudo crawl URL paths
- parse submitted documents directly through abx-dl and project
discovered snapshots at depth zero
- keep ArchiveBox ownership at Crawl to Snapshot while abx-dl owns
Snapshot to ArchiveResult execution
- replace queued ArchiveResult search backfills with indexed
missing-result discovery and snapshot-scoped execution that leaves
sealed snapshots sealed
- make update schedules direct maintenance dispatches instead of
synthetic crawls
- keep filesystem migrations explicit, resumable, keyset-batched, and
indexed by fs_version
- preserve live Process and ArchiveResult projection for the progress UI

## Related PRs

- ArchiveBox/abx-dl#21 provides direct input parsing and explicit
event-bus orchestration
- ArchiveBox/abx-plugins#60 removes the obsolete pseudo URL and Sonic
consolidation branches

## Verification

- abx-dl full suite: 167 passed
- ArchiveBox runner and ArchiveResult projection suite: 89 passed
- add, API, and UI input coverage: 78 passed
- schedule suite: 9 passed
- filesystem migration coverage: 9 passed
- live progress coverage: 16 passed
- real https://sweeting.me abx-dl run: 80.74 seconds, 34 succeeded, 11
noresults, 0 failed, no surviving processes
- commit hooks and diff checks passed

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Simplifies snapshot orchestration by having `abx-dl` parse crawl inputs
and run snapshot hooks directly, replacing internal pseudo-URLs and
pre-created `ArchiveResult` queues. Scheduled maintenance now dispatches
directly, while concurrent snapshot completion preserves retry work
owned by the active run.

- Store submitted URLs and imported documents directly on `Crawl`;
remove pseudo-URL and input-root snapshots, and reject multiline URL
items.
- Keep Crawl-to-Snapshot ownership in ArchiveBox while `abx-dl` owns
Snapshot-to-ArchiveResult execution.
- Discover missing indexed results and dispatch snapshot-scoped
maintenance for sealed snapshots without changing their lifecycle.
- Preserve unbounded maintenance pagination and run filesystem
migrations explicitly in resumable keyset batches using indexed
`fs_version`.
- Bound retry scheduling and correlate completion leases so stale or
concurrent completions cannot consume newer retry work.
- Preserve live `Process` and `ArchiveResult` projections for progress
reporting.

**Migration**

- Apply the database migration before rollout; it removes `current_step`
and adds the `fs_version` index.

<sup>Written for commit a29dd2edbc.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1853?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 15:04:47 -07:00
Nick Sweeting 0a9d8fd525 Merge remote-tracking branch 'origin/dev' into refactor/simplify-snapshot-orchestration
# Conflicts:
#	archivebox/services/runner.py
2026-09-02 15:04:37 -07:00
Nick Sweeting a29dd2edbc test: exercise keyset maintenance pagination 2026-09-02 14:51:37 -07:00
Nick Sweeting ec4b8ef2ef fix: preserve unbounded maintenance pagination 2026-09-02 14:43:13 -07:00
Nick Sweeting e878007b6a fix: bound snapshot scheduling ownership 2026-09-02 14:35:57 -07:00
ArchiveBox Release Bot ae9399b5fd Bump release version to 0.9.35rc408 2026-09-02 21:26:05 +00:00
Nick Sweeting 2e0c0f14e9 Keep UUID migration check migration-only 2026-09-02 14:25:51 -07:00
Nick Sweeting 35e014ae90 fix: correlate snapshot completion leases 2026-09-02 14:18:00 -07:00
ArchiveBox Release Bot baa3e37c59 Bump release version to 0.9.35rc407 2026-09-02 21:09:04 +00:00
Nick Sweeting b7b4f34090 Keep CI dependency roots isolated 2026-09-02 14:06:15 -07:00
ArchiveBox Release Bot ec1d3381b3 Bump release version to 0.9.35rc406 2026-09-02 21:06:05 +00:00
ArchiveBox Release Bot 410d5db1bf Bump for abx-dl 1.12.257 2026-09-02 21:05:45 +00:00
Nick Sweeting 8bc9f40e9f fix: harden concurrent snapshot retries 2026-09-02 13:52:42 -07:00
ArchiveBox Release Bot c791eabfb0 Bump release version to 0.9.35rc405 2026-09-02 20:39:29 +00:00
Nick Sweeting 1a5c6686c2 Provide build headers to LDAP docs example 2026-09-02 13:39:08 -07:00
Nick Sweeting a85d417d0f fix: preserve queued snapshot retry work 2026-09-02 13:33:21 -07:00
ArchiveBox Release Bot 68f5caaa29 Bump release version to 0.9.35rc404 2026-09-02 20:27:46 +00:00
ArchiveBox Release Bot c3b99ceece Bump for abx-dl 1.12.256 2026-09-02 20:27:27 +00:00
ArchiveBox Release Bot ac7598aae2 Bump release version to 0.9.35rc403 2026-09-02 19:56:48 +00:00
Nick Sweeting 9ee0dcf165 fix: address orchestration review findings 2026-09-02 12:56:23 -07:00
ArchiveBox Release Bot 1c3b4bc5ce Bump for abx-dl 1.12.254 2026-09-02 19:56:13 +00:00
ArchiveBox Release Bot 344dc86d83 Bump release version to 0.9.35rc402 2026-09-02 19:43:41 +00:00
Nick Sweeting 718beb3020 Limit docs build dependencies to setup examples 2026-09-02 12:43:17 -07:00
ArchiveBox Release Bot 27efbe6bb0 Bump release version to 0.9.35rc401 2026-09-02 19:31:11 +00:00
ArchiveBox Release Bot 94a9a4d444 Bump for abx-dl 1.12.252 2026-09-02 19:30:09 +00:00
ArchiveBox Release Bot 970c1adedf Bump release version to 0.9.35rc400 2026-09-02 19:28:37 +00:00
Nick Sweeting 40e6ded0f7 Scope machine service test to liteparse 2026-09-02 12:27:56 -07:00
ArchiveBox Release Bot 3416a1f707 Bump release version to 0.9.35rc399 2026-09-02 19:21:59 +00:00
Nick Sweeting 30998a0d16 Stop installing build dependencies in docs jobs 2026-09-02 12:18:30 -07:00
Nick Sweeting 809bd938a3 Preflight plugin dependencies before crawl hooks 2026-09-02 12:18:06 -07:00
Nick Sweeting 2bba758314 refactor: simplify snapshot maintenance orchestration 2026-09-02 12:05:09 -07:00
ArchiveBox Release Bot 0a8f15e130 Bump release version to 0.9.35rc398 2026-09-02 15:51:53 +00:00
ArchiveBox Release Bot cb0155f88a Bump for abx-dl 1.12.251 2026-09-02 15:51:31 +00:00
ArchiveBox Release Bot eeb47938c4 Bump release version to 0.9.35rc397 2026-09-02 15:26:39 +00:00
ArchiveBox Release Bot 1769f8b6e3 Bump for abx-dl 1.12.250 2026-09-02 15:26:21 +00:00
ArchiveBox Release Bot 71c410e791 Bump release version to 0.9.35rc396 2026-09-02 14:22:17 +00:00
ArchiveBox Release Bot 2b6f379453 Bump for abx-dl 1.12.249 2026-09-02 14:21:55 +00:00
ArchiveBox Release Bot 9e9a04bec8 Bump release version to 0.9.35rc395 2026-09-02 14:13:04 +00:00
ArchiveBox Release Bot f45f7e33a6 Bump for abx-dl 1.12.248 2026-09-02 14:12:40 +00:00
ArchiveBox Release Bot 8c0cb7499f Bump release version to 0.9.35rc394 2026-09-02 13:51:11 +00:00
ArchiveBox Release Bot bd7775e4f4 Bump for abx-dl 1.12.246 2026-09-02 13:50:49 +00:00
ArchiveBox Release Bot 8dbec73ee6 Bump release version to 0.9.35rc393 2026-09-02 13:38:01 +00:00
ArchiveBox Release Bot 8702aa33cc Bump for abx-dl 1.12.245 2026-09-02 13:37:34 +00:00
ArchiveBox Release Bot 11df983090 Bump release version to 0.9.35rc392 2026-09-02 13:15:51 +00:00
ArchiveBox Release Bot 8ed30dddb0 Bump for abx-dl 1.12.244 2026-09-02 13:15:29 +00:00
ArchiveBox Release Bot 045d7fd886 Bump release version to 0.9.35rc391 2026-09-02 12:59:18 +00:00
ArchiveBox Release Bot b4421b8f92 Bump for abx-dl 1.12.243 2026-09-02 12:58:39 +00:00
ArchiveBox Release Bot 1594184239 Bump release version to 0.9.35rc390 2026-09-02 12:34:31 +00:00
ArchiveBox Release Bot 79b958e482 Bump for abx-dl 1.12.242 2026-09-02 12:34:16 +00:00
ArchiveBox Release Bot a512884e9e Bump release version to 0.9.35rc389 2026-09-02 12:24:12 +00:00
Nick Sweeting 663017924b refactor: consume canonical abx-dl execution plans 2026-09-02 05:23:11 -07:00
ArchiveBox Release Bot b27f75647c Bump release version to 0.9.35rc388 2026-09-02 12:20:19 +00:00
ArchiveBox Release Bot 6d7d069cc0 Bump for abx-dl 1.12.241 2026-09-02 12:19:42 +00:00
ArchiveBox Release Bot f4aa4d31c5 Bump release version to 0.9.35rc387 2026-09-02 12:08:57 +00:00
Nick Sweeting 8ed80ded07 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 05:08:07 -07:00
Nick Sweeting f3a8c6945d Clarify targeted plugin maintenance contract 2026-09-02 04:59:40 -07:00
Nick Sweeting 07975eccc7 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:59:27 -07:00
ArchiveBox Release Bot 1af2e8f544 Bump release version to 0.9.35rc386 2026-09-02 11:59:24 +00:00
ArchiveBox Release Bot d6b1b269ca Bump for abx-dl 1.12.240 2026-09-02 11:59:08 +00:00
Nick Sweeting e7b3f1c485 Adopt canonical abx-dl execution plan API 2026-09-02 04:44:55 -07:00
Nick Sweeting 23b91d6840 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:34:29 -07:00
Nick Sweeting 95a7e64dd7 Centralize queued plugin execution 2026-09-02 04:34:22 -07:00
ArchiveBox Release Bot 071bc0cce6 Bump release version to 0.9.35rc385 2026-09-02 11:14:34 +00:00
Nick Sweeting 3e58261cd5 Expose yt-dlp media metadata for snapshot cards (#1819)
Supersedes #1818.

This keeps yt-dlp-specific player rendering out of ArchiveBox core. Core
now:

- exposes media type metadata to plugin card templates: `is_video`,
`is_audio`, `is_browser_playable`
- sorts yt-dlp media with browser-playable video/audio first
- prefers browser-playable yt-dlp outputs before thumbnails and
non-browser containers when choosing the primary card output
- avoids pinning resumed snapshots to stale selected hook maps, fixing a
pause/resume runner regression surfaced by CI
- makes `update --index-only` discover the configured search backend
even when `PLUGINS` is restricted for test/runtime isolation
- stabilizes CI-only recursive/takeover/public-UI tests that were
failing due external/heavy plugin coverage, hard-kill recovery races,
and transient full-server `/add/` startup responses

The actual click-to-load player UI lives in the companion plugin PR:
ArchiveBox/abx-plugins#34.

Verification:
```text
pytest archivebox/tests/test_ui_admin_snapshot.py -q
48 passed

pytest archivebox/tests/test_cli_update_reindex_snapshots.py -q
9 passed

pytest -xvs archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py --basetemp=tests/out --ignore=archivebox/pkgs
7 passed

pytest -xvs archivebox/tests/test_takeover_util.py --basetemp=tests/out --ignore=archivebox/pkgs
19 passed

pytest -xvs archivebox/tests/test_takeover_util.py::test_live_add_update_jobs_survive_server_and_cli_owner_exits --basetemp=tests/out --ignore=archivebox/pkgs
1 passed

pytest -xvs archivebox/tests/test_ui_public_snapshot.py --basetemp=tests/out --ignore=archivebox/pkgs
6 passed

pytest -q archivebox/tests/test_recursive_crawl.py::test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel --basetemp=tests/out --ignore=archivebox/pkgs
1 passed
```
2026-09-02 04:14:15 -07:00
ArchiveBox Release Bot 0f1a402940 Bump release version to 0.9.35rc384 2026-09-02 11:05:26 +00:00
Nick Sweeting 5bead960b3 Merge remote-tracking branch 'origin/dev' into dev 2026-09-02 04:04:59 -07:00
Nick Sweeting d5ff47b7e6 Refresh documentation snippet inventory 2026-09-02 04:04:45 -07:00
ArchiveBox Release Bot 457e616d1d Bump release version to 0.9.35rc383 2026-09-02 11:03:09 +00:00
Nick Sweeting 7f9d9ca2c1 Remove transient ArchiveResult migration blip 2026-09-02 04:02:47 -07:00
ArchiveBox Release Bot dbb9f45f53 Bump release version to 0.9.35rc382 2026-09-02 10:50:22 +00:00
Nick Sweeting ef7d590655 Eliminate request-side SQLite write transactions (#1846)
## Summary

- replace request-side `get_or_create()` / M2M transaction boundaries
with autocommit insert-conflict resolution and guarded CAS updates
- remove the ArchiveResult upload `transaction.atomic()` /
`select_for_update()` path while preserving exact `(snapshot, plugin,
hook_name)` identity and parent output-size accounting
- keep intermediate chunk uploads filesystem-only; persist ArchiveResult
metadata once the file is complete
- reuse the same insert-conflict helpers in pending-result creation,
event projection, recovery, tag projection, and admin tag editing

## Why

Browser screenshot/MHTML uploads were colliding with the runner on
SQLite. Django helper methods opened hidden read-then-write
transactions, and every chunk rewrote ArchiveResult/Snapshot rows. A 50+
MiB upload therefore repeatedly entered the SQLite writer path and could
hit an immediate WAL read-to-write upgrade failure.

The new path uses short autocommit statements. Unique constraints
resolve create races, `modified_at` guards ArchiveResult/Snapshot
updates, and a losing writer re-reads and re-merges before retrying the
CAS. Runner lifecycle locks and event serialization remain unchanged.

## Validation

- `uv run pytest archivebox/tests/test_api_v1_core_*.py -q` (38 passed)
- focused runner/service suite (27 passed)
- `uv run pytest archivebox/tests/test_ui_admin_snapshot.py -q` (57
passed)
- tag/list/admin suites (44 passed)
- `uv run prek run --all-files`

No migrations.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Eliminates request-side SQLite write transactions so large browser
uploads no longer hit WAL read-to-write upgrade failures while the
runner holds the writer lock.

- Request-side create/update paths now use autocommit statements with
unique-constraint insert-conflict resolution and `modified_at`-guarded
compare-and-swap updates.
- ArchiveResult identity is now `(snapshot, plugin, hook_name)`;
migration `0054` drops the previously published plugin-unique constraint
so a plugin can keep several sibling hooks.
- A losing CAS writer re-reads and re-merges current state before
retrying; after three attempts the request fails with HTTP 409.
- Intermediate chunk uploads write to the filesystem only; the
`ArchiveResult` row is persisted once the upload completes, keeping the
parent snapshot's output-size accounting in sync.
- `create_snapshot` no longer blocks on the crawl lifecycle lock, and
`archivebox run` recreates exact hook requests rather than re-queueing a
plugin.
- Reused the insert-conflict helpers in pending-result creation, event
projection, recovery, tag projection, and admin tag editing; admin tag
edits now attribute new tags to the acting user.

<sup>Written for commit fbff88fb0e.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1846?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-09-02 03:50:03 -07:00
Nick Sweeting 0c6f017f0a Preserve ArchiveResult hook history through migrations 2026-09-02 03:49:53 -07:00
Nick Sweeting fbff88fb0e Merge remote-tracking branch 'origin/dev' into audit-pr1846
# Conflicts:
#	archivebox/cli/archivebox_extract.py
#	archivebox/core/recovery_util.py
#	archivebox/services/archive_result_service.py
#	archivebox/services/runner.py
2026-09-01 15:43:48 -07:00
ArchiveBox Release Bot 07df9c76a8 Bump release version to 0.9.35rc381 2026-09-01 22:08:21 +00:00
Nick Sweeting 97136703f6 docs: state the collection concurrency contract 2026-09-01 15:07:58 -07:00
ArchiveBox Release Bot 2f761615f0 Bump release version to 0.9.35rc380 2026-09-01 22:07:50 +00:00
ArchiveBox Release Bot 8dc91f90be Bump for abx-dl 1.12.233 2026-09-01 22:07:20 +00:00
ArchiveBox Release Bot 2af88da0d4 Bump release version to 0.9.35rc379 2026-09-01 22:07:14 +00:00
Nick Sweeting 032c20bdf5 cleanup: delete obsolete model lifecycle helpers 2026-09-01 15:06:48 -07:00
ArchiveBox Release Bot d42b530caf Bump release version to 0.9.35rc378 2026-09-01 22:04:10 +00:00
Nick Sweeting 53c300a387 cleanup: remove active crawl heartbeat plumbing 2026-09-01 15:03:29 -07:00
ArchiveBox Release Bot 0498a87884 Bump release version to 0.9.35rc377 2026-09-01 21:57:03 +00:00
ArchiveBox Release Bot e53c9cca7a Bump for abx-dl 1.12.231 2026-09-01 21:56:45 +00:00
ArchiveBox Release Bot 743ca335ad Bump release version to 0.9.35rc376 2026-09-01 21:48:47 +00:00
Nick Sweeting 2d8de8931b fix: keep transformed previews readable at full width 2026-09-01 14:46:18 -07:00
Nick Sweeting c0e09fa22a test: require readable transformed HTML previews 2026-09-01 14:46:18 -07:00
ArchiveBox Release Bot 89bb245fac Bump release version to 0.9.35rc375 2026-09-01 21:24:57 +00:00
ArchiveBox Release Bot f94230e750 Bump for abx-dl 1.12.229 2026-09-01 21:24:35 +00:00
ArchiveBox Release Bot c5353401ea Bump release version to 0.9.35rc374 2026-09-01 20:55:59 +00:00
ArchiveBox Release Bot dfa2d84bef Bump for abx-dl 1.12.228 2026-09-01 20:55:44 +00:00
ArchiveBox Release Bot 3a55240f79 Bump release version to 0.9.35rc373 2026-09-01 20:39:34 +00:00
Nick Sweeting b0acf27b5f Validate selected snapshot preview captures 2026-09-01 13:39:11 -07:00
ArchiveBox Release Bot 9720dbe711 Bump release version to 0.9.35rc372 2026-09-01 20:11:09 +00:00
ArchiveBox Release Bot c67f0c8ec5 Bump for abx-dl 1.12.227 2026-09-01 20:10:51 +00:00
ArchiveBox Release Bot 048f09a31d Bump release version to 0.9.35rc371 2026-09-01 19:58:30 +00:00
Nick Sweeting dda6bfdfb5 Revert "Make transformed HTML previews full-width"
This reverts commit c09070c2f3.
2026-09-01 12:57:51 -07:00
ArchiveBox Release Bot e1425c0a54 Bump release version to 0.9.35rc370 2026-09-01 19:52:18 +00:00
Nick Sweeting c09070c2f3 Make transformed HTML previews full-width 2026-09-01 12:51:38 -07:00
Nick Sweeting 9c8bea8fdf Unify plugin runtime and queue lifecycle boundaries (#1850)
## Summary

This is the ArchiveBox side of a coordinated three-repository boundary
cleanup.

- delete the second python-statemachine lifecycle engine and replace it
with explicit model-owned queue transitions
- reduce workers/models.py to durable queue fields, leases, claims,
pause, resume, and guarded updates
- delete the legacy Crawl direct-hook lifecycle and duplicate
hook-record projector
- make abx-dl the sole hook subprocess runtime through PluginCatalog,
ExecutionPlan, execute_hook, and generic event services
- make the shared catalog the single source for packaged and
data-directory plugins, including admin plugin/config presentation
- make PluginConfigResolver the shared schema, alias, precedence, and
enabled-plugin resolver
- make OutputManifest the single output-files, mimetype, and size
implementation across models, APIs, recovery, template helpers, and
services
- remove python-statemachine plus the pydot and pyparsing diagram-only
dependency closure

Net ArchiveBox change: 585 insertions and 2,625 deletions.

## User impact

Existing CLI, REST, Django admin, custom plugin, queued-work,
pause/resume/cancel, and ArchiveResult surfaces are preserved. The
plugin catalog and configuration remain visible in admin; presentation
metadata now comes from plugin manifests rather than ArchiveBox
hardcoded lists.

## Layer boundary

ArchiveBox retains Django/database persistence, scheduling, projectors,
and product policy. abx-dl owns discovery, selection, execution, phase
setup, and output metadata without knowing about Django or ArchiveBox.
abx-plugins remains standalone and knows neither host.

## Coordinated dependencies and promotion order

- ArchiveBox/abx-plugins#59
- ArchiveBox/abx-dl#20

Promotion is intentionally serial because all three products use exact
release pins:

1. Merge and release abx-plugins#59.
2. Advance the abx-plugins pin on abx-dl#20, rerun CI, merge, and
release it.
3. Advance both exact pins and the lockfile on this branch, rerun native
ArchiveBox CI, then rerun native ArchiveBox CI before merging this PR.

This PR is ready for architectural review; merging remains gated on the
release and exact-pin sequence above.

This avoids temporary shims, git dependencies, and an unsatisfiable pair
of exact package requirements. The current native ArchiveBox install
matrix is expected to fail while it installs the older released abx-dl
API; local integration verification used editable sibling worktrees.

## Validation

- prek run --all-files
- django-admin makemigrations --check --dry-run: no changes detected
- 104 lifecycle, runner, output, and binary tests passed in the final
focused run
- 91 catalog, admin, config, runner, and output tests passed
- 31 direct-hook adapter tests passed
- real custom data-directory plugin extraction plus admin plugin/config
rendering: 3 passed
- additional binary, process, config, API, and lifecycle checks: 21
passed
2026-09-01 12:50:44 -07:00
Nick Sweeting 92585fe9b5 Remove obsolete background marker reference 2026-09-01 12:48:20 -07:00
Nick Sweeting 376273270a Remove obsolete background hook convention 2026-09-01 12:47:46 -07:00
Nick Sweeting a6eb9b240e Merge remote-tracking branch 'origin/dev' into audit-pr1850 2026-09-01 12:40:04 -07:00
Nick Sweeting aaea35f9fd Preserve lifecycle compatibility after runtime unification 2026-09-01 12:39:58 -07:00
ArchiveBox Release Bot 64e36a1d57 Bump release version to 0.9.35rc369 2026-09-01 19:27:53 +00:00
Nick Sweeting 89b6a9c612 Fix screenshot gallery login sequencing 2026-09-01 12:27:18 -07:00
ArchiveBox Release Bot fa84f2facb Bump release version to 0.9.35rc368 2026-09-01 19:20:47 +00:00
Nick Sweeting 0302c4a31c Add first-run setup wizard screenshots 2026-09-01 12:17:18 -07:00
Nick Sweeting e1e5b74cc4 Merge remote-tracking branch 'origin/dev' into audit-pr1819-clean 2026-09-01 11:55:33 -07:00
ArchiveBox Release Bot 2bfbfd153d Bump release version to 0.9.35rc367 2026-09-01 18:54:30 +00:00
Nick Sweeting 6a2eb62295 Merge remote-tracking branch 'origin/dev' into audit-pr1850 2026-09-01 11:54:02 -07:00
Nick Sweeting 1f6c568ecc Merge remote-tracking branch 'origin/dev' into audit-pr1819-clean 2026-09-01 11:53:56 -07:00
Nick Sweeting d142a77c62 Merge remote-tracking branch 'origin/dev' into audit-pr1846 2026-09-01 11:53:49 -07:00
Nick Sweeting aeceedbebb Refresh documentation example inventory 2026-09-01 11:53:42 -07:00
Nick Sweeting f9c94c2c52 Merge current dev and retain focused yt-dlp metadata changes 2026-09-01 11:49:55 -07:00
Nick Sweeting c2ce82964d Expose yt-dlp media metadata for snapshot cards 2026-09-01 11:49:16 -07:00
Nick Sweeting 5b99586c1f Preserve published ArchiveResult migration history 2026-09-01 11:44:54 -07:00
Nick Sweeting 92a939a6a2 Merge remote-tracking branch 'origin/dev' into audit-pr1850 2026-09-01 11:38:24 -07:00
Nick Sweeting c817da7af6 Merge remote-tracking branch 'origin/dev' into audit-pr1846 2026-09-01 11:38:24 -07:00
Nick Sweeting 3fc67705a5 Unify plugin runtime and queue lifecycle boundaries 2026-09-01 01:07:04 -07:00
Nick Sweeting 97486882bd Address Cubic review feedback 2026-08-28 21:56:23 -07:00
Nick Sweeting c062a3c54f Eliminate request-side SQLite write transactions 2026-08-28 21:56:18 -07:00
Nick Sweeting bdc236f076 Revert "fix: project one archive result per plugin" 2026-08-28 21:56:13 -07:00
FellowTraveler 534b467ee8 Allow staticfile recursive crawl no-response failures 2026-07-07 03:14:40 -05:00
FellowTraveler dc61f27632 Align recursive UI tests with direct URL depth 2026-07-07 03:14:40 -05:00
FellowTraveler a2b08bdb36 Fix remaining PR CI test expectations 2026-07-07 03:14:40 -05:00
FellowTraveler ffba3533b8 Fix public add import newline expectation 2026-07-07 03:13:59 -05:00
FellowTraveler 03af59da85 Fix cli remove count expectation 2026-07-07 03:13:59 -05:00
FellowTraveler 9e82159a20 Fix direct URL crawl CI expectations 2026-07-07 03:13:59 -05:00
FellowTraveler e107b42075 Fix internal input crawl maintenance tests 2026-07-07 03:13:05 -05:00
FellowTraveler 01a6ed1cd7 Use queued plugin set for targeted maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 081f39e004 Prioritize queued search maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 1bd42f37e3 Claim paused snapshots for search maintenance 2026-07-07 03:13:05 -05:00
FellowTraveler 00aa3d572f Update tests for internal crawl root 2026-07-07 03:13:05 -05:00
FellowTraveler 8e7dacf74f Fix queued plugin query construction 2026-07-07 03:13:05 -05:00
FellowTraveler a5fdb9bf7f Handle internal root crawl test cases 2026-07-07 03:13:05 -05:00
FellowTraveler bd4c585bd2 Keep crawl file input via stdin 2026-07-07 03:13:05 -05:00
FellowTraveler d08858dc03 Preserve auto parser coverage for imports 2026-07-07 03:13:05 -05:00
FellowTraveler df3e23e3e8 Queue parser hooks for internal input snapshots 2026-07-07 03:12:14 -05:00
FellowTraveler 68c4c2e7a4 Select input parser for internal snapshots at runtime 2026-07-07 03:11:14 -05:00
FellowTraveler c6c3d6742e Avoid status race in API workflow search 2026-07-07 03:10:35 -05:00
FellowTraveler cd422b201b Use stable URL filter in API workflow test 2026-07-07 03:10:35 -05:00
FellowTraveler 9618b39d6e Ensure restricted add runs input parser 2026-07-07 03:10:35 -05:00
FellowTraveler 37a5f205ca Use normalized URL in API workflow test 2026-07-07 03:10:35 -05:00
FellowTraveler 648db077e8 Fix internal input parser hook scheduling 2026-07-07 03:10:35 -05:00
FellowTraveler aa1a8fb9f3 Expose yt-dlp media metadata for snapshot cards 2026-07-07 03:10:35 -05:00
Nick Sweeting 3830544e13 Update Docker runtime dependencies 2026-05-18 02:24:38 -07:00
Nick Sweeting 913542c19f Release v0.7.4 2026-05-18 02:00:33 -07:00
Nick Sweeting f603a0dfff fix: skip URL entries in save_text_as_source to prevent Path() errors (#1787)
## Fix for Issue #1000

When using `archivebox add --parser=wallabag_atom <feed_url>`, the raw
feed URL was being processed by `save_text_as_source()` which splits on
whitespace and passes each token to `Path()`. URLs get broken and
treated as local file paths, causing spurious "No such file" errors and
random file paths to be accessed.

### Root Cause
Commit a676767 added file-referencing logic to `save_text_as_source()`.
URLs in the raw text (like wallabag feed URLs) get split into fragments
that `Path()` then tries to read as local files.

### Fix
Skip entries that look like URLs (`http://`, `https://`, `ftp://`)
before calling `Path().exists()`.

### Testing
Run: `curl <wallabag_feed_url> | archivebox add --parser=wallabag_atom`

Fixes #1000


<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Skip URL tokens (`http://`, `https://`, `ftp://`) in
`save_text_as_source()` to avoid passing them to `Path()`, preventing
"No such file" errors when adding Wallabag Atom feeds. Fixes #1000.

<sup>Written for commit 5e47e055a2.
Summary will update on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2026-04-17 10:11:22 -07:00
zkksdk 5e47e055a2 fix: skip URL entries in save_text_as_source to prevent Path() errors
When raw_text contains URLs (e.g. from wallabag_atom parser), split()
breaks them into fragments that Path() tries to read as local files.
This causes spurious "No such file" errors and random paths to be
accessed. Skip entries that look like URLs before checking Path.exists().

Fixes #1000
2026-04-18 00:25:10 +08:00
Nick Sweeting d1136d74cb fix docker release script 2024-12-15 02:06:49 -08:00
Nick Sweeting 54f598789e bump more js and py dependency lockfiles 2024-12-15 00:33:03 -08:00
Nick Sweeting 5171eaf31e bump dependencies and metadata for 0.7.3 2024-12-15 00:25:35 -08:00
344 changed files with 27060 additions and 28939 deletions

No files matched your search

+3 -3
View File
@@ -30,8 +30,8 @@ body:
description: Please provide the exact steps you took to trigger the issue (including any shell commands run, URLs visited, buttons clicked, etc.).
render: markdown
placeholder: |
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox` in iTerm2
2. Went to the https://127.0.0.1:8000/add/ page in Google Chrome
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 5797:5797 archivebox/archivebox` in iTerm2
2. Went to the https://127.0.0.1:5797/add/ page in Google Chrome
3. Typed 'https://example.com/xyz' into the 'Add URL' input field
4. Clicked the 'Add+' button
5. Got a 500 error and saw the errors below in terminal
@@ -138,7 +138,7 @@ body:
archivebox:
image: archivebox/archivebox:latest
ports:
- 8000:8000
- 5797:5797
volumes:
- ./data:/data
environment:
+4
View File
@@ -116,6 +116,10 @@
}
],
"docker_binaries": [
{
"name": "{BASH_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{DOCKER_BINARY}",
"binproviders": "env"
+119
View File
@@ -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.
+102
View File
@@ -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()
+156
View File
@@ -0,0 +1,156 @@
/*
Shared ArchiveBox website navigation styles and behavior.
Keep this file in sync with .github/pages/base/chrome.css in:
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
Each site builds its committed copy independently; there is no central generator.
Keep site-specific navigation, CTAs, and extra footer links outside this base.
Also review the inline navbar/footer in abx-dl/website/index.html and
debian-archivebox/website/index.html when shared links or branding change.
*/
/* Common brand tokens. Native templates own product layout and content. */
:root {
--abx-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--abx-font-mono: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
--abx-accent: #9b2854;
--abx-accent-hover: #782040;
--abx-paper: #fcfaf7;
--abx-ink: #28252a;
--abx-muted: #716c74;
--abx-line: #e9e2e3;
--font-sans: var(--abx-font-sans);
--font-mono: var(--abx-font-mono);
--ab-font-sans: var(--abx-font-sans);
--ab-font-serif: var(--abx-font-sans);
--ab-font-label: var(--abx-font-sans);
--ab-font-mono: var(--abx-font-mono);
--ab-accent: var(--abx-accent);
--ab-accent-hover: var(--abx-accent-hover);
--ab-on-accent: #fff;
}
body { font-family: var(--abx-font-sans); }
code, pre, kbd, samp { font-family: var(--abx-font-mono); }
/* Reuse existing CTA classes; platform selection continues to control colors. */
.button, .ab-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
min-height: 44px; height: auto; max-width: 100%; padding: 10px 20px;
border-radius: 24px; font-family: var(--abx-font-sans);
font-size: 14px; font-weight: 650; line-height: 1.4;
text-align: center; white-space: normal; text-decoration: none;
}
.button.primary, .ab-btn--primary { background: var(--abx-accent); border-color: var(--abx-accent); color: #fff; }
.button.primary:hover, .ab-btn--primary:hover { background: var(--abx-accent-hover); border-color: var(--abx-accent-hover); color: #fff; }
.button:focus-visible, .ab-btn:focus-visible { outline: 3px solid #d67c9f; outline-offset: 4px; }
.button svg, .button img, .ab-btn svg, .ab-btn img { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .button, .ab-btn { transition: none; } .button:hover { transform: none; } }
/* Shared ArchiveBox site chrome. Independent copy; edit alongside the other product sites, without a build dependency. */
/* Native anchor navigation leaves room for the heading and its surrounding content. */
html { scroll-behavior: smooth; scroll-padding-top: clamp(96px, 25vh, 240px); }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
.abx-header, .abx-footer { box-sizing: border-box; font-family: var(--abx-font-sans); font-size: 14px; line-height: 1.5; letter-spacing: normal; text-align: left; }
.abx-header *, .abx-footer * { box-sizing: border-box; }
.abx-header { position: sticky; top: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; gap: 24px; width: 100%; max-width: none; margin: 0; padding: 15px max(24px, calc((100% - 1280px) / 2)); border: 0; border-bottom: 1px solid #e9e2e3; background: #fcfaf7f5; color: #28252a; backdrop-filter: blur(16px); }
.abx-header a, .abx-footer a { text-decoration: none; text-underline-offset: .24em; }
.abx-header .abx-brand, .abx-footer .abx-brand { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 11px; margin: 0; padding: 0; color: inherit; font-size: 19px; font-weight: 750; line-height: 1.2; letter-spacing: -.5px; white-space: nowrap; }
.abx-brand .abx-logo { display: block; flex: 0 0 auto; width: 32px; height: 32px; margin: 0; }
.abx-header .abx-subsite { transform: translateY(3px); margin-left: 2px; color: #716c74; font-size: 12px; font-weight: 450; letter-spacing: 0; }
.abx-header .abx-nav { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0; width: auto; max-width: none; margin: 0; padding: 0; font-size: 13px; font-weight: 600; }
.abx-nav > a, .abx-nav > details { margin: 0 0 0 15px; padding: 0 0 0 15px; border: 0; border-left: 1px solid #ded4d8; border-radius: 0; background: none; }
.abx-nav > :first-child { margin-left: 0; padding-left: 0; border-left: 0; }
.abx-header .abx-nav a { display: inline-flex; align-items: center; min-height: 36px; color: #4d424b; white-space: nowrap; }
.abx-header .abx-nav a:hover { color: #9b2854; }
.abx-header .abx-nav .abx-cta { margin-left: 18px; padding: 9px 17px; border: 1px solid #9b2854; border-radius: 24px; background: #9b2854; color: #fff; }
.abx-header .abx-nav .abx-cta:hover { background: #782040; color: #fff; }
.abx-header .abx-apps { position: relative; }
.abx-apps > summary { display: flex; align-items: center; gap: 7px; min-height: 36px; list-style: none; cursor: pointer; color: #4d424b; }
.abx-apps > summary::-webkit-details-marker { display: none; }
.abx-apps > summary::before { content: '›'; display: inline-block; color: #9b2854; font-size: 22px; line-height: 1; transition: transform .15s; }
.abx-apps[open] > summary::before { transform: rotate(90deg); }
.abx-header .abx-app-links { position: absolute; right: 0; top: calc(100% + 12px); width: 440px; max-width: calc(100vw - 24px); max-height: min(70vh, 580px); overflow-y: auto; padding: 8px; border: 1px solid #e9e2e3; border-radius: 16px; background: #fcfaf7; box-shadow: 0 18px 48px #2d152522, 0 3px 8px #2d152508; }
.abx-header .abx-app-row { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 4px 6px; border-radius: 10px; transition: background .15s; }
.abx-header .abx-app-row:hover, .abx-header .abx-app-row:focus-within { background: #f2e8ed; }
.abx-header .abx-app-links .abx-app-name { flex: 1; min-width: 0; min-height: 34px; padding: 5px 4px; border-radius: 6px; font-size: 13px; line-height: 1.35; white-space: normal; }
.abx-header .abx-platforms { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; }
.abx-header .abx-app-links .abx-platform, .abx-header .abx-app-links .abx-tool { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; height: 30px; min-height: 30px; padding: 5px; border: 1px solid #e8dfe3; border-radius: 9px; background: #fffdfb; color: #4d424b; box-shadow: 0 2px 3px #2d152510, inset 0 1px 0 #fff; transition: transform .15s, box-shadow .15s, border-color .15s; }
.abx-header .abx-app-links .abx-platform { width: 30px; }
.abx-header .abx-platform svg { display: block; width: 18px; height: 18px; margin: 0; fill: currentColor; }
.abx-header .abx-app-links .abx-platform-docker { color: #2386ce; }
.abx-header .abx-app-links .abx-platform-windows, .abx-header .abx-app-links .abx-platform-app-store, .abx-header .abx-app-links .abx-platform-safari { color: #1879c4; }
.abx-header .abx-app-links .abx-platform-android { color: #29965d; }
.abx-header .abx-app-links .abx-platform-firefox { color: #e46b24; }
.abx-header .abx-app-links .abx-platform-brave { color: #e8552b; }
.abx-header .abx-app-links .abx-platform-edge { color: #168b95; }
.abx-header .abx-app-links .abx-platform:hover, .abx-header .abx-app-links .abx-platform:focus-visible, .abx-header .abx-app-links .abx-tool:hover, .abx-header .abx-app-links .abx-tool:focus-visible { transform: translateY(-2px); border-color: #c99bb0; background: #fff; box-shadow: 0 5px 10px #2d15251c; }
.abx-header .abx-app-links .abx-tool { padding: 5px 8px; font-size: 11px; white-space: nowrap; }
.abx-header .abx-tools-row { margin-top: 5px; padding-top: 9px; border-top: 1px solid #e9e2e3; border-radius: 0 0 10px 10px; }
.abx-header .abx-tools-row .abx-app-name { flex: 0 0 auto; }
.abx-header .abx-tools-row .abx-platforms { flex: 0 1 auto; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
.abx-header .abx-app-links code { display: inline; vertical-align: baseline; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 1em; font-weight: inherit; line-height: inherit; margin: 0; padding: 0; border: 0; background: transparent; color: inherit; }
.abx-header .abx-icon-stack { isolation: isolate; gap: 0; padding: 4px 5px; }
.abx-header .abx-icon-stack .abx-integration { position: relative; z-index: 1; transform: translateY(var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; }
.abx-header .abx-icon-stack .abx-integration + .abx-integration { margin-left: -10px; }
.abx-header .abx-icon-stack .abx-integration:nth-child(2) { --stack-angle: 9deg; --stack-y: -2px; z-index: 2; color: #569a31; }
.abx-header .abx-icon-stack .abx-integration:nth-child(3) { --stack-angle: -6deg; --stack-y: 2px; z-index: 3; }
.abx-header .abx-icon-stack .abx-integration:nth-child(4) { --stack-angle: 7deg; --stack-y: -1px; z-index: 4; color: #f00; }
.abx-header .abx-icon-stack .abx-integration:nth-child(5) { --stack-angle: -10deg; --stack-y: 1px; z-index: 5; }
.abx-header .abx-icon-stack .abx-integration:nth-child(6) { --stack-angle: 6deg; --stack-y: -2px; z-index: 6; }
.abx-header .abx-icon-stack .abx-integration:nth-child(7) { --stack-angle: -5deg; z-index: 7; }
.abx-header .abx-icon-stack .abx-integration:not(:hover, :focus-visible):has(~ .abx-integration:is(:hover, :focus-visible)) { transform: translate(-6px, var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); }
.abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) ~ .abx-integration:not(:hover, :focus-visible) { transform: translate(6px, var(--stack-y, 0px)) rotate(var(--stack-angle, -8deg)); }
.abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) { z-index: 20; transform: translateY(-7px) rotate(0deg) scale(1.12); border-color: #c99bb0; box-shadow: 0 8px 14px #2d15252b; }
@media (hover: none) { .abx-header .abx-icon-stack .abx-integration + .abx-integration { margin-left: -6px; } }
@media (prefers-reduced-motion: reduce) { .abx-header .abx-icon-stack .abx-integration { transition: none; } }
.abx-footer { position: relative; z-index: 1; display: block; width: 100%; max-width: none; margin: 72px 0 0; padding: 60px max(24px, calc((100% - 1280px) / 2)) 24px; border: 0; border-top: 3px solid #7e284a; background: radial-gradient(ellipse at 12% 0, #54243b55, transparent 48%), linear-gradient(120deg, #201923, #14151b 75%); color: #ddd2dd; color-scheme: dark; }
.abx-footer .abx-footer-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 32px; margin: 0 0 42px; }
.abx-footer .abx-brand { color: #fff4ed; font-size: 24px; }
.abx-footer .abx-brand .abx-logo { width: 40px; height: 40px; }
.abx-footer .abx-tagline { max-width: 430px; margin: 12px 0 0; color: #b8acbb; font-size: 14px; line-height: 1.7; }
.abx-footer .abx-footer-motto { margin: 2px 0 0; color: #d8b39f; font: italic 25px/1.45 Georgia, serif; text-align: right; }
.abx-footer .abx-footer-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 42px; margin: 0; }
.abx-footer .abx-footer-column { margin: 0; padding: 20px 0 0; border: 0; border-top: 1px solid #67505e66; background: none; }
.abx-footer .abx-footer-column h2 { margin: 0 0 19px; padding: 0; border: 0; color: #e4acbb; font: 650 12px/1.4 var(--abx-font-sans); letter-spacing: .12em; text-transform: uppercase; }
.abx-footer .abx-footer-column ul { display: block; list-style: none; margin: 0; padding: 0; }
.abx-footer .abx-footer-column li { display: flex; align-items: center; gap: 10px; margin: 0; padding: 0; min-height: 36px; }
.abx-footer .abx-footer-column li::before { content: none; }
.abx-footer .abx-footer-column a { color: #ddd2dd; font-size: 13px; line-height: 1.55; }
.abx-footer .abx-footer-column a:first-child { padding: 6px 0; }
.abx-footer a:hover { color: #fff; text-decoration: underline; }
.abx-footer .abx-source { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; width: 28px; height: 28px; border: 1px solid transparent; border-radius: 50%; color: #a58d9f; }
.abx-footer .abx-source:hover { color: #f3b5cb; border-color: #80516a; background: #ffffff08; }
.abx-source svg { width: 15px; height: 15px; fill: currentColor; }
.abx-footer .abx-footer-bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; margin-top: 32px; padding-top: 21px; border-top: 1px solid #67505e66; color: #a99bab; font-size: 12px; }
.abx-footer .abx-footer-bottom p { margin: 0; font-size: inherit; color: inherit; }
.abx-footer .abx-footer-bottom a { color: #cebbc9; }
.abx-footer .abx-footer-local { margin: 24px 0 0; padding: 16px 0 0; border: 0; border-top: 1px solid #67505e66; border-radius: 0; background: none; color: #baacbc; font-size: 12px; }
.abx-footer-local > summary { cursor: pointer; color: #d6bfce; }
.abx-footer-local > div { padding-top: 18px; }
.abx-footer-local a { color: #e5aabd; }
.abx-footer-local .brand, .abx-footer-local .footer-intro, .abx-footer-local .footer-grid, .abx-footer-local .footer-meta { color: inherit; }
.abx-footer-local .footer-card { background: transparent; border-color: #67505e66; }
.abx-footer-local h2, .abx-footer-local h3, .abx-footer-local strong { color: #e4d8e3; }
.abx-footer-local .footer-link-copy span, .abx-footer-local .footer-card-subtitle { color: #baacbc; }
.abx-header a:focus-visible, .abx-header summary:focus-visible, .abx-footer a:focus-visible, .abx-footer summary:focus-visible { outline: 3px solid #d67c9f; outline-offset: 4px; }
@media (max-width: 1100px) {
.abx-header { flex-wrap: wrap; gap: 8px 24px; padding: 12px 20px; }
.abx-header .abx-nav { justify-content: flex-start; }
.abx-header .abx-app-links { position: fixed; top: auto; left: 20px; right: auto; width: 440px; margin-top: 10px; }
}
@media (max-width: 700px) {
.abx-header { position: relative; padding: 14px 18px; }
.abx-header .abx-brand { flex-wrap: wrap; row-gap: 5px; }
.abx-header .abx-nav { width: 100%; gap: 4px 0; font-size: 12px; }
.abx-nav > a, .abx-nav > details { margin-left: 10px; padding-left: 10px; }
.abx-header .abx-nav .abx-cta { margin-left: 10px; padding: 7px 13px; }
.abx-header .abx-app-links { left: 12px; right: 12px; width: auto; max-height: 60vh; }
.abx-footer { padding: 40px 22px 22px; margin-top: 48px; }
.abx-footer .abx-footer-intro { display: block; margin-bottom: 30px; }
.abx-footer .abx-footer-motto { margin-top: 20px; text-align: left; font-size: 22px; }
.abx-footer .abx-footer-grid { grid-template-columns: 1fr; gap: 26px; }
.abx-footer .abx-footer-column li { min-height: 40px; }
}
@media (prefers-reduced-motion: reduce) { .abx-apps > summary::before, .abx-header .abx-app-row, .abx-header .abx-app-links .abx-platform, .abx-header .abx-app-links .abx-tool { transition: none; } .abx-header .abx-app-links .abx-platform:hover, .abx-header .abx-app-links .abx-tool:hover { transform: none; } }
.abx-footer-local > div > a + a { margin-left: 12px; }
@media (prefers-reduced-motion: reduce) { .abx-header .abx-icon-stack .abx-integration:is(:hover, :focus-visible) { transform: none; } }
+28
View File
@@ -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();
})();
+12
View File
@@ -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 &amp; privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
</footer>
+13
View File
@@ -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 &amp; 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 &amp; iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS &amp; 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
+24
View File
@@ -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>
+11
View File
@@ -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; } }
+44
View File
@@ -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));
});
})();
+96
View File
@@ -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
+4
View File
@@ -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 &amp; 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>
+16
View File
@@ -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"
]
}
+215
View File
@@ -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)
+255
View File
@@ -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 -1
View File
@@ -52,7 +52,7 @@ jobs:
name: Tested Docker images
uses: ./.github/workflows/docker.yml
with:
push_digests: false
push_digests: ${{ github.event_name == 'push' && github.ref_name == 'main' }}
secrets: inherit
required:
+70 -62
View File
@@ -1,79 +1,87 @@
name: Deploy Publicsite to GitHub Pages
name: Publish website
on:
push:
branches:
- dev
paths:
- publicsite/**
- archivebox/**
- bin/collect_ui_screenshots.*
- bin/generate_ui_screenshot_gallery.py
- bin/take_screenshot.js
- docs/Screenshots.md
- pyproject.toml
- uv.lock
- .github/workflows/deploy-publicsite.yml
branches: [dev]
pull_request:
workflow_dispatch:
workflow_run:
workflows: ['Capture publicsite screenshots']
types: [completed]
branches: [dev]
permissions:
contents: read
pages: write
id-token: write
actions: read
env:
# Repository-specific output; all publication logic below is a local copy.
SITE_OUTPUT: _site
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
# One production queue covers restore, build and deploy. PRs have separate queues.
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('github-pages-publicsite-manual-{0}', github.run_id) || 'github-pages-publicsite' }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
group: pages-${{ github.event_name == 'pull_request' && github.ref || 'production' }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
revision: ${{ steps.source.outputs.revision }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.ref || env.DEFAULT_BRANCH }}
persist-credentials: false
- name: Record website source revision
id: source
run: echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# BEGIN REPOSITORY-SPECIFIC BUILD
- uses: astral-sh/setup-uv@v7
- name: Restore latest complete screenshots
run: uv run --no-project python bin/restore_site_screenshots.py publicsite/screenshots
env:
GH_TOKEN: ${{ github.token }}
- name: Render current gallery layout without recapturing images
run: uv run --no-project python bin/generate_ui_screenshot_gallery.py refresh publicsite/screenshots/index.html
- name: Assemble website with the local base components
run: uv run --no-project python .github/pages/site.py render _site --source publicsite
# END REPOSITORY-SPECIFIC BUILD
- name: Verify website in Chromium
run: |
uv run --no-config --no-project --with playwright==1.63.0 playwright install --with-deps chromium
uv run --no-config --no-project --with playwright==1.63.0 python .github/pages/verify.py "$SITE_OUTPUT" --evidence "$RUNNER_TEMP/site-evidence"
- name: Save website verification evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: site-verification
path: ${{ runner.temp }}/site-evidence
if-no-files-found: warn
retention-days: 14
- name: Upload website
uses: actions/upload-pages-artifact@v3
with:
path: ${{ env.SITE_OUTPUT }}
deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Setup Python and uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Install ArchiveBox and screenshot dependencies
run: uv sync --no-cache --frozen
- name: Build UI screenshot gallery
run: bin/collect_ui_screenshots.sh
env:
BASE_URL: http://archivebox.localhost:8000
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
PY
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Upload artifact
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
path: ./publicsite
- name: Deploy to GitHub Pages
- uses: actions/configure-pages@v5
- uses: actions/deploy-pages@v4
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
+70 -14
View File
@@ -13,6 +13,22 @@ on:
required: false
default: true
type: boolean
workflow_dispatch:
inputs:
source_ref:
description: Exact 40-character commit SHA to build
required: true
type: string
push_digests:
description: Push validated per-platform images by digest for the release workflow
required: false
default: true
type: boolean
full_tests:
description: Run storage, CLI, and size validation in addition to release smoke checks
required: false
default: true
type: boolean
env:
DOCKERHUB_IMAGE: archivebox/archivebox
@@ -42,6 +58,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.source_ref || github.sha }}
- name: Verify exact source commit
if: github.event_name == 'workflow_dispatch'
shell: bash
env:
SOURCE_REF: ${{ inputs.source_ref }}
run: |
set -Eeuo pipefail
[[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]
[[ "$(git rev-parse HEAD)" == "$SOURCE_REF" ]]
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -183,7 +211,7 @@ jobs:
{
echo 'labels<<EOF'
echo "org.opencontainers.image.version=${VERSION}"
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
echo "org.opencontainers.image.revision=${{ inputs.source_ref || github.sha }}"
echo 'org.opencontainers.image.source=https://github.com/ArchiveBox/ArchiveBox'
echo "io.archivebox.abx-dl.image=${{ steps.abx_dl_image.outputs.image }}"
echo 'EOF'
@@ -203,7 +231,7 @@ jobs:
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
pull: true
platforms: ${{ matrix.platform }}
# Keep the released image as the one cache authority. A second GHA
@@ -232,7 +260,7 @@ jobs:
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
pull: true
platforms: ${{ matrix.platform }}
# Test the same layer lineage users receive. Reusing :dev avoids a
@@ -246,13 +274,34 @@ jobs:
if: inputs.push_digests
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Verify OpenCode install with a read-only browser profile
shell: bash
env:
IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
run: |
set -Eeuo pipefail
PROFILE_DIR="$(mktemp -d)"
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' \
--volume "$PROFILE_DIR:/home/archivebox/.config/chromium:ro" \
"$IMAGE" bash -c '
set -Eeuo pipefail
test -w "$XDG_CONFIG_HOME"
test ! -w "$XDG_CONFIG_HOME/chromium"
# Check the shipped executable before the installer can repair
# an image that accidentally omitted this opt-in dependency.
"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version
archivebox install opencode
"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version
'
- name: Validate exact built image version and commit
if: ${{ !inputs.push_digests }}
shell: bash
run: |
set -Eeuo pipefail
IMAGE="${{ matrix.local_tag }}"
SHORT_SHA="${GITHUB_SHA::7}"
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}"
SHORT_SHA="${{ (inputs.source_ref || github.sha) }}"
SHORT_SHA="${SHORT_SHA::7}"
DATA_DIR="$(mktemp -d)"
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
@@ -296,8 +345,8 @@ jobs:
MANIFEST="$("$DOCKER_BINARY" buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}@${IMAGE_DIGEST}")"
fi
COMPRESSED_SIZE="$(MANIFEST="$MANIFEST" "$UV_BINARY" run --no-cache --no-project python -c 'import json, os; manifest = json.loads(os.environ["MANIFEST"]); print(manifest["config"]["size"] + sum(layer["size"] for layer in manifest["layers"]))')"
LIMIT=$((750 * 1024 * 1024))
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=750 MiB"
LIMIT=$((1025 * 1024 * 1024))
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=1025 MiB"
(( COMPRESSED_SIZE < LIMIT ))
- name: Resolve real Docker CLI test dependencies through abxpkg
@@ -357,7 +406,7 @@ jobs:
"$SUDO_BINARY" "$MKDIR_BINARY" -p /etc/exports.d
"$SUDO_BINARY" "$CHMOD_BINARY" 0777 "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT"
"$SUDO_BINARY" "$BASH_BINARY" -c \
"printf '%s *(rw,sync,no_subtree_check,no_root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports"
"printf '%s *(rw,sync,no_subtree_check,root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports"
"$SUDO_BINARY" "$BASH_BINARY" -c \
"printf '\\n[archivebox-ci]\\npath = %s\\nbrowseable = yes\\nread only = no\\nguest ok = yes\\nguest only = yes\\nforce user = nobody\\ncreate mask = 0777\\ndirectory mask = 0777\\n' '$SMB_SOURCE' >> /etc/samba/smb.conf"
@@ -375,14 +424,20 @@ jobs:
NFS_TEST_DIR="$NFS_MOUNT" \
SMB_TEST_DIR="$SMB_MOUNT" \
IMAGE="${{ matrix.local_tag }}" \
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}" \
"$BASH_BINARY" bin/validate_docker_uid_gid.sh --local-only
- name: Validate Docker port compatibility
shell: bash
env:
IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
run: '"$BASH_BINARY" bin/validate_docker_ports.sh'
- name: Validate Docker CLI add/list and compose behavior
if: inputs.full_tests && matrix.platform == 'linux/amd64'
shell: bash
env:
ARCHIVEBOX_IMAGE: ${{ matrix.local_tag }}
ARCHIVEBOX_IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
run: |
set -Eeuo pipefail
"$MKDIR_BINARY" "${{ github.workspace }}/data"
@@ -401,13 +456,13 @@ jobs:
"$DOCKER_BINARY" compose run --rm archivebox init
"$DOCKER_BINARY" compose up -d --wait
"$DOCKER_BINARY" network connect archivebox_default www-nginx
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js')"
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/public/')"
admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/static/admin/js/jquery.init.js')"
[[ "$index_html" == *ArchiveBox* ]]
[[ "$admin_js" == *window.django* ]]
"$DOCKER_BINARY" compose run --rm archivebox add --plugins=parse_txt_urls 'http://www-nginx/#test_docker'
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:5797/public/')"
[[ "$index_html" == *'http://www-nginx/#test_docker'* ]]
"$DOCKER_BINARY" compose down
@@ -423,7 +478,7 @@ jobs:
'${{ runner.temp }}/archivebox-image.tar' \
'${{ steps.docker_build_pr.outputs.digest }}' \
"$LOCAL_IMAGE_ID" \
"$((750 * 1024 * 1024))" <<'PY'
"$((1025 * 1024 * 1024))" <<'PY'
from hashlib import sha256
import json
import os
@@ -503,6 +558,7 @@ jobs:
mkdir -p /tmp/digests
digest="${{ steps.docker_build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
printf '%s\n' '${{ inputs.source_ref || github.sha }}' > /tmp/digests/source-ref
- name: Upload digest
if: inputs.push_digests
+6 -3
View File
@@ -99,9 +99,10 @@ jobs:
if [[ '${{ matrix.environment }}' != macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries")
fi
if [[ '${{ matrix.environment }}' == ubuntu ]]; then
if [[ '${{ matrix.name }}' == ubuntu-AGENTS || '${{ matrix.name }}' == ubuntu-skills-archivebox-SKILL || '${{ matrix.name }}' == ubuntu-docs-Configuration || '${{ matrix.name }}' == ubuntu-docs-Setting-up-Authentication ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries")
elif [[ '${{ matrix.environment }}' == docker ]]; then
fi
if [[ '${{ matrix.environment }}' == docker ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries")
elif [[ '${{ matrix.environment }}' == macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-brew.json:required_binaries")
@@ -145,7 +146,9 @@ jobs:
fi
- name: Build the exact checkout's documentation image
if: ${{ matrix.environment == 'docker' }}
run: '"$DOCKER_BINARY" build --tag archivebox/archivebox:dev .'
run: |
ABX_DL_VERSION="$(uv run --no-cache --no-project python -c 'import tomllib; print(next(dep.removeprefix("abx-dl==") for dep in tomllib.load(open("pyproject.toml", "rb"))["project"]["dependencies"] if dep.startswith("abx-dl==")))')"
"$DOCKER_BINARY" build --build-arg "ABX_DL_IMAGE=archivebox/abx-dl:${ABX_DL_VERSION}" --tag archivebox/archivebox:dev .
- name: Run the Ubuntu, Docker, or macOS documentation lane
if: ${{ !contains(fromJSON('["root", "freebsd", "openbsd"]'), matrix.environment) }}
run: uv run --no-cache --no-sync python docs/test_codeblocks_manifest.py run-group '${{ matrix.snippet_ids }}'
@@ -1,96 +0,0 @@
name: Duplicate Issue Detection
on:
issues:
types: [opened]
jobs:
check-duplicates:
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
- name: Resolve OpenCode and workflow tools through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
run: |
set -Eeuo pipefail
uv sync --no-cache --locked --no-sources --no-install-project
OPENCODE_CONFIG="$(uv run --no-cache --no-sync --no-sources python -c 'from abx_plugins import get_plugins_dir; print(get_plugins_dir() / "opencode" / "config.json")')"
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH"
opencode_env="$(
uv run --no-cache --no-sync --no-sources abxpkg env \
--install \
--json \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:github_binaries" \
--deps-from="$OPENCODE_CONFIG:required_binaries"
)"
jq_binary="$ABXPKG_LIB_DIR/env/bin/jq"
"$jq_binary" -r 'to_entries[] | "\(.key)=\(.value)"' \
<<< "$opencode_env" >> "$GITHUB_ENV"
opencode_binary="$ABXPKG_LIB_DIR/env/bin/opencode"
echo "OPENCODE_BINARY=$opencode_binary" >> "$GITHUB_ENV"
test -L "$opencode_binary"
test -x "$opencode_binary"
- name: Check for duplicate issues
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"gh issue*": "allow",
"gh pr*": "allow",
"*": "deny"
},
"webfetch": "allow"
}
run: |
"$OPENCODE_BINARY" run -m anthropic/claude-haiku-4-5 "A new issue has been created:
Issue number:
${{ github.event.issue.number }}
Lookup this issue and search through existing issues and PRs (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
If you find any potential duplicates, please comment on the new issue with:
- A brief explanation of why it might be a duplicate
- Links to the potentially duplicate issues or PRs
- A suggestion to check those issues first
Use this format for the comment:
'This issue might be a duplicate of existing issues. Please check:
- #[issue_number]: [brief description of similarity]
Feel free to ignore if none of these address your specific case.'
If no clear duplicates are found, do not comment."
+34 -6
View File
@@ -2,7 +2,7 @@ name: Release candidate
on:
push:
branches: [dev]
branches: [dev, main]
permissions:
actions: read
@@ -10,7 +10,7 @@ permissions:
packages: write
concurrency:
group: release-candidate-dev
group: release-candidate-${{ github.ref_name }}
cancel-in-progress: true
jobs:
@@ -25,17 +25,31 @@ jobs:
submodules: true
token: ${{ secrets.RELEASE_GH_TOKEN }}
- name: Keep synchronized stable branches on the released version
id: channel
shell: bash
run: |
set -Eeuo pipefail
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
MAIN_SHA="$(git ls-remote origin refs/heads/main | cut -f1)"
if [[ "$GITHUB_REF_NAME" == dev && "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ && "$MAIN_SHA" == "$GITHUB_SHA" ]]; then
echo 'stable=true' >> "$GITHUB_OUTPUT"
else
echo 'stable=false' >> "$GITHUB_OUTPUT"
fi
- id: version
if: steps.channel.outputs.stable != 'true'
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
with:
package: archivebox
branch: dev
branch: ${{ github.ref_name }}
tag-prefix: v
version-scheme: rc
version-scheme: ${{ github.ref_name == 'main' && 'patch' || 'rc' }}
python-artifacts:
needs: prepare
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
uses: ./.github/workflows/pip.yml
with:
full_tests: false
@@ -43,9 +57,23 @@ jobs:
docker-digests:
needs: prepare
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
uses: ./.github/workflows/docker.yml
with:
push_digests: true
full_tests: false
secrets: inherit
opencode:
needs: prepare
if: github.ref_name == 'dev' && (needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved')
strategy:
fail-fast: false
matrix:
test_file:
- archivebox/tests/test_opencode_agent.py
- archivebox/tests/test_opencode_browser.py
uses: ./.github/workflows/test-parallel.yml
with:
test_file: ${{ matrix.test_file }}
secrets: inherit
+186 -19
View File
@@ -5,30 +5,53 @@ on:
workflows: [CI, Release candidate]
types: [completed]
branches: [dev, main]
workflow_dispatch:
inputs:
release_branch:
description: Branch being promoted
required: true
type: choice
options: [main, dev]
release_sha:
description: Exact tested commit to release
required: true
type: string
ci_run_id:
description: Successful CI run containing the tested Python artifacts
required: true
type: string
docker_digest_run_id:
description: Manual Docker digest build run containing the tested image digests
required: true
type: string
permissions: {}
concurrency:
group: release-${{ github.repository }}-${{ github.event.workflow_run.head_branch }}
group: release-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
cancel-in-progress: false
env:
DOCKERHUB_IMAGE: archivebox/archivebox
GHCR_IMAGE: ghcr.io/archivebox/archivebox
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
CI_RUN_ID: ${{ github.event.workflow_run.id }}
RELEASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
RELEASE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.release_sha || github.event.workflow_run.head_sha }}
jobs:
candidate:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
((github.event.workflow_run.head_branch == 'dev' && github.event.workflow_run.name == 'Release candidate') ||
(github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.name == 'CI'))
(github.event.workflow_run.head_branch == 'dev' || github.event.workflow_run.head_branch == 'main') ||
(github.event_name == 'workflow_dispatch' && inputs.release_branch == 'main')
runs-on: ubuntu-24.04
permissions:
actions: read
contents: read
outputs:
ready: ${{ steps.owner.outputs.ready }}
ready: ${{ steps.verified.outputs.ready }}
artifact_run_id: ${{ steps.verified.outputs.artifact_run_id }}
digest_run_id: ${{ steps.verified.outputs.digest_run_id }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -37,19 +60,90 @@ jobs:
- id: owner
shell: bash
run: |
if [[ "$RELEASE_BRANCH" == main ]]; then
echo 'ready=true' >> "$GITHUB_OUTPUT"
set -Eeuo pipefail
BRANCH_SHA="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1)"
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && "$BRANCH_SHA" != "$RELEASE_SHA" ]]; then
echo 'ready=false' >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
if [[ "$RELEASE_BRANCH" == main ]]; then
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Refusing to publish non-stable version $VERSION as a main release" >&2
exit 1
}
git fetch --no-tags origin main
git merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
echo "Refusing to publish $RELEASE_SHA: it is not on main" >&2
exit 1
}
TAG_TARGET="$(git ls-remote origin "refs/tags/v$VERSION" | cut -f1)"
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && -n "$TAG_TARGET" && "$TAG_TARGET" != "$RELEASE_SHA" ]]; then
echo 'ready=false' >> "$GITHUB_OUTPUT"
exit 0
fi
echo 'ready=true' >> "$GITHUB_OUTPUT"
exit 0
fi
# A stable release fast-forwards dev to main. Only main publishes it;
# the resulting dev workflow is not a new release candidate.
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then
echo 'ready=false' >> "$GITHUB_OUTPUT"
exit 0
fi
TARGET="$(git ls-remote origin "refs/tags/release-candidate/$VERSION" | awk '{print $1}')"
[[ "$TARGET" == "$RELEASE_SHA" ]] && echo 'ready=true' >> "$GITHUB_OUTPUT" || echo 'ready=false' >> "$GITHUB_OUTPUT"
- name: Require successful full CI for this commit
id: verified
if: steps.owner.outputs.ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
MANUAL_CI_RUN_ID: ${{ inputs.ci_run_id }}
MANUAL_DIGEST_RUN_ID: ${{ inputs.docker_digest_run_id }}
shell: bash
run: |
set -Eeuo pipefail
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
CI_RUN_ID="$MANUAL_CI_RUN_ID"
DIGEST_RUN_ID="$MANUAL_DIGEST_RUN_ID"
CI_STATUS="$(gh run view "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status,conclusion,headSha,event,name \
--jq '[.status, .conclusion, .headSha, .event, .name] | @tsv')"
[[ "$CI_STATUS" == "$(printf 'completed\tsuccess\t%s\tpush\tCI' "$RELEASE_SHA")" ]]
else
CANDIDATE_RUN_ID="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow release-candidate.yml \
--commit "$RELEASE_SHA" --branch "$RELEASE_BRANCH" --event push --limit 1 \
--json databaseId,status,conclusion \
--jq '.[0] | select(.status == "completed" and .conclusion == "success") | .databaseId // empty')"
CI_RUN_ID="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml \
--commit "$RELEASE_SHA" --branch "$RELEASE_BRANCH" --event push --limit 1 \
--json databaseId,status,conclusion \
--jq '.[0] | select(.status == "completed" and .conclusion == "success") | .databaseId // empty')"
[[ -n "$CANDIDATE_RUN_ID" && -n "$CI_RUN_ID" ]] || exit 0
if [[ "$RELEASE_BRANCH" == dev ]]; then
DIGEST_RUN_ID="$CANDIDATE_RUN_ID"
else
DIGEST_RUN_ID="$CI_RUN_ID"
fi
fi
if [[ "$RELEASE_BRANCH" == dev && "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
ARTIFACT_RUN_ID="$CANDIDATE_RUN_ID"
else
ARTIFACT_RUN_ID="$CI_RUN_ID"
fi
{
echo 'ready=true'
echo "artifact_run_id=$ARTIFACT_RUN_ID"
echo "digest_run_id=$DIGEST_RUN_ID"
} >> "$GITHUB_OUTPUT"
python-release:
name: Publish tested Python artifacts
needs: candidate
if: needs.candidate.outputs.ready == 'true'
runs-on: ubuntu-24.04
env:
CI_RUN_ID: ${{ needs.candidate.outputs.artifact_run_id }}
environment: pypi
permissions:
actions: read
@@ -125,6 +219,10 @@ jobs:
VERSION='${{ steps.version.outputs.version }}'
case "$RELEASE_BRANCH" in
main)
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Refusing to publish non-stable version $VERSION from main" >&2
exit 1
}
;;
dev)
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]] || {
@@ -162,20 +260,37 @@ jobs:
docker-release:
name: Publish tested multiarch image
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
needs: python-release
(github.event_name == 'workflow_dispatch') ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push')
needs: [candidate, python-release]
runs-on: ubuntu-24.04
env:
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
permissions:
actions: read
contents: read
contents: write
packages: write
steps:
- name: Checkout the tested commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
fetch-depth: 0
ref: ${{ env.RELEASE_SHA }}
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
- name: Fast-forward dev to the tested stable release
if: env.RELEASE_BRANCH == 'main' && needs.python-release.outputs.release_ready == 'true'
shell: bash
run: |
set -Eeuo pipefail
git fetch --no-tags origin main dev
MAIN_SHA="$(git rev-parse origin/main)"
DEV_SHA="$(git rev-parse origin/dev)"
if [[ "$MAIN_SHA" == "$RELEASE_SHA" && "$DEV_SHA" != "$RELEASE_SHA" ]] &&
git merge-base --is-ancestor "$DEV_SHA" "$RELEASE_SHA"; then
git push origin "$RELEASE_SHA:refs/heads/dev"
fi
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
@@ -198,13 +313,14 @@ jobs:
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" >/dev/null
for name in uv git jq docker; do
for name in uv gh git jq docker; do
test -L "$ABXPKG_LIB_DIR/env/bin/$name"
test -x "$ABXPKG_LIB_DIR/env/bin/$name"
done
{
echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv"
echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git"
echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh"
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker"
} >> "$GITHUB_ENV"
@@ -232,16 +348,31 @@ jobs:
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ env.CI_RUN_ID }}
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
- name: Verify exact successful digest build run
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -Eeuo pipefail
RUN_STATUS="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status --jq '.status')"
RUN_CONCLUSION="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')"
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]]
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
RUN_SHA="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json headSha --jq '.headSha')"
[[ "$RUN_SHA" == "$RELEASE_SHA" ]]
fi
- name: Verify digest set and collect tags
id: docker_meta
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
[[ "${#DIGESTS[@]}" -eq 2 ]]
for digest in "${DIGESTS[@]}"; do [[ "$digest" =~ ^[0-9a-f]{64}$ ]]; done
[[ "$(< /tmp/digests/source-ref)" == "$RELEASE_SHA" ]]
VERSION='${{ needs.python-release.outputs.version }}'
SHORT_SHA="${RELEASE_SHA::12}"
@@ -251,12 +382,45 @@ jobs:
TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}")"
TAG_TARGET="${TAG_TARGET%%[[:space:]]*}"
fi
if [[ "$RELEASE_BRANCH" == main ]]; then
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Refusing to tag :latest with prerelease $VERSION" >&2
exit 1
}
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] || {
echo "Refusing to tag :latest: v$VERSION does not point to $RELEASE_SHA" >&2
exit 1
}
$GIT_BINARY fetch --no-tags origin main
$GIT_BINARY merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
echo "Refusing to tag :latest: $RELEASE_SHA is not on main" >&2
exit 1
}
MAIN_VERSION="$($GIT_BINARY show origin/main:pyproject.toml | sed -nE 's/^version = "([^"]+)".*/\1/p' | head -n 1)"
[[ "$MAIN_VERSION" == "$VERSION" ]] || {
echo "Refusing to tag :latest: main is now $MAIN_VERSION, not $VERSION" >&2
exit 1
}
fi
SYNC_DEV=false
if [[ "$RELEASE_BRANCH" == main ]]; then
$GIT_BINARY fetch --no-tags origin main dev
MAIN_TARGET="$($GIT_BINARY rev-parse origin/main)"
DEV_TARGET="$($GIT_BINARY rev-parse origin/dev)"
# Recovery can add CI/docs fixes after the immutable release tag.
# Both branches and every shipped application file must agree.
if [[ "$MAIN_TARGET" == "$DEV_TARGET" ]] &&
$GIT_BINARY diff --quiet "$RELEASE_SHA" "$MAIN_TARGET" -- . ':!.github' ':!README.md'; then
SYNC_DEV=true
fi
fi
{
echo 'dockerhub_tags<<EOF'
if [[ "$RELEASE_BRANCH" == dev ]]; then
echo "${DOCKERHUB_IMAGE}:dev"
elif [[ "$RELEASE_BRANCH" == main ]]; then
echo "${DOCKERHUB_IMAGE}:latest"
[[ "$SYNC_DEV" == true ]] && echo "${DOCKERHUB_IMAGE}:dev"
fi
echo "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${DOCKERHUB_IMAGE}:${VERSION}"
@@ -266,6 +430,7 @@ jobs:
echo "${GHCR_IMAGE}:dev"
elif [[ "$RELEASE_BRANCH" == main ]]; then
echo "${GHCR_IMAGE}:latest"
[[ "$SYNC_DEV" == true ]] && echo "${GHCR_IMAGE}:dev"
fi
echo "${GHCR_IMAGE}:sha-${SHORT_SHA}"
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${GHCR_IMAGE}:${VERSION}"
@@ -276,7 +441,7 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
TAG_ARGS=()
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}'
REFS=()
@@ -287,7 +452,7 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
TAG_ARGS=()
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}'
REFS=()
@@ -317,6 +482,7 @@ jobs:
name: Cascade verified release
if: needs.python-release.outputs.release_ready == 'true'
needs:
- candidate
- python-release
- docker-release
permissions:
@@ -327,5 +493,6 @@ jobs:
upstream_package: archivebox
upstream_version: ${{ needs.python-release.outputs.version }}
upstream_artifact_name: python-distributions
upstream_run_id: ${{ needs.candidate.outputs.artifact_run_id }}
secrets:
release_token: ${{ secrets.RELEASE_GH_TOKEN }}
+71
View File
@@ -0,0 +1,71 @@
name: Capture publicsite screenshots
on:
push:
branches:
- dev
paths:
- archivebox/**
- bin/collect_ui_screenshots.*
- bin/generate_ui_screenshot_gallery.py
- bin/take_screenshot.js
- docs/Screenshots.md
- pyproject.toml
- uv.lock
- .github/workflows/screenshots.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('publicsite-captures-manual-{0}', github.run_id) || 'publicsite-captures' }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Setup Python and uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Install ArchiveBox and screenshot dependencies
run: uv sync --no-cache --frozen
- name: Build UI screenshot gallery
run: bin/collect_ui_screenshots.sh
env:
BASE_URL: http://archivebox.localhost:5797
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import re
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
assert all(not re.match(r"^\d+-", name) for name in provenance["files"]), provenance
PY
- name: Save validated screenshots
uses: actions/upload-artifact@v4
with:
name: site-screenshots
path: publicsite/screenshots
if-no-files-found: error
retention-days: 90
+49 -5
View File
@@ -1,7 +1,20 @@
name: Parallel Tests
on:
workflow_dispatch:
inputs:
test_file:
description: Run one discovered test file
required: true
default: archivebox/tests/test_cli_list.py
type: string
workflow_call:
inputs:
test_file:
description: Run one discovered test file; empty runs the complete matrix
required: false
default: ""
type: string
env:
PYTHONIOENCODING: utf-8
@@ -13,11 +26,25 @@ env:
jobs:
discover-tests:
name: Discover every test
# Selection must run on GitHub so an offline NAS cannot block the fallback.
runs-on: ubuntu-24.04
outputs:
tests: ${{ steps.discover.outputs.tests }}
runner: ${{ steps.runner.outputs.use-runner || '["ubuntu-24.04"]' }}
steps:
- name: Select available CI capacity
id: runner
if: vars.UGNAS_CI_ENABLED == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
uses: mikehardy/runner-fallback-action@dc7732f9e532c451b2527b288ba8d58fd4b0a4ca # v1.1.0
with:
github-token: ${{ secrets.RUNNER_STATUS_TOKEN }}
organization: ArchiveBox
primary-runner: self-hosted,ugnas-linux
fallback-runner: ubuntu-24.04
primaries-required: 1
fallback-on-error: true
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
@@ -58,10 +85,15 @@ jobs:
- name: Discover every test exactly once
id: discover
env:
TEST_FILE: ${{ inputs.test_file }}
run: |
set -Eeuo pipefail
mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py)
test "${#output[@]}" -eq 2
if [[ -n "$TEST_FILE" ]]; then
output[1]="$("$JQ_BINARY" -ce '[.[] | select(.paths == [env.TEST_FILE])] | select(length == 1)' <<< "${output[1]}")"
fi
echo "tests=${output[1]}" >> "$GITHUB_OUTPUT"
echo "${output[0]}"
"$JQ_BINARY" -e 'length > 0 and length <= 256 and ([.[].paths[]] | length == (unique | length))' <<< "${output[1]}"
@@ -69,13 +101,13 @@ jobs:
tests:
name: ${{ matrix.test.name }}
needs: discover-tests
runs-on: ubuntu-24.04
# Opt in trusted branches only; PRs always use GitHub-hosted runners.
# UGNAS_CI_TESTS is a JSON array of matrix names, e.g. ["main/test_cli_list"].
# The hosted discovery job falls back when workers are offline/busy or the
# availability API fails. UGNAS_CI_ENABLED=false disables the pool entirely.
runs-on: ${{ fromJSON(contains(fromJSON(vars.UGNAS_CI_TESTS || '[]'), matrix.test.name) && needs.discover-tests.outputs.runner || '["ubuntu-24.04"]') }}
env:
ABXPKG_LIB_DIR: /tmp/archivebox-lib
CHROME_HEADLESS: "true"
DATA_DIR: /tmp/archivebox-test-data
PERSONAS_DIR: /tmp/abx-personas
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
strategy:
fail-fast: false
@@ -83,6 +115,15 @@ jobs:
test: ${{ fromJson(needs.discover-tests.outputs.tests) }}
steps:
- name: Set job-local runtime paths
run: |
{
echo "ABXPKG_LIB_DIR=$RUNNER_TEMP/archivebox-lib"
echo "DATA_DIR=$RUNNER_TEMP/archivebox-test-data"
echo "PERSONAS_DIR=$RUNNER_TEMP/abx-personas"
echo "CHROME_USER_DATA_DIR=$RUNNER_TEMP/abx-personas/Default/chrome_profile"
} >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
@@ -132,6 +173,9 @@ jobs:
extra_args=(--extra "$TEST_EXTRA")
fi
uv sync --no-cache --locked --dev "${extra_args[@]}"
- name: Install Playwright Chromium for browser tests
if: contains(matrix.test.paths_arg, 'test_proof_previews') || contains(matrix.test.paths_arg, 'test_snapshot_output_stacks') || contains(matrix.test.paths_arg, 'test_ui_admin_snapshot') || contains(matrix.test.paths_arg, 'test_cli_extract')
run: uv run --no-cache --no-sync --no-sources playwright install chromium
- name: Run ${{ matrix.test.name }}
env:
+2
View File
@@ -68,3 +68,5 @@ ArchiveBox.conf
# Local Claude Code task locks (not project state)
.claude/scheduled_tasks.lock
/_site/
+2
View File
@@ -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
+8 -1
View File
@@ -15,6 +15,13 @@ ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
## Concurrency Contract
- A collection has one orchestrator at a time. Local PID/process checks may warn about obvious same-machine duplicates, but must not claim to enforce ownership across machines or shared filesystems.
- SQLite remains supported with concurrent short writes from CLI and server processes. Keep the SQLite database on a local filesystem, never NFS/SMB, and use short autocommit/CAS updates instead of long transactions or database locks.
- Network calls, hook execution, filesystem migrations, and other long work belong in the orchestrator. Never hold a database transaction or lock across that work.
- PostgreSQL and shared data directories are the path to future multi-machine scheduling. Coordinate that work through database-backed per-crawl/per-snapshot claims; do not introduce file leases or timer-based orchestrator election.
## Development Setup
```bash
@@ -77,4 +84,4 @@ uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_ta
uv run prek run --all-files
```
Releases are published only by `.github/workflows/release.yml` after the complete `dev` CI workflow succeeds. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
Releases are published only by `.github/workflows/release.yml` after complete CI succeeds for the exact commit. Pushes to `main` prepare stable patch releases automatically; `dev` prepares release candidates. Version selection and dependency repinning are owned by CI and the monorepo coordinator. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
+71 -20
View File
@@ -1,8 +1,38 @@
# syntax=docker/dockerfile:1.7
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
# ArchiveBox owns sonic, supervisor, Django, and the app runtime.
# WHY: abx-dl owns Python, Node, Chromium, and extraction dependencies so the
# standalone downloader stays independent of ArchiveBox's server features.
# ArchiveBox-only dependencies (Sonic, OpenCode, supervisor, Django) belong in
# the layers added by this Dockerfile, never in the shared abx-dl base image.
# REQUIRED INSTALLATION CONTRACT — do not work around failures:
# 1. IMAGE CONSTRUCTION: preinstall ALL plugins supported by runtime="archivebox"
# and ALL their required_binaries, regardless of default enabled state.
# Inherit the complete downloader dependencies from the tested abx-dl image;
# install every remaining ArchiveBox-only dependency in this image's layers,
# including OpenCode and Sonic. Never put server dependencies in abx-dl.
# Use the normal plugin dependency pipeline: install command -> config.json
# required_binaries -> abxpkg. Never add manual apt/pip/npm installs to hide
# failures in that pipeline. OS/interpreter bootstrap is a separate concern,
# not a second hand-maintained list of plugin dependencies.
# 2. FINISHED IMAGE: archivebox install and the full plugin installation check
# must discover everything already present and be NO-OPS offline as UID 911.
# No downloads, package installs, sudo, or root repair may be needed to enable
# ANY plugin or start a new crawl. Never run verification as root or manually
# preinstall packages before it. If the inherited image lacks a downloader
# dependency, fix/release abx-dl and consume that tested image; do not patch
# over the missing base dependency here or repair deployed containers by SSH.
# 3. NON-CONTAINER CI: exercise real runtime auto-installation through the same
# CLI/required_binaries/abxpkg path users invoke. GitHub's runner has
# passwordless sudo; abxpkg must escalate automatically when needed. Never
# manually preinstall dependencies or wrap the tested command in sudo to make
# a failure disappear. This is distinct from the finished-image no-op test.
# 4. SIZE: the complete compressed image budget is 1025 MiB. Preserve ALL
# user-facing functionality. Only mechanical/internal
# optimizations are allowed. Never remove dependencies, disable features or
# plugins, weaken assertions, or skip checks to meet a size limit. If the
# complete image exceeds the configured limit, LET CI FAIL and explicitly
# ask the maintainer what to do. Do not raise the limit unilaterally.
# Build abx-dl first, then point this file at it:
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
# --build-context abxbus=../abxbus \
@@ -10,10 +40,10 @@
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.225 \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:dev \
# -t archivebox:multistage
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.225
ARG ABX_DL_IMAGE=archivebox/abx-dl:main
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
@@ -47,6 +77,7 @@ ENV ARCHIVEBOX_USER=archivebox \
IN_DOCKER=True
ENV CODE_DIR=/app \
ABX_RUNTIME=archivebox \
DATA_DIR=/data \
CONFIG_DIR=/opt/archivebox \
ABXPKG_LIB_DIR=/opt/archivebox/lib \
@@ -96,17 +127,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
<<'EOF'
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
export UV_NO_CACHE=true
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update -qq
apt-get install -qq -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev
apt-get install -qq -y --no-install-recommends x11-utils
# Extend abx-dl's existing venv instead of clearing it. Clearing and later
# copying a complete replacement over the base duplicates every venv byte in
# the final overlay history even when most packages are unchanged.
/usr/bin/uv pip install --no-cache setuptools pip wheel
# Do not rewrite inherited native libraries merely to strip them again.
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print > /tmp/archivebox-inherited-libraries
mkdir -p /tmp/archivebox-uv-project
/venv/bin/python - <<'PY'
@@ -155,25 +184,31 @@ PY
--inexact \
--no-install-project \
--no-install-workspace \
--no-install-package abxbus \
--no-install-package abxpkg \
--no-install-package abx-plugins \
--no-install-package abx-dl \
--no-sources
builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
while IFS= read -r -d '' native_library; do
if grep -Fxq "$native_library" /tmp/archivebox-inherited-libraries; then
continue
fi
magic=''
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
"$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
fi
done < /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries /tmp/archivebox-inherited-libraries
rm -f /venv/bin/uv /venv/bin/uvx
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils build-essential gcc libldap2-dev libsasl2-dev libssl-dev
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
rm -rf "$builder_abxpkg_lib_dir"
rm -rf /venv/lib/python3.*/site-packages/pip* \
/venv/lib/python3.*/site-packages/wheel* \
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
echo 'Binary::apt::APT::Keep-Downloaded-Packages "0";' > /etc/apt/apt.conf.d/99keep-cache
rm -rf /var/lib/apt/lists/* /tmp/archivebox-uv-project
EOF
@@ -202,7 +237,7 @@ RUN echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
&& test -f "$(/venv/bin/python -c 'import archivebox; print(archivebox.__cached__)')" \
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
# The builder installs and purges compilers in one layer, so its final
# The builder installs and purges stripping tools in one layer, so its final
# filesystem is already runtime-clean. Preserve that ancestry: starting again
# from archivebox-runtime-base and COPYing /venv would bake a second full venv
# over the inherited one instead of recording only ArchiveBox's package delta.
@@ -225,7 +260,8 @@ COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
# The builder invokes abxpkg as root for temporary ELF stripping, which can
# rewrite derived state ownership. Restore UID 911 before resolving binaries
# as the runtime user; the old copy-based final stage hid this dependency.
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777,id=archivebox-runtime-config-$TARGETARCH \
echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
@@ -239,12 +275,27 @@ RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..."
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& find "$ABXPKG_LIB_DIR" \( ! -user "$DEFAULT_ARCHIVEBOX_UID" -o ! -group "$DEFAULT_ARCHIVEBOX_GID" \) -exec chown -h "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" {} + \
&& UV_NO_CACHE=true setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& openssl rand -hex 16 > /etc/machine-id \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
# Install the complete ArchiveBox catalog, including disabled server plugins.
# Explicit plugin names make installation independent of enabled defaults.
RUN --mount=type=cache,target=/var/tmp/abxpkg-cache,sharing=locked,mode=1777,id=archivebox-opencode-$TARGETARCH \
chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" /var/tmp/abxpkg-cache \
&& chmod 1777 /var/tmp/abxpkg-cache \
&& export ABX_DOCKER_PLUGINS="$(/venv/bin/python3 -c 'from abx_dl.catalog import PluginCatalog; print(" ".join(PluginCatalog.discover(runtime="archivebox")))')" \
&& env ABXPKG_TMP_CACHE_DIR=/var/tmp/abxpkg-cache XDG_CACHE_HOME=/var/tmp/abxpkg-cache \
setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abx-dl install $ABX_DOCKER_PLUGINS \
# pnpm includes musl variants that Debian's glibc runtime cannot use.
&& find "$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules" -type l -name 'opencode-linux-*-musl' -delete \
&& find "$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.pnpm" -maxdepth 1 -type d -name 'opencode-linux-*-musl@*' -exec rm -rf {} +
RUN --network=none export ABX_DOCKER_PLUGINS="$(/venv/bin/python3 -c 'from abx_dl.catalog import PluginCatalog; print(" ".join(PluginCatalog.discover(runtime="archivebox")))')" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups \
bash -c '"$ABXPKG_LIB_DIR/pnpm/packages/opencode/node_modules/.bin/opencode" --version && abx-dl install $ABX_DOCKER_PLUGINS'
WORKDIR "$DATA_DIR"
RUN echo "[+] Initializing image collection..." \
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
@@ -277,10 +328,10 @@ RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully
WORKDIR "$DATA_DIR"
VOLUME "$DATA_DIR"
EXPOSE 8000
EXPOSE 5797
HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:8000/health/' | grep -q 'OK'
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:5797/health/' | grep -q 'OK'
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
CMD ["archivebox", "server", "--init", "0.0.0.0:8000"]
CMD ["archivebox", "server", "--init", "0.0.0.0:5797"]
+67 -50
View File
@@ -73,15 +73,15 @@ mkdir -p ~/archivebox/data && cd ~/archivebox
curl -fsSL 'https://docker-compose.archivebox.io' > docker-compose.yml
docker compose pull
docker compose up -d --wait # initializes new collections automatically
# open http://admin.archivebox.localhost:8000 to finish setup
# open http://admin.archivebox.localhost:5797 to finish setup
# docker compose run --rm archivebox add 'https://example.com'
# docker compose run --rm archivebox help
<br/>
<br/>
# Option B: Or use it as a plain Docker container:
mkdir -p ~/archivebox/data && cd ~/archivebox/data
docker run -d --name archivebox -v "$PWD:/data" -p 8000:8000 archivebox/archivebox:dev
# open http://admin.archivebox.localhost:8000 to finish setup
docker run -d --name archivebox -v "$PWD:/data" -p 5797:5797 archivebox/archivebox:dev
# open http://admin.archivebox.localhost:5797 to finish setup
# docker run -it -v $PWD:/data archivebox/archivebox:dev add 'https://example.com'
# docker run -it -v $PWD:/data archivebox/archivebox:dev help
<br/>
@@ -93,14 +93,14 @@ archivebox init
archivebox install
# archivebox add 'https://example.com'
# archivebox help
# archivebox server 0.0.0.0:8000
# archivebox server 0.0.0.0:5797
<br/>
<br/>
# Option D: Or use the uv install shortcut for Option C
curl -fsSL 'https://get.archivebox.io' | bash
</code></pre>
<br/>
<sub>Open <a href="http://web.archivebox.localhost:8000"><code>http://web.archivebox.localhost:8000</code></a> for the public UI and <a href="http://admin.archivebox.localhost:8000"><code>http://admin.archivebox.localhost:8000</code></a> for the admin UI ➡️</sub><br/>
<sub>Open <a href="http://web.archivebox.localhost:5797"><code>http://web.archivebox.localhost:5797</code></a> for the public UI and <a href="http://admin.archivebox.localhost:5797"><code>http://admin.archivebox.localhost:5797</code></a> for the admin UI ➡️</sub><br/>
<sub>Set <code>BASE_URL</code> to change the public base domain. The default <code>auto</code> mode uses <code>web.</code> and <code>admin.</code> subdomains on <code>*.localhost</code>, but one host for ordinary DNS names. <code>BIND_ADDR</code> only controls the local listen address.</sub>
</details>
<br/>
@@ -129,10 +129,10 @@ curl -fsSL 'https://get.archivebox.io' | bash
- [**Extracts a wide variety of content out-of-the-box**](https://github.com/ArchiveBox/ArchiveBox/issues/51): [media (yt-dlp), articles (readability), code (git), etc.](#output-formats)
- [**Supports scheduled/realtime importing**](https://github.com/ArchiveBox/ArchiveBox/wiki/Scheduled-Archiving) from [many types of sources](#input-formats)
- [**Uses standard, durable, long-term formats**](#output-formats) like HTML, JSON, PDF, PNG, MP4, TXT, and WARC
- [**Powerful CLI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#CLI-Usage), [**self-hosted web UI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#UI-Usage), [Python API](https://docs.archivebox.io/en/dev/apidocs/archivebox/archivebox.html) (BETA), [REST API](https://github.com/ArchiveBox/ArchiveBox/issues/496) (ALPHA), or [desktop app](https://github.com/ArchiveBox/electron-archivebox)
- [**Powerful CLI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#CLI-Usage), [**self-hosted web UI**](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#UI-Usage), [Python API](https://docs.archivebox.io/en/dev/apidocs/archivebox/archivebox.html) (BETA), [REST API](https://github.com/ArchiveBox/ArchiveBox/issues/496) (ALPHA), or [desktop app](https://electron.archivebox.io/)
- [**Saves all pages to archive.org as well**](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#save_archive_dot_org) by default for redundancy (can be [disabled](https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#stealth-mode) for local-only mode)
- Advanced users: support for archiving [content requiring login/paywall/cookies](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#chrome_user_data_dir) (see wiki security caveats!)
- Planned: support for running [JS during archiving](https://github.com/ArchiveBox/ArchiveBox/issues/51) to adblock, [autoscroll](https://github.com/ArchiveBox/ArchiveBox/issues/80), [modal-hide](https://github.com/ArchiveBox/ArchiveBox/issues/175), [thread-expand](https://github.com/ArchiveBox/ArchiveBox/issues/345)
- Support for archiving [content requiring login/paywall/cookies](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#chrome_user_data_dir) (see wiki security caveats!)
- Support for running [JS during archiving](https://github.com/ArchiveBox/ArchiveBox/issues/51) to adblock, [infiniscroll](https://github.com/ArchiveBox/ArchiveBox/issues/80), [modal-hide](https://github.com/ArchiveBox/ArchiveBox/issues/175), [thread-expand](https://github.com/ArchiveBox/ArchiveBox/issues/345)
<br/>
@@ -151,6 +151,17 @@ curl -fsSL 'https://get.archivebox.io' | bash
#### ✳️&nbsp; Easy Setup
<details>
<summary><b><img src="https://app.archivebox.io/docs/icons/apple.svg" alt="Apple" height="28px" align="top"/> <code>ArchiveBox Server.app</code></b> (macOS)</summary>
<br/>
<ol>
<li><a href="https://github.com/ArchiveBox/ios-archivebox/releases">Download <code>ArchiveBox Server.app</code></a>, move it to Applications, and open it.</li>
<li>Set up an admin account and generate an API Key.</li>
<li>Use the Web UI or <a href="https://app.archivebox.io/"><code>ArchiveBox.app</code></a>, and optionally connect REST API clients, the <a href="https://app.archivebox.io/">mobile app</a>, the <a href="https://extension.archivebox.io/">browser extension</a>, and other integrations to your server using your API Key.</li>
</ol>
<br/>
</details>
<details>
<summary><b><img src="https://user-images.githubusercontent.com/511499/117447182-29758200-af0b-11eb-97bd-58723fee62ab.png" alt="Docker" height="28px" align="top"/> <code>docker-compose</code></b> (macOS/Linux) &nbsp; <b>👈&nbsp; recommended</b> &nbsp; <i>(click to expand)</i></summary>
<br/>
@@ -166,7 +177,7 @@ docker compose pull
<li>Start the server, which initializes a new collection automatically.
<pre lang="bash"><code style="white-space: pre-line">docker compose up -d --wait
</code></pre></li>
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
<pre lang="bash"><code style="white-space: pre-line">
# run CLI commands inside the server container started above
docker compose exec archivebox archivebox add 'https://example.com'
@@ -187,10 +198,10 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the C
<li>Install <a href="https://docs.docker.com/get-docker/">Docker</a> on your system (if not already installed).</li>
<li>Create a new empty directory and start the server, which initializes the collection automatically (can be anywhere).
<pre lang="bash"><code style="white-space: pre-line">mkdir -p ~/archivebox/data && cd ~/archivebox/data
docker run -d --name archivebox -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
docker run -d --name archivebox -v $PWD:/data -p 5797:5797 archivebox/archivebox:dev
</code></pre>
</li>
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
<li>Open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the web setup wizard.
<pre lang="bash"><code style="white-space: pre-line">
# completely optional, CLI can always be used without running a server
# docker exec archivebox archivebox [subcommand] [--help]
@@ -245,8 +256,8 @@ archivebox init # initialize a new collection
archivebox install # install all the runtime dependencies (e.g. chrome, single-file, yt-dlp, etc.)
</code></pre>
</li>
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
# completely optional, CLI can always be used without running a server
# archivebox [subcommand] [--help]
archivebox help
@@ -281,8 +292,8 @@ archivebox add 'https://example.com'
</code></pre>
<br/>
</li>
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
# completely optional, CLI can always be used without running a server
# archivebox [subcommand] [--help]
archivebox help
@@ -314,8 +325,8 @@ archivebox init
archivebox install
</code></pre>
</li>
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:8000/admin/">http://admin.archivebox.localhost:8000/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:8000
<li>Optionally start the server, then open <code>/admin/</code> on the hostname or IP used to reach ArchiveBox (local example: <a href="http://admin.archivebox.localhost:5797/admin/">http://admin.archivebox.localhost:5797/admin/</a>) to create the first admin. If <code>BASE_URL</code> is not configured yet, continue through the same web setup wizard used by Docker installs.
<pre lang="bash"><code style="white-space: pre-line">archivebox server 0.0.0.0:5797
# completely optional, CLI can always be used without running a server
# archivebox [subcommand] [--help]
archivebox help
@@ -354,17 +365,12 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, W
<br/>
<ol>
<li>Install <a href="https://docs.docker.com/get-docker/">Docker</a> on your system (if not already installed).</li>
<li>Download a binary release for your OS or build the native app from source<br/>
<ul>
<li>macOS: <a href="https://github.com/ArchiveBox/ArchiveBox/releases/download/v0.6.2/Electron-ArchiveBox-macOS-x64-0.6.2.app.zip" download><code>ArchiveBox.app.zip</code></a></li>
<li>Linux: <code>ArchiveBox.deb</code> (alpha: <a href="https://github.com/ArchiveBox/electron-archivebox#quickstart">build manually</a>)</li>
<li>Windows: <code>ArchiveBox.exe</code> (beta: <a href="https://github.com/ArchiveBox/electron-archivebox#quickstart">build manually</a>)</li>
</ul>
</li>
<li><a href="https://electron.archivebox.io/#get-started">Download the latest installer for Windows, Linux, or Mac</a>.</li>
<li>Install and open ArchiveBox Desktop, then create your local administrator account to start archiving.</li>
</ol>
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/575ef92f-bb3e-4a7c-a4ba-986c1fd76ecf" width="320px">
<br/>
<i>✨ Alpha (contributors wanted!)</i>: for more info, see the: <a href="https://github.com/ArchiveBox/electron-archivebox">Electron ArchiveBox</a> repo.
<a href="https://electron.archivebox.io/">Desktop app guide</a> · <a href="https://electron.archivebox.io/screenshots/">Screenshots</a>
<br/>
</details>
@@ -426,7 +432,7 @@ For more discussion on third-party hosting options see here: <a href="https://gi
#### ➡️&nbsp; Next Steps
- Import URLs from some of the supported [Input Formats](#input-formats) or view the supported [Output Formats](#output-formats)...
- (Optional) Create a persona and import browser cookies to archive logged-in sites: `archivebox persona create --import=chrome personal`
- (Optional) [Import browser cookies and settings into a persona](docs/Chromium-Install.md#import-an-existing-browser-profile).
- Tweak your UI or archiving behavior [Configuration](#configuration), read about some of the [Caveats](#caveats), or [Troubleshoot](https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting)
- Read about the [Dependencies](#dependencies) used for archiving, the [Upgrading Process](https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives), or the [Archive Layout](#archive-layout) on disk...
- Or check out our full [Documentation](#documentation) or [Community Wiki](#internet-archiving-ecosystem)...
@@ -454,11 +460,6 @@ docker compose run --rm archivebox help
# equivalent: docker run -it -v $PWD:/data archivebox/archivebox:dev [subcommand] [--help]
docker run -it -v $PWD:/data archivebox/archivebox:dev help
# optional: import your browser cookies into a persona for logged-in archiving
archivebox persona create --import=chrome personal
# supported: chrome/chromium/brave/edge (Chromium-based only)
# use --profile to target a specific profile (e.g. Default, Profile 1)
# re-running import merges/dedupes cookies.txt (by domain/path/name) but replaces chrome_user_data
```
#### ArchiveBox Subcommands
@@ -538,16 +539,16 @@ find ./archive/users -path '*/snapshots/*/*/*/index.html' # inspect snapshot da
<summary><b>🖥&nbsp; Web UI & API Usage</b></summary>
<pre lang="bash"><code style="white-space: pre-line">
# Start the server on bare metal (uv/apt/brew):
archivebox server 0.0.0.0:8000 # start the server
archivebox server 0.0.0.0:5797 # start the server
<br/>
# Or with Docker Compose:
docker compose up # start the server
<br/>
# Or with a Docker container:
docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox:dev
docker run -v $PWD:/data -it -p 5797:5797 archivebox/archivebox:dev
</code></pre>
<sup>Open <a href="http://admin.archivebox.localhost:8000/admin/"><code>http://admin.archivebox.localhost:8000/admin/</code></a> to create the first admin and finish web setup. Use <a href="http://web.archivebox.localhost:8000"><code>http://web.archivebox.localhost:8000</code></a> for the public UI. ➡️</sup><br/>
<sup>Open <a href="http://admin.archivebox.localhost:5797/admin/"><code>http://admin.archivebox.localhost:5797/admin/</code></a> to create the first admin and finish web setup. Use <a href="http://web.archivebox.localhost:5797"><code>http://web.archivebox.localhost:5797</code></a> for the public UI. ➡️</sup><br/>
<sup>Advanced: <code>archivebox manage createsuperuser</code> remains available for creating accounts from the CLI.</sup><br/>
<sup>Set <code>BASE_URL</code> to change the public base domain. The default <code>auto</code> mode uses <code>web.</code> and <code>admin.</code> subdomains on <code>*.localhost</code>, but one host for ordinary DNS names. <code>BIND_ADDR</code> only controls the local listen address.</sup>
<br/><br/>
@@ -564,6 +565,22 @@ docker compose run --rm archivebox config --set PERMISSIONS=public
# restart the server to apply any config changes
</code></pre>
Snapshot caching follows each snapshot's saved `PERMISSIONS`, including inherited defaults and snapshot overrides. Public content can be cached by browsers and shared proxies/CDNs; private and unlisted content uses `Cache-Control: private` so only the user's browser can cache it. Both retain normal artifact cache lifetimes and validators for fast repeat viewing. Authentication handoffs are not stored. Changing permissions does not revoke copies already cached under the previous policy; purge any affected shared cache when needed.
</details>
<br/>
<details>
<summary><b><img src="https://app.archivebox.io/docs/icons/apple.svg" alt="Apple" height="22px" align="top"/> <img src="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.17.0/icons/windows11/windows11-original.svg" alt="Windows" height="22px" align="top"/> 🐧&nbsp; Desktop / Mobile / Extension Usage</b></summary>
<br/>
- 📱 [<code>ArchiveBox</code> iOS Mobile App](https://app.archivebox.io/)
- 🖥️ [<code>ArchiveBox.app</code> macOS Desktop App](https://app.archivebox.io/)
- 🧩 [ArchiveBox Browser Extension](https://extension.archivebox.io/)
- 💻 [Linux/Windows Desktop App (Requires Docker Desktop)](https://electron.archivebox.io/)
<br/>
</details>
<br/>
@@ -619,7 +636,10 @@ docker run -it -v $PWD:/data archivebox/archivebox:dev add --depth=1 'https://ex
- <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/ff20d251-5347-4b85-ae9b-83037d0ac01e" height="28px"/> <b>From the official <a href="https://github.com/ArchiveBox/archivebox-extension">ArchiveBox Browser Extension</a></b>
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox browsers.</i>
<i>Provides realtime archiving of browsing history or selected pages from Chrome/Chromium/Firefox/Safari browsers.</i>
- <img src="https://app.archivebox.io/App/AppIcon.icon/Assets/ArchiveBox.png" alt="ArchiveBox app icon" height="28px"/> <b>From the official <a href="https://app.archivebox.io/">ArchiveBox macOS Desktop App or iOS Mobile App</a></b><br/>
<i>Save directly from the Share menu or Siri/Shortcuts to your server.</i>
- <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/64078483-21d7-4eb1-aa6e-9ad55afe45b8" height="22px"/> From manual imports of URLs from RSS, JSON, CSV, TXT, SQL, HTML, Markdown, etc. files
<i>ArchiveBox supports injesting URLs in [any text-based format](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Import-a-list-of-URLs-from-a-text-file).</i>
@@ -932,7 +952,7 @@ archivebox manage createsuperuser
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive">Wiki: Publishing Your Archive</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Wiki: Security Overview</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#setting-up-a-chromium-user-profile">Wiki: Chromium Install (Setting Up a User Profile)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Personas">Wiki: Personas (browser profiles and cookies)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#import-an-existing-browser-profile">Wiki: Chrome profiles and cookies</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#default_persona">Wiki: Configuration (<code>DEFAULT_PERSONA</code>)</a></li>
</ul>
@@ -985,14 +1005,11 @@ For various reasons, many large sites (Reddit, Twitter, Cloudflare, etc.) active
<ul>
<li>Set <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#curl_user_agent"><code>CHROME_USER_AGENT</code>, <code>WGET_USER_AGENT</code>, <code>CURL_USER_AGENT</code></a> to impersonate a real browser (by default, ArchiveBox reveals that it's a bot when using the default user agent settings)</li>
<li>Set up a logged-in browser session for archiving by <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Personas">importing a dedicated browser profile into a persona</a></li>
<li>Set up a logged-in browser session for archiving by <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Chromium-Install#import-an-existing-browser-profile">importing a chrome profile into a persona</a></li>
<li>Rewrite your URLs before archiving to swap in alternative frontends that are more bot-friendly e.g.<br>
<code>reddit.com/some/url</code> -&gt; <code>teddit.net/some/url</code>: <a href="https://github.com/mendel5/alternative-front-ends">https://github.com/mendel5/alternative-front-ends</a></li>
</ul>
In the future we plan on adding support for running JS scripts during archiving to block ads, cookie popups, modals, and fix other issues. Follow here for progress: <a href="https://github.com/ArchiveBox/ArchiveBox/issues/51">Issue #51</a>.
</details>
<br/>
@@ -1041,8 +1058,8 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
</li>
<li><strong>Don't store large collections on older filesystems like EXT3/FAT</strong> as they may not be able to handle more than 50k directory entries in the <code>data/archive/</code> folder.
</li>
<li><strong>Try to keep the <code>data/index.sqlite3</code> file on local drive (not a network mount)</strong> or SSD for maximum performance, however the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, make sure the mounted data directory is writable by its intended owner and consider disabling <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304"><code>root_squash</code></a> on your fileshare server.
<li><strong>Keep the <code>data/index.sqlite3</code> file on a reliable local drive (not a network mount)</strong> or SSD when using SQLite; the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, configure the server-side UID/GID mapping or ACL so ArchiveBox's non-root user can create and remove files. <code>root_squash</code> is compatible when the selected non-root identity has write access; do not rely on container root to override server-side permissions.
</li>
</ul>
@@ -1323,10 +1340,10 @@ archivebox init
archivebox install # detect and install all extractor dependencies
# Run the development server w/ autoreloading (but no bg workers)
archivebox server --debug --reload 0.0.0.0:8000
archivebox server --debug --reload 0.0.0.0:5797
# Run the production server (with bg workers but no autoreloading)
archivebox server 0.0.0.0:8000
archivebox server 0.0.0.0:5797
```
#### 2. Option B: Build the docker container and use that for development instead
@@ -1338,10 +1355,10 @@ archivebox server 0.0.0.0:8000
./bin/build_docker.sh dev
# Run the development server w/ autoreloading (but no bg workers)
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init --debug --reload 0.0.0.0:8000
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 5797:5797 archivebox/archivebox:dev server --init --debug --reload 0.0.0.0:5797
# Run the production server (with bg workers but no autoreloading)
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --init
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 5797:5797 archivebox/archivebox:dev server --init
# (remove the --reload flag and add the --nothreading flag when profiling with the django debug toolbar)
# When using --reload, make sure any files you create can be read by the user in the Docker container, eg with 'chmod a+rX'.
@@ -1363,11 +1380,11 @@ You can also run all these in Docker. For more examples see the GitHub Actions C
archivebox config --set DEBUG=True
# OR you can run a dev server with DEBUG=True in a few ways:
archivebox server --debug --reload 0.0.0.0:8000
archivebox server --debug --reload 0.0.0.0:5797
# or
archivebox server --debug 0.0.0.0:8000
archivebox server --debug 0.0.0.0:5797
# or
env DEBUG=True daphne -b 0.0.0.0 -p 8000 archivebox.core.asgi:application
env DEBUG=True daphne -b 0.0.0.0 -p 5797 archivebox.core.asgi:application
```
https://stackoverflow.com/questions/1074212/how-can-i-see-the-raw-sql-queries-django-is-running
@@ -1402,7 +1419,7 @@ services:
# or with plain Docker:
docker build -t archivebox:dev https://github.com/ArchiveBox/ArchiveBox.git#dev
docker run -it -v $PWD:/data -p 8000:8000 archivebox:dev
docker run -it -v $PWD:/data -p 5797:5797 archivebox:dev
# or with uv:
uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
+5
View File
@@ -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
+42 -1
View File
@@ -4,7 +4,7 @@ from django.http import HttpRequest
from ninja import Router, Schema
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token, HeaderTokenAuth, BearerTokenAuth
router = Router(tags=["Authentication"], auth=None)
@@ -63,3 +63,44 @@ def check_api_token(request: HttpRequest, token_data: TokenAuthSchema):
return {"success": True, "user_id": str(user.pk)}
return {"success": False, "user_id": None}
@router.post(
"/browser_session",
auth=[HeaderTokenAuth(), BearerTokenAuth()],
summary="Exchange an administrator API key for a normal browser session",
)
def browser_session(request: HttpRequest):
from django.conf import settings
from django.contrib.auth import login
from django.http import JsonResponse
from django.utils import timezone
from ninja.errors import HttpError
from archivebox.core.routes_util import get_admin_base_url
user = request.auth
if not user.is_active or not user.is_superuser:
raise HttpError(403, "An active administrator is required")
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
# A browser session must not outlive the key used to establish it.
token = request._api_token
lifetime = settings.SESSION_COOKIE_AGE
if token.expires:
lifetime = min(lifetime, max(0, int((token.expires - timezone.now()).total_seconds())))
request.session.set_expiry(lifetime)
request.session.save()
response = JsonResponse(
{
"admin_url": get_admin_base_url(request=request).rstrip("/") + "/admin/",
"cookie": {
"name": settings.SESSION_COOKIE_NAME,
"value": request.session.session_key,
"expires": request.session.get_expiry_date().timestamp(),
"secure": settings.SESSION_COOKIE_SECURE,
},
},
)
# The native client installs a host-only HttpOnly cookie on the admin origin;
# API and admin hosts can differ, so a Set-Cookie on the API host is insufficient.
response["Cache-Control"] = "no-store"
return response
+10 -1
View File
@@ -133,6 +133,15 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
validate_url(args.urls[0])
except ValueError:
submitted_urls = args.urls[0]
else:
submitted_urls = []
for url in args.urls:
try:
submitted_urls.append(validate_url(url))
except ValueError:
continue
if not submitted_urls:
raise HttpError(400, "No valid URLs were submitted")
crawl, snapshots = add(
urls=submitted_urls,
snapshot_ids=args.snapshot_ids,
@@ -156,7 +165,7 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
"crawl_id": str(crawl.id),
"num_snapshots": len(snapshot_ids),
"snapshot_ids": snapshot_ids,
"queued_urls": args.urls,
"queued_urls": args.urls if isinstance(submitted_urls, str) else submitted_urls,
}
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
+125 -136
View File
@@ -11,7 +11,7 @@ from uuid import UUID
from typing import Union, Any, Annotated
from datetime import datetime, time
from django.db import transaction
from django.db import IntegrityError
from django.db.models import Model, Q
from django.http import HttpRequest, HttpResponse
from django.http.multipartparser import MultiPartParser, MultiPartParserError
@@ -32,7 +32,7 @@ from archivebox.core.models import Snapshot, ArchiveResult, Tag
from archivebox.core.permissions import public_snapshots_queryset
from archivebox.api.auth import authenticated_user_from_request
from archivebox.config.common import get_config
from archivebox.core.routes_util import build_web_url
from archivebox.core.routes_util import build_snapshot_detail_url, build_web_url
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length
from archivebox.core.tag_util import (
add_snapshot_counts,
@@ -247,7 +247,7 @@ def get_archiveresult(request: HttpRequest, archiveresult_id: str):
def _normalize_uploaded_archiveresult_plugin(plugin: str) -> str:
normalized = str(plugin or "").strip().strip("/")
if not ARCHIVERESULT_UPLOAD_PLUGIN_RE.fullmatch(normalized):
if normalized in (".", "..") or not ARCHIVERESULT_UPLOAD_PLUGIN_RE.fullmatch(normalized):
raise HttpError(400, "Invalid ArchiveResult plugin name")
return normalized
@@ -342,21 +342,10 @@ def _parse_archiveresult_upload_int(value: str, field_name: str, *, default: int
def _summarize_archiveresult_output_files(output_files: dict[str, dict[str, Any]]) -> tuple[int, str]:
mime_sizes: dict[str, int] = defaultdict(int)
total_size = 0
for metadata in output_files.values():
if not isinstance(metadata, dict):
continue
try:
size = max(int(metadata.get("size") or 0), 0)
except (TypeError, ValueError):
size = 0
mime_type = str(metadata.get("mimetype") or "").strip()
total_size += size
if mime_type and size:
mime_sizes[mime_type] += size
output_mimetypes = ",".join(mime for mime, _size in sorted(mime_sizes.items(), key=lambda item: item[1], reverse=True))
return total_size, output_mimetypes
from abx_dl.output_files import OutputManifest
manifest = OutputManifest.from_value(output_files)
return manifest.total_size, ",".join(manifest.mimetypes)
def _get_snapshot_by_ref(snapshot_id: str):
@@ -397,6 +386,7 @@ def _write_archiveresult_files(
existing_output_files: dict[str, dict[str, Any]] | None = None,
allow_empty: bool = False,
) -> dict[str, dict[str, Any]]:
plugin_name = _normalize_uploaded_archiveresult_plugin(plugin_name)
files = _get_archiveresult_upload_files(request, allow_empty=allow_empty)
output_paths = _get_archiveresult_upload_form_values(request, "output_paths", "output_path")
mime_types = _get_archiveresult_upload_form_values(request, "mime_types", "mime_type")
@@ -528,52 +518,53 @@ def create_archiveresult(
normalized_status = ArchiveResult.normalize_status(status)
parsed_output_json = _parse_archiveresult_output_json(output_json)
hook = hook_name or ARCHIVERESULT_UPLOAD_HOOK_NAME
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
existing_output_files = dict(existing_result.output_files or {}) if existing_result else {}
output_files = _write_archiveresult_files(
result_lookup = {
"snapshot": snapshot,
"plugin": plugin_name,
"hook_name": hook,
}
uploaded_output_files = _write_archiveresult_files(
request,
snapshot,
plugin_name,
existing_output_files=existing_output_files,
allow_empty=True,
)
now = timezone.now()
with transaction.atomic():
Snapshot.objects.select_for_update().get(pk=snapshot.pk)
existing_result = ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin_name).first()
if existing_result:
output_files = {
**dict(existing_result.output_files or {}),
**output_files,
}
result = existing_result
else:
existing_result = None
result = ArchiveResult(
snapshot=snapshot,
plugin=plugin_name,
hook_name=hook,
)
if (
existing_result
and normalized_status == ArchiveResult.StatusChoices.STARTED
and existing_result.status != ArchiveResult.StatusChoices.STARTED
):
normalized_status = existing_result.status
result = ArchiveResult.objects.filter(**result_lookup).first()
for _attempt in range(3):
output_files = {
**(result.output_file_map() if result else {}),
**uploaded_output_files,
}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
output_file_paths = list(output_files.keys())
result.status = normalized_status
result.hook_name = hook
result.output_str = output_str or (output_file_paths[0] if output_file_paths else "")
result.output_json = parsed_output_json
result.output_files = output_files
result.output_size = output_size
result.output_mimetypes = output_mimetypes
result.start_ts = result.start_ts or now
result.end_ts = now
result.save()
result_status = normalized_status
if result and result_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
result_status = result.status
now = timezone.now()
values = {
"status": result_status,
"output_str": output_str or (output_file_paths[0] if output_file_paths else ""),
"output_json": parsed_output_json,
"output_files": output_files,
"output_size": output_size,
"output_mimetypes": output_mimetypes,
"start_ts": result.start_ts or now if result else now,
"end_ts": now,
}
if result:
if result.safe_update(values):
break
continue
result, created = ArchiveResult.get_or_create_by_hook(
snapshot,
plugin_name,
hook,
defaults=values,
)
if created:
break
else:
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
if result.status != ArchiveResult.StatusChoices.STARTED:
_queue_archiveresult_snapshot_maintenance(snapshot)
@@ -587,44 +578,46 @@ def patch_archiveresult(
):
"""Append or replace files on an existing ArchiveResult."""
result = ArchiveResult.objects.select_related("snapshot__crawl__created_by").get(_uuid_ref_query("id", archiveresult_id))
output_files = _write_archiveresult_files(
uploaded_output_files = _write_archiveresult_files(
request,
result.snapshot,
result.plugin,
existing_output_files=result.output_file_map(),
)
latest_result = ArchiveResult.objects.only("output_files", "status").get(pk=result.pk)
output_files = {
**latest_result.output_file_map(),
**output_files,
}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
update_fields = ["output_files", "output_size", "output_mimetypes", "end_ts", "modified_at"]
result.output_files = output_files
result.output_size = output_size
result.output_mimetypes = output_mimetypes
result.end_ts = timezone.now()
output_str = _get_archiveresult_upload_form_value(request, "output_str")
status = _get_archiveresult_upload_form_value(request, "status")
output_json = _get_archiveresult_upload_form_value(request, "output_json")
if output_str:
result.output_str = output_str
update_fields.append("output_str")
if status:
normalized_status = ArchiveResult.normalize_status(status)
if normalized_status == ArchiveResult.StatusChoices.STARTED and latest_result.status != ArchiveResult.StatusChoices.STARTED:
normalized_status = latest_result.status
result.status = normalized_status
update_fields.append("status")
elif latest_result.status == ArchiveResult.StatusChoices.QUEUED and ArchiveResult.output_files_upload_complete(output_files):
result.status = ArchiveResult.StatusChoices.SUCCEEDED
update_fields.append("status")
if output_json:
result.output_json = _parse_archiveresult_output_json(output_json)
update_fields.append("output_json")
parsed_output_json = _parse_archiveresult_output_json(output_json) if output_json else None
if not ArchiveResult.output_files_upload_complete(uploaded_output_files):
result.output_files = {**result.output_file_map(), **uploaded_output_files}
result.output_size, result.output_mimetypes = _summarize_archiveresult_output_files(result.output_files)
return result
for _attempt in range(3):
output_files = {**result.output_file_map(), **uploaded_output_files}
output_size, output_mimetypes = _summarize_archiveresult_output_files(output_files)
values: dict[str, Any] = {
"output_files": output_files,
"output_size": output_size,
"output_mimetypes": output_mimetypes,
"end_ts": timezone.now(),
}
if output_str:
values["output_str"] = output_str
if status:
normalized_status = ArchiveResult.normalize_status(status)
if normalized_status == ArchiveResult.StatusChoices.STARTED and result.status != ArchiveResult.StatusChoices.STARTED:
normalized_status = result.status
values["status"] = normalized_status
elif result.status == ArchiveResult.StatusChoices.QUEUED:
values["status"] = ArchiveResult.StatusChoices.SUCCEEDED
if output_json:
values["output_json"] = parsed_output_json
if result.safe_update(values):
break
else:
raise HttpError(409, "ArchiveResult changed while upload metadata was being updated")
result.save(update_fields=update_fields)
if result.status != ArchiveResult.StatusChoices.STARTED:
_queue_archiveresult_snapshot_maintenance(result.snapshot)
@@ -807,7 +800,7 @@ def _snapshots_rss_response(
)
for snapshot in snapshots:
archived_url = build_web_url(f"/{snapshot.archive_path_from_db}", request=request)
archived_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
tags = [tag.name for tag in snapshot.tags.all()]
crawl_user = snapshot.crawl.created_by if snapshot.crawl_id else None
description = f"Original URL: {snapshot.url}\nArchived snapshot: {archived_url}"
@@ -941,52 +934,47 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
created_by=request.user if isinstance(request.user, User) else None,
)
# Browser clients sync metadata and upload capture artifacts immediately
# after queueing a URL. The runner holds the crawl lifecycle lock for the
# whole crawl, so idempotently posting an already-created snapshot must not
# wait behind that potentially long-running archive job.
existing_snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if existing_snapshot is not None and (status is None or existing_snapshot.status == status):
if data.title is not None and existing_snapshot.title != data.title:
Snapshot.objects.filter(pk=existing_snapshot.pk).update(title=data.title, modified_at=timezone.now())
existing_snapshot.title = data.title
if tags:
existing_snapshot.save_tags(tags)
setattr(request, "with_archiveresults", False)
return existing_snapshot
# Browser uploads must not wait behind the runner's crawl-wide lifecycle
# lock. The unique insert recovery and CAS update below are the request-side
# coordination boundary for this idempotent metadata sync.
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if snapshot is None:
try:
snapshot = Snapshot.objects.create(
url=data.url,
crawl=crawl,
depth=data.depth,
title=data.title,
timestamp=str(timezone.now().timestamp()),
status=status or Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
except IntegrityError:
snapshot = Snapshot.objects.filter(url=data.url, crawl=crawl).first()
if snapshot is None:
raise
with crawl_lifecycle_lock(str(crawl.id)):
snapshot_defaults = {
"depth": data.depth,
"title": data.title,
"timestamp": str(timezone.now().timestamp()),
"status": status or Snapshot.StatusChoices.QUEUED,
"retry_at": timezone.now(),
}
snapshot, _ = Snapshot.objects.get_or_create(
url=data.url,
crawl=crawl,
defaults=snapshot_defaults,
for _attempt in range(3):
updates: dict[str, Any] = {}
if data.title is not None and snapshot.title != data.title:
updates["title"] = data.title
if status is not None and snapshot.status != status:
updates["status"] = status
if not updates or snapshot.safe_update(updates, extra_filter={"modified_at": snapshot.modified_at}):
break
else:
raise HttpError(409, "Snapshot changed while metadata was being updated")
if tags:
snapshot.save_tags(
tags,
created_by=request.user if isinstance(request.user, User) else None,
)
update_fields: list[str] = []
if data.title is not None and snapshot.title != data.title:
snapshot.title = data.title
update_fields.append("title")
if status is not None and snapshot.status != status:
snapshot.status = status
update_fields.append("status")
if update_fields:
update_fields.append("modified_at")
snapshot.save(update_fields=update_fields)
if tags:
snapshot.save_tags(tags)
try:
snapshot.ensure_crawl_symlink()
except Exception:
pass
try:
snapshot.ensure_crawl_symlink()
except Exception:
pass
setattr(request, "with_archiveresults", False)
return snapshot
@@ -1034,7 +1022,10 @@ def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateS
update_fields.append("retry_at")
if tags is not None:
snapshot.save_tags(normalize_tag_list(tags))
snapshot.save_tags(
normalize_tag_list(tags),
created_by=request.user if isinstance(request.user, User) else None,
)
if payload.get("status") == Snapshot.StatusChoices.SEALED:
snapshot.cancel()
@@ -1421,8 +1412,7 @@ def tags_add_to_snapshot(request: HttpRequest, data: TagSnapshotRequestSchema):
else:
raise HttpError(400, "Either tag_name or tag_id is required")
# Add the tag to the snapshot
snapshot.tags.add(tag.pk)
snapshot.add_tag_ids([tag.pk])
return {
"success": True,
@@ -1450,8 +1440,7 @@ def tags_remove_from_snapshot(request: HttpRequest, data: TagSnapshotRequestSche
else:
raise HttpError(400, "Either tag_name or tag_id is required")
# Remove the tag from the snapshot
snapshot.tags.remove(tag.pk)
snapshot.remove_tag_ids([tag.pk])
return {
"success": True,
+7 -4
View File
@@ -1,6 +1,5 @@
__package__ = "archivebox.api"
import json
from pathlib import Path
from uuid import UUID
from datetime import datetime
@@ -23,7 +22,7 @@ from archivebox.core.permissions import (
)
from archivebox.config.common import get_config
from archivebox.crawls.models import Crawl
from archivebox.misc.util import filter_queryset_by_uuid_substring
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url
from .auth import API_AUTH_METHODS, authenticated_user_from_request
@@ -113,7 +112,10 @@ def get_crawls(request: HttpRequest):
@router.post("/crawls", response=CrawlSchema, url_name="create_crawl")
def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
urls = [url.strip() for url in data.urls if url and url.strip()]
try:
urls = [validate_url(url) for url in data.urls if url and url.strip()]
except ValueError as err:
raise HttpError(400, str(err)) from err
if not urls:
raise HttpError(400, "At least one URL is required")
if data.max_depth not in (0, 1, 2, 3, 4):
@@ -121,9 +123,10 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
config.setdefault("PARSER", "url_list")
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in urls),
urls="\n".join(urls),
max_depth=data.max_depth,
tags_str=",".join(tags),
label=data.label,
+6 -3
View File
@@ -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
+28
View File
@@ -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)
+4 -7
View File
@@ -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
+34 -40
View File
@@ -6,7 +6,7 @@ __command__ = "archivebox add"
import sys
import os
import json
import signal
from pathlib import Path
from typing import Any, TYPE_CHECKING
@@ -71,17 +71,7 @@ def add(
created_by_id: int | None = None,
config: dict[str, Any] | None = None,
) -> tuple[Crawl, QuerySet[Snapshot]]:
"""Add a new URL or list of URLs to your archive.
The flow is:
1. Save URLs to sources file
2. Create Crawl with URLs and max_depth
3. Crawl runner creates Snapshots from Crawl URLs (depth=0)
4. Crawl runner runs parser extractors on root snapshots
5. Parser extractors output to urls.jsonl
6. URLs are added to Crawl.urls and child Snapshots are created
7. Repeat until max_depth is reached
"""
"""Add a URL list or imported URL document to a new Crawl."""
from rich import print
@@ -125,12 +115,12 @@ def add(
created_by_id = created_by_id or get_or_create_system_user_pk()
started_at = timezone.now()
use_internal_input_root = isinstance(urls, str)
source_text = (
urls
if use_internal_input_root
else "\n".join(json.dumps({"type": "CrawlSeed", "url": str(url), "depth": 0}, separators=(",", ":")) for url in urls)
)
if isinstance(urls, str):
source_text = urls
else:
from archivebox.misc.util import validate_url
source_text = "\n".join(validate_url(str(url)) for url in urls)
# 2. Create a new Crawl with inline URLs
# Foreground add must claim runner ownership before publishing runnable
@@ -187,12 +177,7 @@ def add(
try:
crawl = Crawl.objects.create(
urls=source_text,
# Stdin/import text gets an extra hop because the synthetic
# archivebox://internal root lives at depth 0 and parser-discovered
# URLs land at depth 1; direct URL args become the depth=0 input
# snapshots themselves so --depth=N matches the deepest hop the user
# asked for.
max_depth=depth + 1 if use_internal_input_root else depth,
max_depth=depth,
tags_str=tag,
persona_id=persona_obj.id,
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
@@ -213,9 +198,8 @@ def add(
print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]")
print(f" [dim]First URL: {first_url}[/dim]")
# 3. The CrawlMachine will create Snapshots from all URLs when started
# Parser extractors run on snapshots and discover more URLs
# Discovered URLs become child Snapshots (depth+1)
# The runner parses Crawl.urls after claiming the Crawl, then persists the
# resulting depth-0 Snapshot facts. Recursive discoveries start at depth 1.
if index_only:
print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
@@ -244,6 +228,10 @@ def add(
assert command is not None
exit_code = 0
interactive_interrupts = sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
def interrupt_hook() -> None:
raise KeyboardInterrupt
def crawl_is_complete() -> bool:
crawl.refresh_from_db(fields=["status"])
@@ -251,7 +239,13 @@ def add(
try:
try:
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
with (
foreground_shutdown_signals(
first_signal_message=None,
interrupt_handlers={signal.SIGINT: interrupt_hook},
),
foreground_parent_watchdog(),
):
while True:
standby = standby_until_foreground_runner_needed(
command,
@@ -263,7 +257,7 @@ def add(
exit_code = run_runner_worker(
["--crawl-id", str(crawl.id)],
name=f"worker_runner_add_{os.getpid()}",
interactive_interrupts=True,
interactive_interrupts=interactive_interrupts,
config=get_config(crawl=crawl),
)
crawl.refresh_from_db(fields=["status", "retry_at"])
@@ -328,9 +322,13 @@ def add(
admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id}/change/", config=config)
print("\n[bold]crawl output saved to:[/bold]")
print(f" {rel_output_str}")
print(f" {admin_url}")
if snapshots_count:
print("\n[bold]snapshot output saved to:[/bold]")
for snapshot in crawl.snapshot_set.order_by("created_at").iterator():
click.echo(f" {snapshot.output_dir}")
print("\n[bold]crawl logs and setup:[/bold]")
click.echo(f" {rel_output_str}")
click.echo(f" {admin_url}")
print(f"\n[bold]total urls snapshotted:[/bold] {snapshots_count}")
print(f"[bold]total size:[/bold] {total_size}")
print(f"[bold]total time:[/bold] {duration_str}")
@@ -405,10 +403,6 @@ def main(**kwargs):
raise click.BadParameter(str(err), param_hint="--snapshot-max-size") from err
if kwargs.get("crawl_max_concurrent_snapshots") is not None and int(kwargs["crawl_max_concurrent_snapshots"]) < 1:
raise click.BadParameter("crawl_max_concurrent_snapshots must be at least 1.", param_hint="--crawl-max-concurrent-snapshots")
if not kwargs.get("index_only"):
from archivebox.workers.supervisord_util import require_crawl_memory
require_crawl_memory()
# Translate --only-new/--no-only-new into a crawl config override.
# add() takes config overrides as a dict; no per-flag kwargs.
@@ -421,13 +415,13 @@ def main(**kwargs):
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
if extract:
from abx_dl.models import discover_plugins, plugins_matching_output
from archivebox.plugins.discovery import get_plugin_catalog
all_plugins = discover_plugins()
catalog = get_plugin_catalog()
tokens = [token.strip() for token in extract.split(",") if token.strip()]
plugin_names = {name.lower(): name for name in all_plugins}
plugin_names = {name.lower(): name for name in catalog}
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
selected += plugins_matching_output(all_plugins, tokens)
selected += catalog.matching_output(tokens)
if not selected:
raise click.UsageError(f"No plugins found matching extract types: {extract}")
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
+18 -22
View File
@@ -6,13 +6,13 @@ archivebox archiveresult <action> [args...] [--filters]
Manage ArchiveResult records (plugin extraction results).
Actions:
create - Create ArchiveResults for Snapshots (queue extractions)
create - Emit plugin extraction request records for Snapshots
list - List ArchiveResults as JSONL (with optional filters)
update - Update ArchiveResults from stdin JSONL
delete - Delete ArchiveResults from stdin JSONL
Examples:
# Create ArchiveResults for snapshots (queue for extraction)
# Emit extraction requests; `archivebox run` schedules their parent snapshots
archivebox snapshot list --status=queued | archivebox archiveresult create
archivebox archiveresult create --plugin=screenshot --snapshot-id=<uuid>
@@ -20,9 +20,6 @@ Examples:
archivebox archiveresult list --status=failed
archivebox archiveresult list --plugin=screenshot --status=succeeded
# Update (reset failed extractions to queued)
archivebox archiveresult list --status=failed | archivebox archiveresult update --status=queued
# Delete
archivebox archiveresult list --plugin=singlefile | archivebox archiveresult delete --yes
@@ -67,7 +64,7 @@ def create_archiveresults(
Reads Snapshot records from stdin and emits ArchiveResult request JSONL.
Pass-through: Non-Snapshot/ArchiveResult records are output unchanged.
If --plugin is specified, only emits requests for that plugin.
Otherwise, emits one request for each enabled Snapshot plugin.
Otherwise, emits requests for all enabled snapshot hooks.
Exit codes:
0: Success
@@ -144,19 +141,20 @@ def create_archiveresults(
created_count = 0
for snapshot in snapshots:
if plugin:
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
hooks = [
hook
for hook in discover_hooks("Snapshot", filter_disabled=not plugin, config=config)
if not plugin or hook.parent.name == plugin
]
for hook_path in hooks:
hook_name = hook_path.stem
plugin_name = hook_path.parent.name
if not is_tty:
write_record(build_archiveresult_request(snapshot.id, plugin, status=status))
write_record(build_archiveresult_request(snapshot.id, plugin_name, hook_name=hook_name, status=status))
created_count += 1
else:
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
hooks = discover_hooks("Snapshot", filter_disabled=True, config=config)
for plugin_name in dict.fromkeys(hook_path.parent.name for hook_path in hooks):
if not is_tty:
write_record(build_archiveresult_request(snapshot.id, plugin_name, status=status))
created_count += 1
rprint(f"[green]Created {created_count} archive result request records[/green]", file=sys.stderr)
rprint(f"[green]Created {created_count} extraction request records[/green]", file=sys.stderr)
return 0
@@ -254,11 +252,9 @@ def update_archiveresults(
# Apply updates from CLI flags
if status:
if status == ArchiveResult.StatusChoices.QUEUED:
result.reset_for_retry()
else:
result.status = status
result.save(update_fields=["status", "modified_at"])
result.status = status
result.save()
updated_count += 1
if not is_tty:
@@ -342,7 +338,7 @@ def main():
@click.option("--plugin", "-p", help="Plugin name (e.g., screenshot, singlefile)")
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
def create_cmd(snapshot_id: str | None, plugin: str | None, status: str):
"""Create ArchiveResults for Snapshots from stdin JSONL."""
"""Emit Snapshot plugin extraction requests as JSONL."""
sys.exit(create_archiveresults(snapshot_id=snapshot_id, plugin=plugin, status=status))
+136 -388
View File
@@ -1,95 +1,135 @@
#!/usr/bin/env python3
"""
archivebox extract [snapshot_ids...] [--plugins=NAMES]
Run plugins on Snapshots. Accepts snapshot IDs as arguments, from stdin, or via JSONL.
Input formats:
- Snapshot UUIDs (one per line)
- JSONL: {"type": "Snapshot", "id": "...", "url": "..."}
- JSONL: {"type": "ArchiveResult", "snapshot_id": "...", "plugin": "..."}
Output (JSONL):
{"type": "ArchiveResult", "id": "...", "snapshot_id": "...", "plugin": "...", "status": "..."}
Examples:
# Extract specific snapshot
archivebox extract 01234567-89ab-cdef-0123-456789abcdef
# Pipe from snapshot command
archivebox snapshot https://example.com | archivebox extract
# Run specific plugins only
archivebox extract --plugins=screenshot,singlefile 01234567-89ab-cdef-0123-456789abcdef
# Chain commands
archivebox crawl https://example.com | archivebox snapshot | archivebox extract
"""
"""Run abx-dl snapshot hooks for existing ArchiveBox snapshots."""
__package__ = "archivebox.cli"
__command__ = "archivebox extract"
import sys
from collections import defaultdict
from itertools import product
from contextlib import redirect_stdout
import rich_click as click
def process_archiveresult_by_id(archiveresult_id: str) -> int:
"""
Re-run extraction for a single ArchiveResult by ID.
def _resolve_requests(records: list[dict], plugins: str) -> tuple[dict[str, set[str]], set[str]]:
"""Resolve CLI records to snapshot-level execution requests.
ArchiveResults are projected status rows, not queued work items. Re-running
a single result means resetting that row and queueing its parent snapshot
through the shared crawl runner with the corresponding plugin selected.
ArchiveResult input is accepted as a convenient reference to its parent
Snapshot and plugin. It is never reset or converted into queued work.
"""
from rich import print as rprint
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.misc.jsonl import TYPE_ARCHIVERESULT
explicit_plugins = {name.strip() for name in plugins.split(",") if name.strip()}
requested: dict[str, set[str]] = defaultdict(set)
missing: set[str] = set()
for record in records:
record_type = record.get("type")
record_id = str(record.get("id") or "")
if record_type == TYPE_ARCHIVERESULT and record.get("snapshot_id"):
requested[str(record["snapshot_id"])].update(explicit_plugins or {str(record.get("plugin") or "")})
requested[str(record["snapshot_id"])].discard("")
continue
if record_type == TYPE_ARCHIVERESULT:
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
snapshot_id = str(record.get("snapshot_id") or record_id or "")
snapshot = Snapshot.objects.filter(id=snapshot_id).only("id").first() if snapshot_id else None
if snapshot is None and record.get("url"):
snapshot = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").only("id").first()
# Bare UUID CLI arguments are parsed as Snapshot records because the
# input layer cannot know which model owns them. Preserve the public
# convenience of passing an ArchiveResult ID by trying that reference
# only after the Snapshot lookup misses.
if snapshot is None and record_id and not record.get("url"):
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
if snapshot is None:
missing.add(snapshot_id or str(record.get("url") or ""))
continue
requested[str(snapshot.id)].update(explicit_plugins)
return requested, missing
def _run_snapshot_requests(requested: dict[str, set[str]], *, wait: bool, show_progress: bool) -> int:
from django.utils import timezone
from rich import print as rprint
from archivebox.core.models import Snapshot
from archivebox.services.runner import run_crawl
try:
archiveresult = ArchiveResult.objects.get(_uuid_ref_query("id", archiveresult_id))
except ArchiveResult.DoesNotExist:
rprint(f"[red]ArchiveResult {archiveresult_id} not found[/red]", file=sys.stderr)
snapshots = {str(snapshot.id): snapshot for snapshot in Snapshot.objects.filter(id__in=requested).select_related("crawl")}
if not snapshots:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
return 1
rprint(f"[blue]Extracting {archiveresult.plugin} for {archiveresult.snapshot.url}[/blue]", file=sys.stderr)
from archivebox.config.common import get_config
from archivebox.plugins.discovery import get_enabled_plugins
try:
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
snapshot.queue_for_extraction()
crawl = snapshot.crawl
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
f"[yellow]Crawl {crawl.id} is already owned by another runner[/yellow]",
file=sys.stderr,
)
return 1
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None and not plugin_names:
plugin_names.update(get_enabled_plugins(config=get_config(crawl=snapshot.crawl, snapshot=snapshot)))
run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
archiveresult.refresh_from_db()
if wait and any("search_backend_sonic" in plugin_names for plugin_names in requested.values()):
from archivebox.core.takeover_util import ensure_daemon_stack
if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED:
print(f"[green]Extraction succeeded: {archiveresult.output_str}[/green]")
return 0
elif archiveresult.status == ArchiveResult.StatusChoices.NORESULTS:
print(f"[dim]Extraction completed with no results: {archiveresult.output_str}[/dim]")
return 0
elif archiveresult.status == ArchiveResult.StatusChoices.FAILED:
print(f"[red]Extraction failed: {archiveresult.output_str}[/red]", file=sys.stderr)
return 1
else:
# Still in progress or backoff - not a failure
print(f"[yellow]Extraction status: {archiveresult.status}[/yellow]")
return 0
with redirect_stdout(sys.stderr):
ensure_daemon_stack(reason="Sonic snapshot indexing")
except Exception as e:
print(f"[red]Extraction error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
return 1
# Explicit extraction resumes open/paused snapshots at the snapshot level.
# Sealed snapshots stay sealed during targeted maintenance backfills.
if wait:
for snapshot in snapshots.values():
if snapshot.status != Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if not wait:
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is None:
continue
if plugin_names:
snapshot.schedule_plugin_run(plugin_names, when=timezone.now())
elif snapshot.status == Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(retry_at=timezone.now())
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if show_progress:
rprint(f"[blue]Queued {len(snapshots)} snapshots for extraction[/blue]", file=sys.stderr)
return 0
grouped: dict[tuple[str, tuple[str, ...]], list[str]] = defaultdict(list)
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None:
grouped[(str(snapshot.crawl_id), tuple(sorted(plugin_names)))].append(snapshot_id)
for (crawl_id, plugin_names), snapshot_ids in grouped.items():
run_crawl(
crawl_id,
snapshot_ids=sorted(snapshot_ids),
selected_plugins=list(plugin_names) or None,
show_progress=show_progress,
)
return 0
def run_plugins(
@@ -99,356 +139,64 @@ def run_plugins(
wait: bool = True,
emit_results: bool = True,
show_progress: bool = True,
preserve_queued: bool = False,
) -> int:
"""
Run plugins on Snapshots from input.
Reads Snapshot IDs or JSONL from args/stdin, runs plugins, outputs JSONL.
Exit codes:
0: Success
1: Failure
"""
"""Execute selected plugins through the snapshot-level runner."""
from rich import print as rprint
from django.utils import timezone
from archivebox.misc.jsonl import (
read_args_or_stdin,
write_record,
TYPE_SNAPSHOT,
TYPE_ARCHIVERESULT,
)
from archivebox.core.models import Snapshot
from archivebox.core.models import ArchiveResult
from archivebox.services.runner import run_crawl
from archivebox.misc.jsonl import read_args_or_stdin, write_record
is_tty = sys.stdout.isatty()
# Parse comma-separated plugins list once (reused in creation and filtering)
plugins_list = [p.strip() for p in plugins.split(",") if p.strip()] if plugins else []
# Parse stdin/args exactly once per CLI invocation.
# `main()` may already have consumed stdin to distinguish Snapshot input from
# ArchiveResult IDs; if so, it must pass the parsed records through here
# instead of asking this helper to reread an already-drained pipe.
if records is None:
records = list(read_args_or_stdin(args))
if not records:
rprint("[yellow]No snapshots provided. Pass snapshot IDs as arguments or via stdin.[/yellow]", file=sys.stderr)
return 1
# Gather snapshot IDs and optional plugin constraints to process
snapshot_ids = set()
requested_plugins_by_snapshot: dict[str, set[str]] = defaultdict(set)
for record in records:
record_type = record.get("type")
if record_type == TYPE_SNAPSHOT:
snapshot_id = record.get("id")
if snapshot_id:
snapshot_ids.add(str(snapshot_id))
elif record.get("url"):
# Look up by URL (get most recent if multiple exist)
snap = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").first()
if snap:
snapshot_ids.add(str(snap.id))
else:
rprint(f"[yellow]Snapshot not found for URL: {record['url']}[/yellow]", file=sys.stderr)
elif record_type == TYPE_ARCHIVERESULT:
snapshot_id = record.get("snapshot_id")
if snapshot_id:
snapshot_ids.add(str(snapshot_id))
plugin_name = record.get("plugin")
if plugin_name and not plugins_list:
requested_plugins_by_snapshot[str(snapshot_id)].add(str(plugin_name))
elif "id" in record:
# Assume it's a snapshot ID
snapshot_ids.add(str(record["id"]))
if not snapshot_ids:
rprint("[red]No valid snapshot IDs found in input[/red]", file=sys.stderr)
requested, missing = _resolve_requests(records, plugins)
for value in sorted(missing):
rprint(f"[yellow]Snapshot or ArchiveResult not found: {value}[/yellow]", file=sys.stderr)
if not requested:
return 1
existing_snapshots = list(Snapshot.objects.filter(id__in=snapshot_ids).values_list("id", "crawl_id"))
existing_snapshot_ids = {str(snapshot_id) for snapshot_id, _crawl_id in existing_snapshots}
existing_crawl_ids = {str(crawl_id) for _snapshot_id, crawl_id in existing_snapshots}
missing_snapshot_ids = sorted(str(snapshot_id) for snapshot_id in snapshot_ids - existing_snapshot_ids)
for snapshot_id in missing_snapshot_ids:
rprint(f"[yellow]Snapshot {snapshot_id} not found[/yellow]", file=sys.stderr)
# Queue only the target plugin rows. Bulk updates keep large reindex runs
# from doing one SELECT+UPDATE per snapshot/plugin before hooks even start.
requested_pairs: set[tuple[str, str]] = set()
if plugins_list:
requested_pairs.update((snapshot_id, plugin_name) for snapshot_id, plugin_name in product(existing_snapshot_ids, plugins_list))
else:
requested_pairs.update(
(snapshot_id, plugin_name)
for snapshot_id, plugin_names in requested_plugins_by_snapshot.items()
if snapshot_id in existing_snapshot_ids
for plugin_name in plugin_names
)
queued_rows: set[tuple[str, str]] = set()
if preserve_queued and requested_pairs:
queued_rows = {
(str(snapshot_id), plugin_name)
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
snapshot_id__in=existing_snapshot_ids,
plugin__in={plugin_name for _snapshot_id, plugin_name in requested_pairs},
status=ArchiveResult.StatusChoices.QUEUED,
).values_list("snapshot_id", "plugin")
}
rows_to_queue = requested_pairs - queued_rows
reset_fields = {
"status": ArchiveResult.StatusChoices.QUEUED,
"hook_name": "",
"output_str": "",
"output_json": None,
"output_files": {},
"output_size": 0,
"output_mimetypes": "",
"start_ts": None,
"end_ts": None,
"modified_at": timezone.now(),
}
if rows_to_queue and plugins_list:
rows_to_reset_by_plugin: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in rows_to_queue:
rows_to_reset_by_plugin[plugin_name].add(snapshot_id)
for plugin_name, plugin_snapshot_ids in rows_to_reset_by_plugin.items():
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
**reset_fields,
)
elif rows_to_queue and requested_plugins_by_snapshot:
snapshot_ids_by_plugin: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in rows_to_queue:
snapshot_ids_by_plugin[plugin_name].add(snapshot_id)
for plugin_name, plugin_snapshot_ids in snapshot_ids_by_plugin.items():
ArchiveResult.objects.filter(snapshot_id__in=plugin_snapshot_ids, plugin=plugin_name).update(
**reset_fields,
)
existing_rows = (
{
(str(snapshot_id), plugin_name)
for snapshot_id, plugin_name in ArchiveResult.objects.filter(
snapshot_id__in=existing_snapshot_ids,
plugin__in={plugin_name for _snapshot_id, plugin_name in rows_to_queue},
).values_list("snapshot_id", "plugin")
}
if rows_to_queue
else set()
)
missing_rows = rows_to_queue - existing_rows
if missing_rows:
ArchiveResult.objects.bulk_create(
[
ArchiveResult(
snapshot_id=snapshot_id,
plugin=plugin_name,
hook_name="",
status=ArchiveResult.StatusChoices.QUEUED,
)
for snapshot_id, plugin_name in sorted(missing_rows)
],
batch_size=500,
)
processed_count = len(existing_snapshot_ids)
queue_at = timezone.now()
if existing_snapshot_ids:
if requested_pairs:
# Search indexing on a sealed Snapshot is the only targeted plugin
# allowed to bypass the normal lifecycle. Every other requested
# plugin requeues its Snapshot through the unified state machine.
affected_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in rows_to_queue}
if preserve_queued and queued_rows:
queued_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name in queued_rows}
affected_snapshot_ids.update(
str(snapshot_id)
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids)
.filter(retry_at__gt=queue_at)
.values_list("id", flat=True)
)
affected_snapshot_ids.update(
str(snapshot_id)
for snapshot_id in Snapshot.objects.filter(id__in=queued_snapshot_ids, retry_at__isnull=True).values_list(
"id",
flat=True,
)
)
requested_plugins_by_id: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name in requested_pairs:
requested_plugins_by_id[snapshot_id].add(plugin_name)
for snapshot in Snapshot.objects.filter(id__in=affected_snapshot_ids).only("id", "status", "modified_at"):
# Guard the read-time status so we never bump retry_at on a
# row that's been re-queued / started by a concurrent runner.
plugin_names = requested_plugins_by_id.get(str(snapshot.id), set())
sealed_search_backfill = (
snapshot.status == Snapshot.StatusChoices.SEALED
and plugin_names
and all(plugin_name.startswith("search_backend_") for plugin_name in plugin_names)
)
if sealed_search_backfill:
snapshot.safe_update(
{"retry_at": queue_at, "modified_at": queue_at},
refresh=False,
extra_filter={"status": snapshot.status},
)
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=queue_at,
current_step=0,
)
else:
# No plugin rows were requested, so this is a full snapshot retry.
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "retry_at", "modified_at"):
snapshot.safe_update(
{
"status": Snapshot.StatusChoices.QUEUED,
"retry_at": queue_at,
"current_step": 0,
"modified_at": queue_at,
},
refresh=False,
extra_filter={"status": snapshot.status},
)
if existing_crawl_ids and not requested_pairs:
from archivebox.crawls.models import Crawl
for crawl in Crawl.objects.filter(id__in=existing_crawl_ids).only("id", "status", "retry_at", "modified_at"):
update_fields = {
"retry_at": queue_at,
"modified_at": queue_at,
}
if crawl.status != Crawl.StatusChoices.STARTED:
update_fields["status"] = Crawl.StatusChoices.QUEUED
crawl.safe_update(
update_fields,
refresh=False,
extra_filter={"status": crawl.status},
)
if processed_count == 0:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
return 1
if show_progress:
rprint(f"[blue]Queued {processed_count} snapshots for extraction[/blue]", file=sys.stderr)
# Run orchestrator if --wait (default)
if wait:
if show_progress:
rprint("[blue]Running plugins...[/blue]", file=sys.stderr)
snapshot_ids_by_crawl: dict[str, set[str]] = defaultdict(set)
for snapshot_id, crawl_id in existing_snapshots:
snapshot_ids_by_crawl[str(crawl_id)].add(str(snapshot_id))
for crawl_id, crawl_snapshot_ids in snapshot_ids_by_crawl.items():
from archivebox.crawls.models import Crawl
crawl = Crawl.objects.get(id=crawl_id)
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
f"[yellow]Crawl {crawl_id} is already owned by another runner[/yellow]",
file=sys.stderr,
)
return 1
selected_plugins = (
plugins_list
or sorted(
{plugin for snapshot_id in crawl_snapshot_ids for plugin in requested_plugins_by_snapshot.get(str(snapshot_id), set())},
)
or None
)
run_crawl(
crawl_id,
snapshot_ids=sorted(crawl_snapshot_ids),
selected_plugins=selected_plugins,
show_progress=show_progress,
)
if not emit_results:
return 0
# Output results as JSONL (when piped) or human-readable (when TTY)
for snapshot_id in snapshot_ids:
try:
snapshot = Snapshot.objects.get(id=snapshot_id)
results = snapshot.archiveresult_set.all()
if plugins_list:
results = results.filter(plugin__in=plugins_list)
for result in results:
if is_tty:
status_color = {
"succeeded": "green",
"failed": "red",
"skipped": "yellow",
}.get(result.status, "dim")
rprint(
f" [{status_color}]{result.status}[/{status_color}] {result.plugin} → {result.output_str or ''}",
file=sys.stderr,
)
else:
write_record(result.to_json())
except Snapshot.DoesNotExist:
continue
exit_code = _run_snapshot_requests(requested, wait=wait, show_progress=show_progress)
if exit_code or not emit_results:
return exit_code
is_tty = sys.stdout.isatty()
for snapshot in Snapshot.objects.filter(id__in=requested):
results = snapshot.archiveresult_set.all()
requested_plugins = requested[str(snapshot.id)]
if requested_plugins:
results = results.filter(plugin__in=requested_plugins)
for result in results:
if is_tty:
color = {"succeeded": "green", "failed": "red", "skipped": "yellow"}.get(result.status, "dim")
rprint(f" [{color}]{result.status}[/{color}] {result.plugin} → {result.output_str or ''}", file=sys.stderr)
else:
write_record(result.to_json())
return 0
def is_archiveresult_id(value: str) -> bool:
"""Check if value resolves to an ArchiveResult ID."""
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
return ArchiveResult.objects.filter(_uuid_ref_query("id", value)).exists()
def process_archiveresult_by_id(archiveresult_id: str) -> int:
"""Re-run the parent Snapshot plugin referenced by an ArchiveResult."""
return run_plugins((), records=[{"id": archiveresult_id}], wait=True)
@click.command()
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run (e.g., screenshot,singlefile)")
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run")
@click.option("--wait/--no-wait", default=True, help="Wait for plugins to complete (default: wait)")
@click.argument("args", nargs=-1)
def main(plugins: str, wait: bool, args: tuple):
"""Run plugins on Snapshots, or process existing ArchiveResults by ID"""
"""Run plugins on Snapshots; ArchiveResult IDs select their parent plugin."""
from archivebox.misc.jsonl import read_args_or_stdin
# Read all input
records = list(read_args_or_stdin(args))
if not records:
from rich import print as rprint
rprint("[yellow]No Snapshot IDs or ArchiveResult IDs provided. Pass as arguments or via stdin.[/yellow]", file=sys.stderr)
sys.exit(1)
# Check if input looks like existing ArchiveResult IDs to process
all_are_archiveresult_ids = all(is_archiveresult_id(r.get("id") or r.get("url", "")) for r in records)
if all_are_archiveresult_ids:
# Process existing ArchiveResults by ID
from rich import print as rprint
exit_code = 0
for record in records:
archiveresult_id = record.get("id") or record.get("url")
if not isinstance(archiveresult_id, str):
rprint(f"[red]Invalid ArchiveResult input: {record}[/red]", file=sys.stderr)
exit_code = 1
continue
result = process_archiveresult_by_id(archiveresult_id)
if result != 0:
exit_code = result
sys.exit(exit_code)
else:
# Default behavior: run plugins on Snapshots from input
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
if __name__ == "__main__":
+2 -2
View File
@@ -68,7 +68,7 @@ def help() -> None:
[blue]docker compose run[/blue] [dark_green]archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
[grey53]# using Docker:[/grey53]
[blue]docker run[/blue] -v [light_slate_blue]$PWD:/data[/light_slate_blue] [grey53]-p 8000:8000[/grey53] -it [dark_green]archivebox/archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
[blue]docker run[/blue] -v [light_slate_blue]$PWD:/data[/light_slate_blue] [grey53]-p 5797:5797[/grey53] -it [dark_green]archivebox/archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
"""
if IN_DOCKER
else ""
@@ -109,7 +109,7 @@ def help() -> None:
[dark_green]archivebox[/dark_green] [green]add[/green] --depth=1 "https://example.com/some/page"
[dark_green]archivebox[/dark_green] [green]list[/green] --sort=timestamp --csv=timestamp,downloaded_at,url,title
[dark_green]archivebox[/dark_green] [green]schedule[/green] --every=day --depth=1 "https://example.com/some/feed.rss"
[dark_green]archivebox[/dark_green] [green]server[/green] [blue]0.0.0.0:8000[/blue] [grey53]# Start the Web UI / API server[/grey53]
[dark_green]archivebox[/dark_green] [green]server[/green] [blue]0.0.0.0:5797[/blue] [grey53]# Start the Web UI / API server[/grey53]
"""
print(
Panel(
+1 -1
View File
@@ -134,7 +134,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
print(" > Skipping orphan snapshot import during init.")
print()
print(" [violet]Hint:[/violet] To import orphaned snapshot directories and reconcile filesystem state, run:")
print(" archivebox update")
print(" archivebox update --rescan --migrate-only")
print("\n[green]----------------------------------------------------------------------[/green]")
+7 -7
View File
@@ -17,12 +17,12 @@ def _resolve_install_targets(
requested_names: tuple[str, ...],
) -> tuple[list[str], list[str]]:
"""Resolve plugin names and declared binary aliases, leaving unknown tokens raw."""
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.plugins.discovery import get_plugin_catalog
plugins = discover_plugins(runtime="archivebox")
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in plugins}
catalog = get_plugin_catalog()
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in catalog}
plugin_names_by_binary_alias: dict[str, set[str]] = {}
for plugin_name, plugin in plugins.items():
for plugin_name, plugin in catalog.items():
for required_binary in plugin.config.required_binaries:
aliases = {required_binary.name.lower()}
template_match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", required_binary.name)
@@ -47,14 +47,14 @@ def _resolve_install_targets(
else:
raw_binary_names.append(name)
selected_plugins = filter_plugins(plugins, requested_plugins, include_providers=True) if requested_plugins else {}
selected_plugins = catalog.select(requested_plugins) if requested_plugins else catalog.select([])
selected_plugin_names = {name.lower() for name in selected_plugins}
raw_binary_names = [name for name in raw_binary_names if name.lower() not in selected_plugin_names]
return sorted(selected_plugins), sorted(set(raw_binary_names))
def _install_raw_binary_names(binary_names: list[str], binproviders: str) -> None:
"""Install user-requested standalone binaries through the Binary state machine."""
"""Install user-requested standalone binaries through the Binary lifecycle."""
from django.utils import timezone
from archivebox.machine.models import Binary, Machine, _canonical_binary_name
@@ -179,7 +179,7 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
run_install(plugin_names=install_plugin_names or None)
if raw_binary_names:
print(f"[+] Running direct binary installer via ArchiveBox binary state machine: {', '.join(raw_binary_names)}")
print(f"[+] Running direct binary installer via ArchiveBox binary lifecycle: {', '.join(raw_binary_names)}")
print()
_install_raw_binary_names(raw_binary_names, binproviders)
+147 -137
View File
@@ -27,11 +27,10 @@ Examples:
__package__ = "archivebox.cli"
__command__ = "archivebox persona"
import os
import sys
import shutil
import platform
from pathlib import Path
from dataclasses import replace
from collections.abc import Iterable
import rich_click as click
@@ -41,116 +40,6 @@ from archivebox.cli.cli_util import apply_filters
from archivebox.personas import importers as persona_importers
# =============================================================================
# Browser Profile Locations
# =============================================================================
def get_chrome_user_data_dir() -> Path | None:
"""Get the default Chrome user data directory for the current platform."""
system = platform.system()
home = Path.home()
if system == "Darwin": # macOS
candidates = [
home / "Library" / "Application Support" / "Google" / "Chrome",
home / "Library" / "Application Support" / "Chromium",
]
elif system == "Linux":
candidates = [
home / ".config" / "google-chrome",
home / ".config" / "chromium",
home / ".config" / "chrome",
home / "snap" / "chromium" / "common" / "chromium",
]
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = [
local_app_data / "Google" / "Chrome" / "User Data",
local_app_data / "Chromium" / "User Data",
]
else:
candidates = []
for candidate in candidates:
if candidate.exists() and (candidate / "Default").exists():
return candidate
return None
def get_brave_user_data_dir() -> Path | None:
"""Get the default Brave user data directory for the current platform."""
system = platform.system()
home = Path.home()
if system == "Darwin":
candidates = [
home / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser",
]
elif system == "Linux":
candidates = [
home / ".config" / "BraveSoftware" / "Brave-Browser",
]
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = [
local_app_data / "BraveSoftware" / "Brave-Browser" / "User Data",
]
else:
candidates = []
for candidate in candidates:
if candidate.exists() and (candidate / "Default").exists():
return candidate
return None
def get_edge_user_data_dir() -> Path | None:
"""Get the default Edge user data directory for the current platform."""
system = platform.system()
home = Path.home()
if system == "Darwin":
candidates = [
home / "Library" / "Application Support" / "Microsoft Edge",
]
elif system == "Linux":
candidates = [
home / ".config" / "microsoft-edge",
home / ".config" / "microsoft-edge-beta",
home / ".config" / "microsoft-edge-dev",
]
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = [
local_app_data / "Microsoft" / "Edge" / "User Data",
]
else:
candidates = []
for candidate in candidates:
if candidate.exists() and (candidate / "Default").exists():
return candidate
return None
BROWSER_PROFILE_FINDERS = {
"chrome": get_chrome_user_data_dir,
"chromium": get_chrome_user_data_dir, # Same locations
"brave": get_brave_user_data_dir,
"edge": get_edge_user_data_dir,
}
CHROMIUM_BROWSERS = {"chrome", "chromium", "brave", "edge"}
# =============================================================================
# Cookie Extraction via CDP
# =============================================================================
# =============================================================================
# Validation Helpers
# =============================================================================
@@ -218,6 +107,9 @@ def create_personas(
names: Iterable[str],
import_from: str | None = None,
profile: str | None = None,
source: str | None = None,
browser_binary: str | None = None,
permissions: str | None = None,
) -> int:
"""
Create Personas from names.
@@ -239,25 +131,26 @@ def create_personas(
rprint("[yellow]No persona names provided. Pass names as arguments.[/yellow]", file=sys.stderr)
return 1
# Validate import source if specified
source_profile_dir = None
import_source = None
if source and not import_from:
rprint("[red]--source requires --import (the source browser name).[/red]", file=sys.stderr)
return 1
if import_from:
import_from = import_from.lower()
if import_from not in BROWSER_PROFILE_FINDERS:
rprint(f"[red]Unknown browser: {import_from}[/red]", file=sys.stderr)
rprint(f"[dim]Supported browsers: {', '.join(BROWSER_PROFILE_FINDERS.keys())}[/dim]", file=sys.stderr)
try:
if source:
import_source = persona_importers.resolve_custom_import_source(source, profile_dir=profile)
if import_source.browser != "persona":
import_source = replace(import_source, browser=import_from.lower(), browser_binary=browser_binary)
elif import_from.startswith(("http://", "https://", "ws://", "wss://")) or Path(import_from).expanduser().is_absolute():
import_source = persona_importers.resolve_custom_import_source(import_from)
else:
import_source = persona_importers.resolve_browser_import_source(import_from, profile_dir=profile)
if browser_binary:
import_source = replace(import_source, browser_binary=browser_binary)
except ValueError as err:
rprint(f"[red]{err}[/red]", file=sys.stderr)
return 1
source_profile_dir = BROWSER_PROFILE_FINDERS[import_from]()
if not source_profile_dir:
rprint(f"[red]Could not find {import_from} profile directory[/red]", file=sys.stderr)
return 1
rprint(f"[dim]Found {import_from} profile: {source_profile_dir}[/dim]", file=sys.stderr)
if profile is None and (source_profile_dir / "Default").exists():
profile = "Default"
created_count = 0
for name in name_list:
name = name.strip()
@@ -270,21 +163,24 @@ def create_personas(
rprint(f'[red]Invalid persona name "{name}": {error_msg}[/red]', file=sys.stderr)
continue
persona, created = Persona.objects.get_or_create(name=name)
persona, created = Persona.objects.get_or_create(
name=name,
defaults={"config": {"PERMISSIONS": permissions}} if permissions else {},
)
if created:
persona.ensure_dirs()
created_count += 1
rprint(f"[green]Created persona: {name}[/green]", file=sys.stderr)
else:
rprint(f"[dim]Persona already exists: {name}[/dim]", file=sys.stderr)
cookies_file = Path(persona.path) / "cookies.txt"
# Import browser profile if requested
if import_from in CHROMIUM_BROWSERS and source_profile_dir is not None:
if import_source is not None:
try:
import_source = persona_importers.resolve_browser_import_source(import_from, profile_dir=profile)
rprint(f"[dim]Importing {import_source.display_label}; copying settings and exporting cookies...[/dim]", file=sys.stderr)
import_result = persona_importers.import_persona_from_source(
persona,
import_source,
@@ -292,20 +188,25 @@ def create_personas(
import_cookies=True,
capture_storage=False,
)
except Exception as e:
except (Exception, KeyboardInterrupt) as e:
if created:
shutil.rmtree(persona.path, ignore_errors=True)
persona.delete()
rprint(f"[red]Failed to import browser profile: {e}[/red]", file=sys.stderr)
return 1
if import_result.profile_copied:
rprint("[green]Copied browser profile to persona[/green]", file=sys.stderr)
if import_result.cookies_imported:
rprint(f"[green]Extracted cookies to {cookies_file}[/green]", file=sys.stderr)
rprint(f"[green]Extracted {import_result.cookie_count} cookies to {cookies_file}[/green]", file=sys.stderr)
elif not import_result.profile_copied:
rprint("[yellow]Could not import cookies automatically.[/yellow]", file=sys.stderr)
for warning in import_result.warnings:
rprint(f"[yellow]{warning}[/yellow]", file=sys.stderr)
if created:
rprint(f"[green]Created persona: {name}[/green]", file=sys.stderr)
if not is_tty:
write_record(
{
@@ -544,11 +445,42 @@ def main():
@main.command("create")
@click.argument("names", nargs=-1)
@click.option("--import", "import_from", help="Import profile from browser (chrome, chromium, brave, edge)")
@click.option(
"--import",
"import_from",
help="Import from chrome, chrome-beta, chromium, brave, edge, a persona directory, or a live CDP URL",
)
@click.option("--source", help="Source browser user-data directory or exact profile path (including Docker mounts)")
@click.option(
"--browser-binary",
type=click.Path(exists=True, dir_okay=False),
help="Source Chromium browser executable; required for other Chromium-based browsers",
)
@click.option("--profile", help="Profile directory name under the user data dir (e.g. Default, Profile 1)")
def create_cmd(names: tuple, import_from: str | None, profile: str | None):
@click.option(
"--permissions",
type=click.Choice(["public", "unlisted", "private"]),
help="Visibility for a newly created persona (existing personas are unchanged)",
)
def create_cmd(
names: tuple,
import_from: str | None,
profile: str | None,
source: str | None,
browser_binary: str | None,
permissions: str | None,
):
"""Create Personas, optionally importing from a browser profile."""
sys.exit(create_personas(names, import_from=import_from, profile=profile))
sys.exit(
create_personas(
names,
import_from=import_from,
profile=profile,
source=source,
browser_binary=browser_binary,
permissions=permissions,
),
)
@main.command("list")
@@ -560,6 +492,84 @@ def list_cmd(name: str | None, name__icontains: str | None, limit: int | None):
sys.exit(list_personas(name=name, name__icontains=name__icontains, limit=limit))
@main.command("open")
@click.argument("name", default="Default")
def open_cmd(name: str):
"""Open a persona's browser, including its imported logins."""
import json
import os
import subprocess
import psutil
from abx_plugins import get_plugins_dir
from archivebox.config.common import get_config
from archivebox.personas.models import Persona
valid, reason = validate_persona_name(name)
if not valid:
raise click.ClickException(reason)
persona = Persona.get_or_create_named(name)
persona.ensure_dirs()
config = get_config(persona=persona)
if os.environ.get("DISPLAY"):
from archivebox.config import CONSTANTS
for process in psutil.process_iter():
try:
args = process.cmdline()
profile = next((arg.split("=", 1)[1] for arg in args if arg.startswith("--user-data-dir=")), None)
if (
not profile
or any(arg.startswith(("--type=", "--headless")) for arg in args)
or not Path(profile).resolve().is_relative_to(CONSTANTS.DATA_DIR.resolve())
or process.environ().get("DISPLAY") != os.environ["DISPLAY"]
):
continue
rprint("An ArchiveBox browser is already open on this display; waiting for it to close.")
process.wait()
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
plugins_dir = Path(get_plugins_dir()).resolve()
env = os.environ.copy()
dependencies = subprocess.run(
[
str(Path(sys.executable).with_name("abxpkg")),
"env",
"--install",
"--json",
f"--lib={config.ABXPKG_LIB_DIR}",
f"--deps-from={plugins_dir / 'chrome' / 'config.json'}:required_binaries",
],
capture_output=True,
text=True,
env=env,
)
if dependencies.returncode:
raise click.ClickException(dependencies.stderr.strip())
env.update({key: str(value) for key, value in json.loads(dependencies.stdout).items()})
env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(plugins_dir)
env["ACTIVE_PERSONA"] = persona.name
env["PERSONAS_DIR"] = str(persona.path.parent)
env["NODE_MODULES_DIR"] = str(config.ABXPKG_LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules")
payload = config.model_dump(mode="json")
payload.update(
ACTIVE_PERSONA=persona.name,
CHROME_HEADLESS=False,
CHROME_USER_DATA_DIR=str(persona.CHROME_USER_DATA_DIR),
AUTH_STORAGE_FILE=str(persona.AUTH_STORAGE_FILE or ""),
COOKIES_FILE=str(persona.COOKIES_FILE or ""),
)
script = Path(__file__).parent.parent / "personas" / "open_browser.js"
result = subprocess.run(
[env.get("NODE_BINARY") or "node", str(script)],
input=json.dumps(payload),
text=True,
env=env,
)
sys.exit(result.returncode)
@main.command("update")
@click.option("--name", "-n", help="Set new name")
def update_cmd(name: str | None):
+1 -1
View File
@@ -48,7 +48,7 @@ def pluginmap(
Show the current abx-dl event phases and their associated plugin hooks.
This command reflects the new bus-driven runtime, not the legacy ArchiveBox
state-machine executor. Event names are normalized to hook prefixes by
event runtime. Event names are normalized to hook prefixes by
stripping a trailing `Event`, then ArchiveBox checks whether any matching
`on_{EventFamily}__*` scripts actually exist.
"""
+45 -63
View File
@@ -54,12 +54,9 @@ RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
# A supervised `archivebox run --daemon` is intentionally a disposable
# child. If it receives SIGINT/SIGTERM directly, exit with the conventional
# signal status so supervisord treats it as an unexpected worker death and
# restarts only the runner. The parent `archivebox server` owns supervisord
# shutdown and must not be pulled down by a killed daemon worker.
os._exit(128 + int(sig))
# A supervised daemon must exit non-zero so supervisord restarts it, while
# still unwinding the active crawl's hooks and persisted process state.
raise SystemExit(128 + sig.value)
def process_stdin_records() -> int:
@@ -72,8 +69,8 @@ def process_stdin_records() -> int:
Outputs JSONL of all processed records (for chaining).
Handles any record type: Crawl, Snapshot, ArchiveResult.
Auto-cascades: Crawl → Snapshots → ArchiveResults.
Handles Crawl and Snapshot work records. ArchiveResult records are accepted
as references to their parent Snapshot and plugin.
Returns exit code (0 = success, 1 = error).
"""
@@ -90,6 +87,7 @@ def process_stdin_records() -> int:
)
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import Snapshot, ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.crawls.models import Crawl
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
from archivebox.machine.models import Binary
@@ -156,33 +154,15 @@ def process_stdin_records() -> int:
queued_count += 1
elif record_type == TYPE_ARCHIVERESULT:
if record_id:
# Existing archiveresult - re-queue
try:
archiveresult = ArchiveResult.objects.get(id=record_id)
except ArchiveResult.DoesNotExist:
archiveresult = None
else:
archiveresult = None
snapshot_id = record.get("snapshot_id")
plugin_name = record.get("plugin")
snapshot = None
if archiveresult:
if archiveresult.status in [
ArchiveResult.StatusChoices.FAILED,
ArchiveResult.StatusChoices.SKIPPED,
ArchiveResult.StatusChoices.NORESULTS,
ArchiveResult.StatusChoices.BACKOFF,
]:
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
plugin_name = plugin_name or archiveresult.plugin
elif snapshot_id:
try:
snapshot = Snapshot.objects.get(id=snapshot_id)
except Snapshot.DoesNotExist:
snapshot = None
snapshot_id = str(record.get("snapshot_id") or "")
plugin_name = str(record.get("plugin") or "")
archiveresult = None
if not snapshot_id and record_id:
archiveresult = ArchiveResult.objects.filter(_uuid_ref_query("id", str(record_id))).select_related("snapshot").first()
if archiveresult:
snapshot_id = str(archiveresult.snapshot_id)
plugin_name = plugin_name or archiveresult.plugin
snapshot = Snapshot.objects.filter(id=snapshot_id).first() if snapshot_id else None
if snapshot:
snapshot.queue_for_extraction()
@@ -190,7 +170,7 @@ def process_stdin_records() -> int:
snapshot_ids_by_crawl[crawl_id].add(str(snapshot.id))
if plugin_name:
plugin_names_by_crawl[crawl_id].add(str(plugin_name))
output_records.append(record if not archiveresult else archiveresult.to_json())
output_records.append(archiveresult.to_json() if archiveresult else record)
queued_count += 1
elif record_type in {TYPE_BINARYREQUEST, TYPE_BINARY}:
@@ -248,7 +228,6 @@ def process_stdin_records() -> int:
crawl_id,
snapshot_ids=None if crawl_id in full_crawl_ids else sorted(snapshot_ids_by_crawl[crawl_id]),
selected_plugins=None if crawl_id in run_all_plugins_for_crawl else sorted(plugin_names_by_crawl[crawl_id]),
selected_plugins_are_explicit=False,
)
return 0
@@ -257,7 +236,6 @@ def run_runner(
daemon: bool = False,
crawl_id: str | None = None,
maintenance_only: bool = False,
maintenance_batch_size: int | None = None,
) -> int:
"""
Run the background runner loop.
@@ -304,19 +282,26 @@ def run_runner(
# future ownership lease cannot hide it from the unified scheduler.
if crawl is not None:
crawl.update_and_requeue(retry_at=now, refresh=False)
# Only a foreground `archivebox add` gets the interactive "abort current
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
# orchestrators should shut down immediately and cleanly on the first signal.
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
# Interactivity follows terminal ownership, not the spelling of the command.
# A supervised add worker has pipes and receives terminal intent/answers from
# its parent; direct run owns the terminal itself. Daemons and redirected
# commands must never wait for a person who cannot answer their prompt.
# Trust the explicit terminal-owner flag, not Process.root: when add borrows
# a server-owned supervisord, its worker's OS ancestry still leads to server.
# Gating on root=ADD silently made the first Ctrl+C abort after takeover.
interactive_interrupts = not daemon and (
(sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())) or os.environ.get("ARCHIVEBOX_INTERACTIVE_INTERRUPTS") == "1"
)
if daemon:
os.environ[RUNNER_DAEMON_ENV] = "1"
shutdown_state = None
try:
with (
foreground_shutdown_signals(
on_signal=_exit_daemon_runner_on_signal if daemon else None,
raise_on_first_signal=not daemon,
),
) as shutdown_state,
foreground_parent_watchdog(enabled=not daemon),
):
run_pending_crawls(
@@ -324,12 +309,17 @@ def run_runner(
crawl_id=crawl_id,
maintenance_only=maintenance_only,
interactive_interrupts=interactive_interrupts,
**({"maintenance_batch_size": maintenance_batch_size} if maintenance_batch_size else {}),
)
if daemon and shutdown_state.signal_name:
return 128 + signal.Signals[shutdown_state.signal_name].value
return 0
except KeyboardInterrupt:
return 0
if daemon and shutdown_state and shutdown_state.signal_name:
return 128 + signal.Signals[shutdown_state.signal_name].value
return 130
except asyncio.CancelledError as e:
if daemon and shutdown_state and shutdown_state.signal_name:
return 128 + signal.Signals[shutdown_state.signal_name].value
if daemon:
rprint(f"[red]Runner cancelled unexpectedly: {type(e).__name__}: {e}[/red]", file=sys.stderr)
return 1
@@ -349,13 +339,6 @@ def run_runner(
@click.option("--snapshot-id", help="Run one snapshot through its crawl")
@click.option("--binary-id", help="Run one queued binary install directly on the bus")
@click.option("--maintenance-only", is_flag=True, help="Only process sealed Snapshot maintenance and search-index backfills")
@click.option(
"--maintenance-batch-size",
type=int,
default=None,
hidden=True,
help="Limit queued maintenance snapshots claimed per scheduler tick",
)
@click.option("--no-stdin", is_flag=True, hidden=True, help="Run the scheduler even when stdin is not a TTY")
def main(
daemon: bool,
@@ -363,7 +346,6 @@ def main(
snapshot_id: str,
binary_id: str,
maintenance_only: bool,
maintenance_batch_size: int | None,
no_stdin: bool,
):
"""
@@ -388,13 +370,13 @@ def main(
),
foreground_parent_watchdog(enabled=False),
):
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
except KeyboardInterrupt:
sys.exit(0)
sys.exit(130)
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=not daemon):
if snapshot_id:
sys.exit(run_snapshot_worker(snapshot_id))
sys.exit(run_snapshot_worker(snapshot_id, daemon=daemon))
if binary_id:
try:
@@ -414,23 +396,22 @@ def main(
if crawl_id:
sys.exit(
run_runner(
daemon=False,
daemon=daemon,
crawl_id=crawl_id,
maintenance_only=maintenance_only,
maintenance_batch_size=maintenance_batch_size,
),
)
if maintenance_only:
sys.exit(run_runner(daemon=daemon, maintenance_only=True, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=daemon, maintenance_only=True))
if not no_stdin and not sys.stdin.isatty():
sys.exit(process_stdin_records())
else:
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
def run_snapshot_worker(snapshot_id: str) -> int:
def run_snapshot_worker(snapshot_id: str, *, daemon: bool = False) -> int:
from archivebox.config import CONSTANTS
from archivebox.core.takeover_util import enter_single_runner_gate
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
@@ -445,6 +426,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
return 0
snapshot = None
interactive_interrupts = not daemon and sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
try:
with foreground_shutdown_signals(), foreground_parent_watchdog():
for _ in range(10):
@@ -453,7 +435,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
snapshot.update_and_requeue(retry_at=timezone.now())
elif snapshot.retry_at > timezone.now():
break
if not run_due_snapshot(snapshot, lock_seconds=60):
if not run_due_snapshot(snapshot, lock_seconds=60, interactive_interrupts=interactive_interrupts):
break
return 0
except KeyboardInterrupt:
@@ -466,7 +448,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
snapshot.update_and_requeue(retry_at=timezone.now())
except Exception:
pass
return 0
return 130
except Exception as e:
rprint(f"[red]Runner error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
import traceback
+14 -6
View File
@@ -37,6 +37,7 @@ def schedule(
"created_schedule_ids": [],
"disabled_count": 0,
"run_all_enqueued": 0,
"run_all_maintained": 0,
"active_schedule_ids": [],
}
@@ -57,12 +58,12 @@ def schedule(
created_by_id = get_or_create_system_user_pk()
is_update_schedule = not import_path
template_urls = import_path or "archivebox://update"
template_urls = import_path or ""
template_label = (f"Scheduled import: {template_urls}" if import_path else "Scheduled ArchiveBox update")[:64]
template_notes = (
f"Created by archivebox schedule for {template_urls}"
if import_path
else "Created by archivebox schedule to queue recurring archivebox://update maintenance crawls."
else "Created by archivebox schedule to run recurring ArchiveBox maintenance."
)
template = Crawl.objects.create(
@@ -76,7 +77,6 @@ def schedule(
retry_at=None,
config={
"DEPTH": 0 if is_update_schedule else depth,
"SCHEDULE_KIND": "update" if is_update_schedule else "crawl",
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) win over the
# template defaults. Anything left unset falls through to the
# standard config stack at crawl-resolution time.
@@ -86,6 +86,7 @@ def schedule(
crawl_schedule = CrawlSchedule.objects.create(
template=template,
schedule=schedule_str,
config={**template.config, "SCHEDULE_KIND": "update" if is_update_schedule else "crawl"},
is_enabled=True,
label=template_label,
notes=template_notes,
@@ -119,12 +120,19 @@ def schedule(
if run_all:
enqueued = 0
maintained = 0
now = timezone.now()
for scheduled_crawl in schedules:
scheduled_crawl.enqueue(queued_at=now)
enqueued += 1
if scheduled_crawl.dispatch(queued_at=now) is None:
maintained += 1
else:
enqueued += 1
result["run_all_enqueued"] = enqueued
result["run_all_maintained"] = maintained
print(f"[green]\\[*] Enqueued {enqueued} scheduled crawl(s) immediately.[/green]")
if maintained:
run_pending_crawls(maintenance_only=True)
print(f"[green]\\[*] Ran {maintained} scheduled maintenance update(s).[/green]")
if enqueued:
print(
"[yellow]\\[*] Start `archivebox server`, `archivebox run --daemon`, or `archivebox schedule --foreground` to process the queued crawls.[/yellow]",
@@ -171,7 +179,7 @@ def schedule(
@click.option("--clear", is_flag=True, help="Disable all currently enabled schedules")
@click.option("--show", is_flag=True, help="Print all currently enabled schedules")
@click.option("--foreground", "-f", is_flag=True, help="Run the global crawl runner in the foreground (no crontab required)")
@click.option("--run-all", is_flag=True, help="Enqueue all enabled schedules immediately and process them once")
@click.option("--run-all", is_flag=True, help="Dispatch all enabled schedules immediately")
@click.argument("import_path", required=False)
@docstring(schedule.__doc__)
def main(**kwargs):
+18 -15
View File
@@ -46,13 +46,13 @@ def _split_bind_spec(spec: str) -> tuple[str, str]:
"""Split a ``host:port`` / ``host`` / ``port`` spec into ``(host, port)``.
The empty strings stand in for "not provided"; the caller fills in
defaults. Bracketed IPv6 literals like ``[::1]:8000`` are handled.
defaults. Bracketed IPv6 literals like ``[::1]:5797`` are handled.
"""
spec = (spec or "").strip()
if not spec:
return "", ""
if spec.startswith("["):
# Bracketed IPv6: ``[::1]`` or ``[::1]:8000``
# Bracketed IPv6: ``[::1]`` or ``[::1]:5797``
end = spec.find("]")
if end == -1:
return spec, "" # malformed; let validator reject it
@@ -77,11 +77,11 @@ def _parse_and_validate_bind_spec(spec: str) -> tuple[str, str]:
(normalized to ``127.0.0.1``). Bare hostnames are rejected because the
bind address feeds Daphne, which has to listen on a numeric address;
public hostnames belong in ``BASE_URL`` instead. Empty values fall back
to ``127.0.0.1`` / ``8000``.
to ``127.0.0.1`` / ``5797``.
"""
raw_host, raw_port = _split_bind_spec(spec)
host = raw_host.strip()
port = (raw_port or "").strip() or "8000"
port = (raw_port or "").strip() or "5797"
if host == "" or host.lower() == "localhost":
host = "127.0.0.1"
@@ -198,7 +198,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None:
"[yellow] routing cannot work against an IP address. Set BASE_URL explicitly, e.g.[/yellow]",
)
print(
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:8000[/yellow]",
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:5797[/yellow]",
)
if config.USES_SUBDOMAIN_ROUTING:
print(
@@ -221,7 +221,7 @@ def _print_server_startup_warnings(config, host: str, port: str) -> None:
"[yellow] a reverse proxy / ingress / public hostname, e.g.[/yellow]",
)
print(
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:8000[/yellow]",
"[yellow] BASE_URL=https://archive.example.com archivebox server 0.0.0.0:5797[/yellow]",
)
print()
@@ -254,7 +254,7 @@ def server(
print()
# First non-empty positional arg is the bind spec; otherwise inherit from
# config (which defaults to "127.0.0.1:8000"). _parse_and_validate_bind_spec
# config (which defaults to "127.0.0.1:5797"). _parse_and_validate_bind_spec
# hard-errors on hostnames so the rest of the server can assume a numeric
# bind host.
bind_spec = next((arg for arg in runserver_args if arg), "")
@@ -363,12 +363,10 @@ def server(
command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
def still_owns_runtime_stack() -> bool:
from django.db import connections
try:
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
finally:
connections.close_all()
# This is polled at the log-tail cadence. Keep the autocommit
# connection open so SQLite can reuse its page cache between polls;
# reconnecting here repeatedly rereads the process table while idle.
return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
shutdown_state = None
try:
@@ -416,8 +414,13 @@ def server(
except KeyboardInterrupt:
pass
finally:
if not shutdown_state or not shutdown_state.signal_name:
command.mark_exited()
from django.db import connections
try:
if not shutdown_state or not shutdown_state.signal_name:
command.mark_exited()
finally:
connections.close_all()
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
+7
View File
@@ -44,6 +44,7 @@ SNAPSHOT_LIST_CHUNK_SIZE = 5000
def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
from archivebox.config import VERSION
from archivebox.config.common import redact_sensitive_config
from archivebox.core.models import SnapshotTag
fields = (
@@ -53,6 +54,9 @@ def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
"title",
"bookmarked_at",
"created_at",
"modified_at",
"config",
"notes",
"timestamp",
"depth",
"status",
@@ -80,6 +84,9 @@ def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]:
"tags": ",".join(sorted(tags_by_snapshot[row["id"]])),
"bookmarked_at": row["bookmarked_at"].isoformat() if row["bookmarked_at"] else None,
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"modified_at": row["modified_at"].isoformat() if row["modified_at"] else None,
"config": redact_sensitive_config(row["config"]),
"notes": row["notes"],
"timestamp": row["timestamp"],
"depth": row["depth"],
"status": row["status"],
+202 -394
View File
@@ -7,6 +7,7 @@ import os
import asyncio
import shlex
import time
from itertools import islice
from typing import TYPE_CHECKING, Any
from collections.abc import Iterable
@@ -52,17 +53,20 @@ def _get_search_indexing_plugins() -> list[str]:
def _build_filtered_snapshots_queryset(
**kwargs,
):
from archivebox.core.models import Snapshot
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
limit = kwargs.pop("limit", None)
reverse = kwargs.pop("reverse", False)
snapshots = build_snapshot_queryset(**kwargs)
snapshots = snapshots.order_by(kwargs.get("sort") or "-created_at", "-id")
if reverse:
snapshots = snapshots.reverse()
if kwargs.get("resume"):
snapshots = snapshots.filter(timestamp__lte=kwargs["resume"])
snapshots = snapshots.filter(**{"timestamp__gte" if reverse else "timestamp__lte": kwargs["resume"]})
snapshots = snapshots.select_related("crawl")
if limit is not None and limit > 0:
snapshot_ids = list(snapshots.values_list("id", flat=True)[:limit])
snapshots = Snapshot.objects.filter(id__in=snapshot_ids).select_related("crawl")
snapshots = snapshots.filter(id__in=snapshot_ids)
return snapshots
@@ -73,111 +77,75 @@ def reindex_snapshots(
search_plugins: list[str],
batch_size: int,
collect_ids: bool = False,
reverse: bool = False,
wait_for_turn=None,
) -> dict[str, Any]:
from archivebox.cli.archivebox_extract import run_plugins
from archivebox.core.models import ArchiveResult, Snapshot
from abx_dl.models import discover_plugins
from django.db.models import Exists, OuterRef
# Search backfill is the one maintenance hook allowed to execute without
# reopening a Snapshot. Restrict that exception to already-sealed rows;
# every open lifecycle state remains owned by the normal state machine.
# every open lifecycle state remains owned by the normal runner lifecycle.
snapshots = snapshots.filter(status=Snapshot.StatusChoices.SEALED)
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "skipped_queued": 0, "reindexed": 0, "snapshot_ids": []}
records: list[dict[str, str]] = []
plugins_by_name = discover_plugins(runtime="archivebox")
required_hooks_by_plugin = {
plugin_name: frozenset(hook.name for hook in plugins_by_name[plugin_name].filter_hooks("Snapshot"))
for plugin_name in search_plugins
if plugin_name in plugins_by_name
}
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
print(f"[*] Reindexing missing search indexes with: {', '.join(search_plugins)}")
total = snapshots.count()
print(f"[*] Reindexing {total} snapshots with search plugins: {', '.join(search_plugins)}")
def run_batch() -> None:
if not records:
return
if wait_for_turn:
wait_for_turn()
batch_records = list(records)
snapshot_ids = {record["snapshot_id"] for record in batch_records}
plugin_names = {record["plugin"] for record in batch_records}
queued_rows = {
(str(snapshot_id), plugin_name, hook_name)
for snapshot_id, plugin_name, hook_name in ArchiveResult.objects.filter(
snapshot_id__in=snapshot_ids,
plugin__in=plugin_names,
status=ArchiveResult.StatusChoices.QUEUED,
).values_list("snapshot_id", "plugin", "hook_name")
}
records_to_queue = []
for record in batch_records:
snapshot_id = record["snapshot_id"]
plugin_name = record["plugin"]
required_hooks = required_hooks_by_plugin.get(plugin_name, frozenset())
if required_hooks and all((snapshot_id, plugin_name, hook_name) in queued_rows for hook_name in required_hooks):
stats["skipped_queued"] += 1
continue
records_to_queue.append(record)
if not records_to_queue:
print(
f" [{stats['processed']}/{total}] Already queued {len(batch_records)} index jobs",
)
records.clear()
return
# `archivebox update --index-only` intentionally breaks the usual
# "runner discovers work" rule by inserting synthetic queued
# ArchiveResult rows for search backends. run_plugins() keeps this as
# statement-sized UPDATE/bulk_create work, then bumps Snapshot.retry_at
# so the orchestrator owns actual hook execution. Paused snapshots stay
# PAUSED; run_due_snapshot restores retry_at=MAX after targeted rows
# finish.
exit_code = run_plugins(
args=(),
records=records_to_queue,
wait=False,
emit_results=False,
show_progress=False,
preserve_queued=True,
completed_statuses = [ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS]
for plugin_name in search_plugins:
completed_result = ArchiveResult.objects.filter(
snapshot_id=OuterRef("pk"),
plugin=plugin_name,
status__in=completed_statuses,
)
if exit_code != 0:
raise SystemExit(exit_code)
stats["queued"] += len(records_to_queue)
print(
f" [{stats['processed']}/{total}] Queued {len(records_to_queue)} index jobs for orchestrator",
)
records.clear()
for snapshot in snapshots.select_related("crawl").paged_iterator(chunk_size=batch_size):
try:
stats["processed"] += 1
if _get_snapshot_crawl(snapshot) is None:
continue
candidates = snapshots.annotate(has_completed_index=Exists(completed_result)).filter(has_completed_index=False)
candidates = candidates.order_by("created_at", "id") if reverse else candidates.order_by("-created_at", "-id")
remaining = candidates.select_related(None).only("id", "timestamp", "created_at").paged_iterator(chunk_size=batch_size)
while True:
if wait_for_turn:
wait_for_turn()
batch = list(islice(remaining, batch_size))
if not batch:
break
records = [{"type": "Snapshot", "id": str(snapshot.id)} for snapshot in batch]
stats["processed"] += len(batch)
stats["requested"] += len(batch)
if collect_ids:
stats["snapshot_ids"].append(str(snapshot.id))
for plugin_name in search_plugins:
records.append(
{
"type": "ArchiveResult",
"snapshot_id": str(snapshot.id),
"plugin": plugin_name,
},
)
stats["requested"] += 1
if len(records) >= batch_size:
run_batch()
except KeyboardInterrupt as err:
err.archivebox_resume = snapshot.timestamp
raise
run_batch()
stats["snapshot_ids"].extend(str(snapshot.id) for snapshot in batch)
exit_code = run_plugins(
args=(),
records=records,
plugins=plugin_name,
wait=True,
emit_results=False,
show_progress=False,
)
if exit_code != 0:
raise SystemExit(exit_code)
stats["reindexed"] += len(batch)
print(f" [{plugin_name}] indexed {stats['reindexed']} missing snapshots")
return stats
def run_scheduled_maintenance(*, batch_size: int = 500) -> dict[str, Any]:
"""Queue stale filesystem rows and backfill missing search facts."""
from archivebox.core.models import Snapshot
filesystem_stats = process_all_db_snapshots(batch_size=batch_size)
search_plugins = _get_search_indexing_plugins()
search_stats = (
reindex_snapshots(
Snapshot.objects.filter(fs_version=Snapshot._fs_current_version()),
search_plugins=search_plugins,
batch_size=batch_size,
)
if search_plugins
else {"processed": 0, "requested": 0, "queued": 0, "reindexed": 0, "snapshot_ids": []}
)
return {"filesystem": filesystem_stats, "search": search_stats}
@enforce_types
def update(
filter_patterns: Iterable[str] = (),
@@ -197,18 +165,18 @@ def update(
continuous: bool = False,
index_only: bool = False,
migrate_only: bool = False,
rescan: bool = False,
reverse: bool = False,
stop_daemon_stack: bool = True,
) -> None:
"""
Update snapshots: migrate old dirs, reconcile DB, and re-queue for archiving.
Update the database without forcing a filesystem scan by default.
Three-phase operation (without filters):
- Phase 1: Drain old archive/ dirs by moving to new fs location (0.8.x → 0.9.x)
- Phase 2: O(n) scan over entire DB from most recent to least recent
- No orphan scans needed (trust 1:1 mapping between DB and filesystem after phase 1)
With filters: Only phase 2 (DB query), no filesystem operations.
Without filters: All phases (full update).
--migrate-only: eagerly finish pending filesystem migrations for known rows.
--index-only: reconcile known snapshot assets/metadata and backfill search.
--rescan: discover filesystem orphans first, then repair known snapshots.
Explicit scans default to newest first; --reverse selects oldest first.
Filters select existing database snapshots for targeted updates.
"""
from rich import print
@@ -232,7 +200,6 @@ def update(
from archivebox.core.takeover_util import (
command_owns_foreground_runner,
current_command,
foreground_runner_owner,
standby_until_foreground_runner_needed,
)
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
@@ -285,17 +252,28 @@ def update(
exit_code = 0
try:
if rescan and (is_filtered_update or resume or continuous):
raise click.UsageError("--rescan cannot be combined with filters, --resume, or --continuous; rerun it to resume automatically.")
if rescan:
migrate_only = True # Discovery must not schedule new captures.
wait_for_turn()
with foreground_shutdown_signals(), foreground_parent_watchdog():
while True:
do_migrate = migrate_only or not index_only
do_index = index_only or not migrate_only
do_run_until_idle = do_migrate or do_index
ran_post_migrate_runner = False
full_update_empty = False
maintenance_work_queued = False
runner_work_queued = False
if rescan:
from archivebox.cli.rescan import rescan_snapshots
rescan_snapshots(
wait_for_turn=wait_for_turn,
reverse=reverse,
scan_legacy=lambda: drain_old_archive_dirs(batch_size=batch_size, reverse=reverse),
)
do_migrate = migrate_only or is_filtered_update and not index_only
do_index = index_only
if not do_migrate and not do_index:
print(
"[*] Collection ready. Filesystem migrations remain lazy; use --migrate-only, --index-only, or --rescan for explicit scans.",
)
if do_migrate:
if (
@@ -327,178 +305,86 @@ def update(
after=after,
resume=resume,
batch_size=batch_size,
queue_for_archiving=do_run_until_idle,
queue_for_archiving=not migrate_only,
reverse=reverse,
wait_for_turn=wait_for_turn,
)
print_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
maintenance_work_queued = stats.get("queued", 0) > 0
runner_work_queued = runner_work_queued or maintenance_work_queued
else:
stats_combined = {"phase1": {}, "phase2": {}}
print("[*] Phase 1: Draining old archive/ directories (0.8.x → 0.9.x migration)...")
stats_combined["phase1"] = drain_old_archive_dirs(
resume_from=resume,
batch_size=batch_size,
)
print("[*] Phase 2: Processing all database snapshots (most recent first)...")
print("[*] Phase 2: Selecting database snapshots with stale filesystem versions...")
stats_combined["phase2"] = process_all_db_snapshots(
batch_size=batch_size,
resume=resume,
wait_for_turn=wait_for_turn,
reverse=reverse,
migrate=True,
)
print_combined_stats(stats_combined)
full_update_empty = (
stats_combined["phase1"].get("processed", 0) == 0 and stats_combined["phase2"].get("snapshots", 0) == 0
)
maintenance_work_queued = any(
(
stats_combined["phase1"].get("queued", 0),
stats_combined["phase2"].get("queued", 0),
stats_combined["phase2"].get("crawls_sealed", 0),
),
)
runner_work_queued = runner_work_queued or maintenance_work_queued
if do_run_until_idle:
# Filesystem migration is maintenance on existing
# Snapshot rows: Snapshot.save() moves archive/<ts> to
# the current output_dir and preserves the lifecycle
# status. Drain those retry_at ticks before queuing
# search backfill below. Otherwise the sealed search
# runner branch correctly sees queued ArchiveResult
# rows first, runs the targeted plugins, and may leave
# the fs_version maintenance tick hidden behind that
# plugin work until another update pass.
if full_update_empty:
print("[*] No snapshots or legacy archive directories found; skipping filesystem maintenance runner.")
elif not maintenance_work_queued:
print("[*] No filesystem maintenance work queued; skipping filesystem maintenance runner.")
else:
print("[*] Phase 3: Running filesystem maintenance until idle...")
if full_update_empty:
pass
elif not maintenance_work_queued:
pass
elif is_filtered_update:
if not touched_snapshot_ids:
print("[*] No matching snapshots queued work for the runner.")
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
run_scoped_runner("--maintenance-only", "--maintenance-batch-size", str(batch_size))
ran_post_migrate_runner = True
# The due selectors are indexed and cheap when empty, so
# always drain them instead of preceding the runner with
# whole-table counts merely to decide whether to call it.
print("[*] Phase 3: Running filesystem maintenance until idle...")
if is_filtered_update:
for snapshot_id in sorted(touched_snapshot_ids, reverse=not reverse):
run_scoped_runner("--snapshot-id", snapshot_id)
elif migrate_only:
run_scoped_runner("--maintenance-only")
else:
run_scoped_runner()
if do_index:
if full_update_empty:
print("[*] No snapshots found; skipping search indexing backfill.")
else:
search_plugins = _get_search_indexing_plugins()
if not search_plugins:
print("[*] No search indexing plugins are available, nothing to backfill.")
else:
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
status=status,
url__icontains=url__icontains,
url__istartswith=url__istartswith,
tag=tag,
crawl_id=crawl_id,
limit=limit,
sort=sort,
search=search,
before=before,
after=after,
resume=resume,
)
from django.db.models import Exists, OuterRef, Q
from django.utils import timezone
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.cli.rescan import reconcile_known_snapshots
scoped_snapshot_ids = snapshots.order_by().values("id") if is_filtered_update else None
queued_index_results = ArchiveResult.objects.filter(
status=ArchiveResult.StatusChoices.QUEUED,
plugin__in=search_plugins,
)
if scoped_snapshot_ids is not None:
queued_index_results = queued_index_results.filter(snapshot_id__in=scoped_snapshot_ids)
if queued_index_results.exists():
runner_work_queued = True
now = timezone.now()
queued_result_for_snapshot = queued_index_results.filter(snapshot_id=OuterRef("pk"))
snapshots_to_wake = (
Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
.annotate(
has_queued_index_result=Exists(queued_result_for_snapshot),
)
.filter(
has_queued_index_result=True,
)
.filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
)
)
if scoped_snapshot_ids is not None:
snapshots_to_wake = snapshots_to_wake.filter(id__in=scoped_snapshot_ids)
woken_count = snapshots_to_wake.update(
retry_at=now,
modified_at=now,
)
print(
"[*] Existing queued search index jobs found; "
f"skipping backfill scan and waking {woken_count} snapshot(s) for the runner.",
)
else:
collect_index_ids = (
is_filtered_update
or foreground_runner_owner(
data_dir=CONSTANTS.DATA_DIR,
exclude_id=command.id,
)
is not None
)
stats = reindex_snapshots(
snapshots,
search_plugins=search_plugins,
batch_size=batch_size,
collect_ids=collect_index_ids,
wait_for_turn=wait_for_turn,
)
print_index_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
runner_work_queued = runner_work_queued or stats["queued"] > 0
if do_run_until_idle and (do_index or not ran_post_migrate_runner):
# Search/index backfill intentionally queues targeted
# ArchiveResult rows without reopening sealed snapshots.
# This second runner pass drains those plugin
# rows after filesystem maintenance has had its own turn.
# For a normal unfiltered `archivebox update`, keep the
# historical final pass broad enough to resume genuinely
# queued/interrupted crawl work after maintenance is done.
if full_update_empty:
print("[*] No snapshots found; skipping queued/interrupted crawl runner.")
elif not runner_work_queued:
print("[*] No queued/interrupted crawl work found; skipping queued/interrupted crawl runner.")
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
status=status,
url__icontains=url__icontains,
url__istartswith=url__istartswith,
tag=tag,
crawl_id=crawl_id,
limit=limit,
sort=sort,
search=search,
before=before,
after=after,
resume=resume,
reverse=reverse,
)
reconcile_known_snapshots(snapshots, wait_for_turn=wait_for_turn)
search_plugins = _get_search_indexing_plugins()
if not search_plugins:
print("[*] No search indexing plugins are available, nothing to backfill.")
else:
print("[*] Phase 3: Running queued/interrupted crawl work until idle...")
if full_update_empty:
pass
elif not runner_work_queued:
pass
elif touched_snapshot_ids and is_filtered_update:
if not touched_snapshot_ids:
print("[*] No matching snapshots queued work for the runner.")
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
run_scoped_runner(
*(["--maintenance-only", "--maintenance-batch-size", str(batch_size)] if index_only or migrate_only else []),
snapshots = _build_filtered_snapshots_queryset(
filter_patterns=filter_patterns,
filter_type=filter_type,
status=status,
url__icontains=url__icontains,
url__istartswith=url__istartswith,
tag=tag,
crawl_id=crawl_id,
limit=limit,
sort=sort,
search=search,
before=before,
after=after,
resume=resume,
reverse=reverse,
)
stats = reindex_snapshots(
snapshots,
search_plugins=search_plugins,
batch_size=batch_size,
collect_ids=is_filtered_update,
reverse=reverse,
wait_for_turn=wait_for_turn,
)
print_index_stats(stats)
touched_snapshot_ids.update(stats.get("snapshot_ids", []))
if not continuous:
break
@@ -510,6 +396,10 @@ def update(
exit_code = 130
exact_resume = err.__dict__.get("archivebox_resume")
resume_cmd = ["archivebox", "update"]
if rescan:
resume_cmd.append("--rescan")
if reverse:
resume_cmd.append("--reverse")
if migrate_only:
resume_cmd.append("--migrate-only")
if index_only:
@@ -557,7 +447,7 @@ def update(
stop_own_supervisord_process()
def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500) -> dict[str, int]:
def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500, reverse: bool = False) -> dict[str, int]:
"""
Drain old archive/ directories (0.8.x → 0.9.x migration).
@@ -623,18 +513,18 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
# Scan real legacy directories only; these still contain data to migrate.
entries = [
(e.stat().st_mtime, e.path)
(float(e.name), e.path)
for e in all_entries
if e.is_dir(follow_symlinks=False) and Snapshot.is_legacy_archive_dir(Path(e.path)) # Skip symlinks and 0.9.x roots
]
entries.sort(reverse=True) # Newest first
entries.sort(reverse=not reverse) # Newest first
print(f"[*] Found {len(entries)} old directories to drain")
for mtime, entry_path in entries:
entry_path = Path(entry_path)
# Resume from timestamp if specified
if resume_from and entry_path.name > resume_from:
if resume_from and (entry_path.name < resume_from if reverse else entry_path.name > resume_from):
continue
stats["processed"] += 1
@@ -736,19 +626,15 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
return stats
def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, wait_for_turn=None) -> dict[str, int]:
"""
O(n) scan over entire DB from most recent to least recent.
For each snapshot:
1. Reconcile index.json with DB (merge titles, tags, archive results)
2. Mark migrated snapshots sealed unless explicitly re-queued elsewhere
No orphan detection needed - we trust 1:1 mapping between DB and filesystem
after Phase 1 has drained all old archive/ directories.
"""
def process_all_db_snapshots(
batch_size: int = 500,
resume: str | None = None,
wait_for_turn=None,
reverse: bool = False,
migrate: bool = False,
) -> dict[str, int]:
"""Queue only snapshots whose indexed filesystem version is stale."""
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
from django.db.models import Q
from django.utils import timezone
@@ -761,128 +647,41 @@ def process_all_db_snapshots(batch_size: int = 500, resume: str | None = None, w
"sealed": 0,
"crawls_sealed": 0,
}
current_fs_version = Snapshot._fs_current_version()
queryset = Snapshot.objects.all()
queryset = Snapshot.objects.filter(fs_version__in=Snapshot._FS_VERSION_MIGRATION_PATHS)
if resume:
queryset = queryset.filter(timestamp__lte=resume)
total = queryset.count()
stats["snapshots"] = total
print(f"[*] Processing {total} snapshots from database (most recent first)...")
def update_in_batches(rows, *, label: str, **updates) -> int:
updated = 0
checked = 0
while True:
if wait_for_turn:
wait_for_turn()
batch = list(rows.only("id", "modified_at").order_by("-timestamp")[:batch_size])
if not batch:
if updated:
print(f" [{label}] complete: {updated} rows updated")
return updated
checked += len(batch)
print(f" [{label}] updating next {len(batch)} rows (seen {checked})...")
for snapshot in batch:
# This maintenance scan intentionally bypasses save(); it is
# only normalizing scheduler fields, and Snapshot.save() may
# do filesystem migration work that belongs in the runner.
# Guard each single-row UPDATE with modified_at so stale scan
# pages cannot overwrite newer runner/admin writes.
updated += int(
snapshot.safe_update(
updates,
refresh=False,
extra_filter={"modified_at": snapshot.modified_at},
),
)
print(f" [{label}] updated {updated} rows so far")
now = timezone.now()
updated_rows = update_in_batches(
queryset.exclude(
status__in=[
Snapshot.StatusChoices.QUEUED,
Snapshot.StatusChoices.STARTED,
Snapshot.StatusChoices.PAUSED,
Snapshot.StatusChoices.SEALED,
],
),
label="snapshot status normalization",
status=Snapshot.StatusChoices.SEALED,
retry_at=None,
modified_at=now,
)
stats["sealed"] += updated_rows
stats["updated_db"] += updated_rows
fs_version_rows = queryset.exclude(fs_version=current_fs_version).filter(Q(retry_at__isnull=True) | Q(retry_at__gt=now))
stale_batch = []
def queue_stale_fs_batch() -> None:
if not stale_batch:
return
queryset = queryset.filter(**{"timestamp__gte" if reverse else "timestamp__lte": resume})
initial_now = timezone.now()
rows_to_wake = queryset if migrate else queryset.filter(Q(retry_at__isnull=True) | Q(retry_at__gt=initial_now))
ordered = rows_to_wake.order_by("created_at", "id") if reverse else rows_to_wake.order_by("-created_at", "-id")
remaining = ordered.select_related("crawl__created_by").paged_iterator(chunk_size=batch_size)
while True:
if wait_for_turn:
wait_for_turn()
batch = list(islice(remaining, batch_size))
if not batch:
break
now = timezone.now()
# Do not bump fs_version here. The orchestrator calls Snapshot.save(),
# which performs the idempotent filesystem migration and commits the new
# fs_version in the same serialized worker path as normal crawls.
updated = 0
for snapshot in stale_batch:
# Each row gets its own short autocommit UPDATE because this scan
# can touch millions of snapshots while a server is also alive.
# The modified_at predicate is the CAS guard: if the runner or
# admin changed the snapshot after paged_iterator read it, skip it
# and let the newer state decide whether migration is still due.
for snapshot in batch:
if migrate:
from archivebox.services.runner import run_snapshot_maintenance
if wait_for_turn:
wait_for_turn()
run_snapshot_maintenance(str(snapshot.id))
continue
updated += int(
snapshot.safe_update(
{
"retry_at": now,
"modified_at": now,
},
{"retry_at": now, "modified_at": now},
refresh=False,
extra_filter={"fs_version": snapshot.fs_version},
),
)
stats["processed"] += len(stale_batch)
stats["processed"] += len(batch)
stats["updated_db"] += updated
stats["queued"] += updated
print(f" [{stats['processed']}/{total}] Queued {updated} filesystem migrations for orchestrator...")
stale_batch.clear()
for snapshot in (
fs_version_rows.only("id", "crawl_id", "timestamp", "fs_version", "modified_at")
.order_by("-timestamp")
.paged_iterator(chunk_size=batch_size)
):
try:
stale_batch.append(snapshot)
if len(stale_batch) >= batch_size:
queue_stale_fs_batch()
except KeyboardInterrupt as err:
err.archivebox_resume = snapshot.timestamp
raise
queue_stale_fs_batch()
now = timezone.now()
# Crawls with no open child snapshots are already finished. Seal them here
# instead of waking the foreground runner; otherwise migration/update can
# accidentally re-enter full crawl execution for historical rows.
stats["crawls_sealed"] = (
Crawl.objects.filter(
status__in=Crawl.RUNNABLE_STATES,
)
.exclude(
snapshot_set__status__in=Snapshot.OPEN_STATES,
)
.update(
status=Crawl.StatusChoices.SEALED,
retry_at=None,
modified_at=now,
)
)
stats["updated_db"] += stats["crawls_sealed"]
print(f" Queued {stats['queued']} stale filesystem snapshots so far...")
stats["snapshots"] = stats["processed"]
return stats
@@ -902,6 +701,7 @@ def process_filtered_snapshots(
resume: str | None,
batch_size: int,
queue_for_archiving: bool = True,
reverse: bool = False,
wait_for_turn=None,
) -> dict[str, Any]:
"""Process snapshots matching filters (DB query only)."""
@@ -924,6 +724,7 @@ def process_filtered_snapshots(
before=before,
after=after,
resume=resume,
reverse=reverse,
)
total = snapshots.count()
@@ -942,8 +743,10 @@ def process_filtered_snapshots(
stats["snapshot_ids"].append(str(snapshot.id))
update_values = {}
updated = 0
if not isinstance(snapshot.current_step, int):
update_values["current_step"] = 0
if not queue_for_archiving and snapshot.fs_migration_needed:
from archivebox.services.runner import run_snapshot_maintenance
run_snapshot_maintenance(str(snapshot.id))
if queue_for_archiving:
update_values.update(
{
@@ -1029,10 +832,9 @@ def print_index_stats(stats: dict[str, Any]) -> None:
print(f"""
[green]Search Reindex Complete[/green]
Scanned rows: {stats["processed"]}
Requested jobs: {stats.get("requested", stats["queued"])}
Queued index jobs: {stats["queued"]}
Already queued: {stats.get("skipped_queued", 0)}
Missing index runs: {stats["processed"]}
Requested runs: {stats.get("requested", 0)}
Indexed snapshots: {stats.get("reindexed", 0)}
""")
@@ -1042,6 +844,12 @@ def print_index_stats(stats: dict[str, Any]) -> None:
@click.option("--continuous", is_flag=True, help="Run continuously as background worker")
@click.option("--index-only", is_flag=True, help="Backfill available search indexes from existing archived content")
@click.option("--migrate-only", is_flag=True, help="Only migrate filesystem and update database/index state")
@click.option("--reverse", is_flag=True, help="Process oldest first instead of newest first for explicit scans")
@click.option(
"--rescan",
is_flag=True,
help="Scan all snapshot directories for orphans and metadata repairs (slow, opt-in, safe to restart)",
)
@snapshot_filter_options(default_filter_type="exact")
@docstring(update.__doc__)
def main(**kwargs):
+105 -167
View File
@@ -80,20 +80,6 @@ def _render_binary_abspath(abspath: str):
return Text(abspath, style="green")
def _binary_record_matches_runtime(installed, lib_dir: Path) -> bool:
if not installed or not installed.is_valid or not installed.version:
return False
try:
abspath = Path(installed.abspath).expanduser().resolve(strict=False)
if not abspath.exists():
return False
if installed.binprovider not in {"", "env", "apt", "brew"}:
abspath.relative_to(lib_dir)
except (OSError, ValueError):
return False
return True
def _binary_row_dedupe_key(
*,
display_name: str,
@@ -127,7 +113,6 @@ def version(
from rich.panel import Panel
from rich.console import Console
from rich.live import Live
from abx_dl.tables import binary_dependency_status, binary_dependency_table
from archivebox.config import CONSTANTS
@@ -263,47 +248,37 @@ def version(
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
failures = []
seen_failures: set[str] = set()
seen_rows: set[tuple[str, str, str, str]] = set()
from archivebox.plugins.discovery import get_enabled_plugins
from archivebox.plugins.discovery import get_enabled_plugins, get_plugin_catalog
from abx_dl.config import get_required_binary_requests
from abx_dl.dependencies import resolve_binary_requests
from abx_dl.models import discover_plugins, filter_plugins
from abx_dl.orchestrator import create_bus
from abxpkg.binary_service import BinaryEvent, BinaryService
plugins = discover_plugins(runtime="archivebox")
enabled_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
enabled_plugin_names = set(enabled_plugins)
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
plugins = get_plugin_catalog()
enabled_plugin_names = set(get_enabled_plugins(config=config))
runtime_config = normalize_runtime_config(dict(config.items()), json_safe=False)
derived_config: dict[str, object] = {}
db_binaries = {}
db_available = False
if in_data_dir:
try:
from archivebox.config.django import setup_django
setup_django()
from archivebox.machine.models import Machine, Binary
from archivebox.machine.models import Machine
machine = Machine.current()
derived_config = normalize_runtime_config(machine.config, json_safe=False)
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
if _binary_record_matches_runtime(binary, config.ABXPKG_LIB_DIR):
db_binaries.setdefault(binary.name, binary)
db_available = True
except Exception as e:
prnt()
prnt("", f"[yellow]Warning: Could not query collection binary records; resolving through abxpkg: {e}[/yellow]")
prnt("", f"[yellow]Warning: Could not query collection machine config; resolving through abxpkg: {e}[/yellow]")
declared_binary_specs: dict[str, dict[str, object]] = {}
for plugin_name, plugin in plugins.items():
if not plugin_may_have_requested_binary(plugin):
continue
plugin_requested = bool(requested_names)
plugin_enabled = plugin_name in enabled_plugin_names
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
binary_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
@@ -321,10 +296,9 @@ def version(
display_name = logical_name
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
continue
if not plugin_enabled and not requested_names:
continue
if _binary_record_matches_runtime(db_binaries.get(logical_name), config.ABXPKG_LIB_DIR):
continue
# This is a diagnostic: recheck the provider instead of trusting an
# installation record or a cached version, including disabled plugins.
binary_record["no_cache"] = True
signature = json.dumps(binary_record, sort_keys=True, default=str)
declared_binary_specs.setdefault(signature, binary_record)
@@ -342,126 +316,47 @@ def version(
loaded_binaries = asyncio.run(resolve_declared_binaries())
rows: list[dict[str, object]] = []
rows_by_binary: dict[str, dict] = {}
any_rows = False
any_available = False
compact_paths = console.is_terminal
live_enabled = console.is_terminal
live_cm = Live(binary_dependency_table(rows), console=console, refresh_per_second=8) if live_enabled else None
def emit_row(row: dict[str, object]) -> None:
rows.append(row)
if live_cm is not None:
live_cm.update(binary_dependency_table(rows), refresh=True)
if live_cm is not None:
live_cm.start()
try:
for plugin_name, plugin in plugins.items():
if not plugin_may_have_requested_binary(plugin):
continue
plugin_requested = bool(requested_names)
plugin_enabled = plugin_name in enabled_plugin_names
binary_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
for plugin_name, plugin in plugins.items():
if not plugin_may_have_requested_binary(plugin):
continue
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
plugin_enabled = plugin_name in enabled_plugin_names
disabled_by = ", ".join(
f"{dependency.enabled_key}=False"
for dependency in plugins.select([plugin_name]).values()
if not config.get(dependency.enabled_key, True)
)
binary_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
)
for binary_record in binary_records:
binary_record["no_cache"] = True
actual_name = str(binary_record["name"])
logical_name = (
Path(actual_name).expanduser().name
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
else actual_name
)
for binary_record in binary_records:
actual_name = str(binary_record["name"])
logical_name = (
Path(actual_name).expanduser().name
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
else actual_name
)
display_name = logical_name
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
continue
display_name = logical_name
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
continue
installed = db_binaries.get(logical_name) if db_available else None
if _binary_record_matches_runtime(installed, config.ABXPKG_LIB_DIR):
abspath = installed.abspath
version_str = (installed.version or "unknown")[:15]
provider = (installed.binprovider or "env")[:8]
valid = True
elif not plugin_enabled and not requested_names:
# `archivebox version` is expected to verify the active runtime, not
# cold-load every optional plugin provider. Migration and status
# checks often run with PLUGINS narrowed to a tiny set; resolving
# disabled plugin binaries there can spend most of the command on
# providers the current collection will never execute.
continue
else:
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
abspath = loaded.abspath if loaded is not None else ""
version_str = str(loaded.version or "unknown")[:15] if loaded is not None else "unknown"
provider = str(loaded.binprovider or "env")[:8] if loaded is not None else "env"
valid = loaded is not None
any_rows = True
if valid:
display_path = (
_format_binary_abspath(
abspath,
pwd=Path.cwd(),
lib_dir=config.ABXPKG_LIB_DIR,
personas_dir=CONSTANTS.PERSONAS_DIR,
home=Path.home(),
)
if compact_paths
else abspath
)
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
any_available = True
else:
rendered_path = "[grey53]not installed[/grey53]"
if plugin_enabled and display_name not in seen_failures:
failures.append(display_name)
seen_failures.add(display_name)
row_key = _binary_row_dedupe_key(
display_name=display_name,
valid=valid,
version=version_str if valid else "-",
provider=provider if valid else "-",
abspath=abspath,
)
if row_key in seen_rows:
continue
seen_rows.add(row_key)
emit_row(
{
"plugin": plugin_name,
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
"binary": display_name,
"version": version_str if valid else "-",
"provider": provider if valid else "-",
"path": rendered_path,
"style": "" if plugin_enabled else "dim",
},
)
if db_available:
for binary_name, installed in db_binaries.items():
if requested_names and binary_name not in requested_names:
continue
abspath = installed.abspath
version_str = (installed.version or "unknown")[:15]
provider = (installed.binprovider or "env")[:8]
row_key = _binary_row_dedupe_key(
display_name=binary_name,
valid=True,
version=version_str,
provider=provider,
abspath=abspath,
)
if row_key in seen_rows:
continue
seen_rows.add(row_key)
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
abspath = loaded.abspath if loaded is not None else ""
version_str = str(loaded.version or "unknown") if loaded is not None else "unknown"
provider = str(loaded.binprovider or "env") if loaded is not None else "env"
valid = loaded is not None and Path(abspath).is_file()
any_rows = True
if valid:
display_path = (
_format_binary_abspath(
abspath,
@@ -473,24 +368,67 @@ def version(
if compact_paths
else abspath
)
emit_row(
{
"plugin": "(database)",
"status": binary_dependency_status(enabled=True, valid=True),
"binary": binary_name,
"version": version_str,
"provider": provider,
"path": _render_binary_abspath(display_path) if compact_paths else display_path,
"style": "dim",
},
)
any_rows = True
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
any_available = True
finally:
if live_cm is not None:
live_cm.stop()
else:
prnt(binary_dependency_table(rows))
else:
rendered_path = "[grey53]not installed[/grey53]"
if plugin_enabled and display_name not in seen_failures:
failures.append(display_name)
seen_failures.add(display_name)
if not plugin_enabled:
rendered_path = f"[grey53]disabled by {disabled_by or 'PLUGINS configuration'}[/grey53]"
row = {
"plugin": plugin_name,
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
"binary": display_name,
"version": version_str if valid else "-",
"provider": provider if valid else "-",
"path": rendered_path,
"style": "" if plugin_enabled else "dim",
"enabled": plugin_enabled,
"valid": valid,
"plugins": [plugin_name],
"disabled_by": [disabled_by or "PLUGINS configuration"] if not plugin_enabled else [],
}
existing = rows_by_binary.get(display_name)
if existing is None:
rows_by_binary[display_name] = row
else:
consumers = list(dict.fromkeys([*existing["plugins"], plugin_name]))
disabled_reasons = list(dict.fromkeys([*existing["disabled_by"], *row["disabled_by"]]))
# Disabled consumers cannot mask an active requirement. If any
# active request fails (e.g. a version floor), keep that failure.
if plugin_enabled and (not existing["enabled"] or not valid):
existing.update(row)
existing["plugins"] = consumers
existing["disabled_by"] = disabled_reasons
existing["plugin"] = ", ".join(consumers)
if not existing["enabled"]:
existing["path"] = f"[grey53]disabled by {', '.join(disabled_reasons)}[/grey53]"
rows = list(rows_by_binary.values())
if console.is_terminal and console.width < 120:
from rich.text import Text
for row in rows:
heading = Text.from_markup(str(row["status"]))
heading.append(f" {row['binary']} {row['version']} ({row['provider']}) · {row['plugin']}")
prnt(heading, style=str(row["style"]))
path = row["path"]
prnt(Text.assemble(" ", path if isinstance(path, Text) else Text.from_markup(str(path))), style=str(row["style"]))
else:
table = binary_dependency_table(rows)
table.expand = False
table.title = None
table.box = None
for column in table.columns:
column.width = None
column.max_width = None
column.ratio = None
prnt(table)
if not any_rows:
prnt("", "[grey53]No required binaries declared for discovered plugins.[/grey53]")
+222
View File
@@ -0,0 +1,222 @@
"""Explicit discovery of portable snapshots; no checkpoint or default tree walk."""
import json
import os
import uuid
from itertools import groupby
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from rich import print
from archivebox.config import CONSTANTS
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.misc.util import parse_date
def _directories(parent: Path):
# Names encode dates / UUIDv7 creation time. Newest first within each
# subtree, with bounded memory and no traversal into plugin payloads.
with os.scandir(parent) as entries:
paths = [Path(entry.path) for entry in entries if entry.is_dir(follow_symlinks=False)]
yield from sorted(paths, key=lambda path: path.name, reverse=True)
def _snapshot_directories(reverse=False):
dates = []
for user in _directories(CONSTANTS.USERS_DIR):
root = user / CONSTANTS.SNAPSHOTS_DIR_NAME
if root.is_symlink() or not root.is_dir():
continue
dates.extend(_directories(root))
dates.sort(key=lambda path: path.name, reverse=not reverse)
for _, date_group in groupby(dates, key=lambda path: path.name):
directories = [directory for date in date_group for domain in _directories(date) for directory in _directories(domain)]
yield from sorted(directories, key=lambda path: path.name, reverse=not reverse)
def _fields(record, names):
return {name: record[name] for name in names.split() if name in record}
def _dates(record):
return {name: parse_date(record[name]) for name in ("created_at", "modified_at") if record.get(name)}
def _restore_dates(obj, record):
dates = _dates(record)
if dates:
type(obj).objects.filter(pk=obj.pk).update(**dates)
for name, value in dates.items():
setattr(obj, name, value)
def _reconcile_directory(directory: Path) -> str:
snapshot_id = uuid.UUID(directory.name)
snapshot = Snapshot.objects.select_related("crawl__created_by").filter(pk=snapshot_id).first()
index = directory / CONSTANTS.JSONL_INDEX_FILENAME
if index.is_symlink():
raise ValueError("metadata must not be a symlink")
if not index.exists():
if snapshot is None or Path(snapshot.output_dir) != directory or snapshot.status != "sealed":
raise ValueError("missing index.jsonl; cannot reconstruct an unknown or active snapshot")
snapshot.write_index_jsonl(output_dir=directory)
snapshot.reconcile_filesystem_links()
return "Repaired"
records = [json.loads(line) for line in index.read_text().splitlines() if line.strip()]
if any(not isinstance(record, dict) for record in records):
raise ValueError("metadata records must be JSON objects")
snapshots = [record for record in records if record.get("type") == "Snapshot"]
if len(snapshots) != 1:
raise ValueError("expected exactly one Snapshot record")
record = snapshots[0]
if uuid.UUID(record["id"]) != snapshot_id:
raise ValueError("snapshot ID conflicts with directory name")
if record.get("status") != "sealed":
raise ValueError("snapshot is not sealed; finish the source capture first")
if record.get("fs_version") not in {Snapshot._fs_current_version(), *Snapshot._FS_VERSION_MIGRATION_PATHS}:
raise ValueError("unsupported filesystem version")
crawl_id = uuid.UUID(record["crawl_id"])
owner = directory.parents[3].name
crawl_records = [item for item in records if item.get("type") == "Crawl" and uuid.UUID(item["id"]) == crawl_id]
crawl_record = crawl_records[0] if crawl_records else {}
if crawl_record.get("created_by", owner) != owner:
raise ValueError("crawl owner conflicts with directory path")
crawl = Crawl.objects.select_related("created_by").filter(pk=crawl_id).first()
imported_crawl = crawl is None
if crawl and crawl.created_by.username != owner:
raise ValueError("existing crawl ID belongs to a different owner")
if snapshot and (snapshot.url != record["url"] or snapshot.crawl_id != crawl_id or snapshot.timestamp != record["timestamp"]):
raise ValueError("existing snapshot ID has conflicting identity; refusing to overwrite")
if snapshot and Path(snapshot.output_dir) != directory:
raise ValueError("existing snapshot has a different storage path")
# Validate identities before inserting anything. Never turn an ID collision
# into a new ID, and never overwrite a newer destination row.
# write_index_jsonl retains historical hook records before appending the
# canonical DB rows. The final record for each hook is authoritative.
results = {(item.get("plugin"), item.get("hook_name", "")): item for item in records if item.get("type") == "ArchiveResult"}.values()
pending = []
for result in results:
result_id = uuid.UUID(result["id"])
if uuid.UUID(result["snapshot_id"]) != snapshot_id:
raise ValueError("ArchiveResult belongs to another snapshot")
existing = ArchiveResult.objects.filter(pk=result_id).first()
identity = (snapshot_id, result["plugin"], result.get("hook_name", ""))
if existing:
if (existing.snapshot_id, existing.plugin, existing.hook_name) != identity:
raise ValueError("existing ArchiveResult ID has conflicting identity")
elif ArchiveResult.objects.filter(snapshot_id=snapshot_id, plugin=identity[1], hook_name=identity[2]).exists():
raise ValueError("existing hook has a different ArchiveResult ID")
else:
pending.append(result)
imported = snapshot is None
if imported:
if Snapshot.objects.filter(timestamp=record["timestamp"]).exists():
raise ValueError("timestamp belongs to another snapshot")
if Snapshot.objects.filter(crawl_id=crawl_id, url=record["url"]).exists():
raise ValueError("crawl URL belongs to another snapshot")
user, _ = get_user_model().objects.get_or_create(
username=owner,
defaults={"is_active": False, "password": "!"},
)
if crawl is None:
# Old exports contain only crawl_id. Keep that identity but report
# that absent crawl metadata cannot be reconstructed exactly.
if not crawl_record:
print(f"[yellow] {directory}: no Crawl record; preserving ID with recovered metadata[/yellow]")
crawl = Crawl(
id=crawl_id,
created_by=user,
status="sealed",
retry_at=None,
**_fields(crawl_record, "urls max_depth config tags_str label"),
)
crawl.save(force_insert=True)
_restore_dates(crawl, crawl_record)
snapshot = Snapshot.create_from_directory(directory, crawl=crawl, preserve_identity=True)
if snapshot is None:
raise ValueError("invalid snapshot metadata")
snapshot.status = "sealed"
snapshot.retry_at = None
if "config" not in record:
snapshot.config = {"PERMISSIONS": "private"}
if Path(snapshot.get_storage_path_for_version(Snapshot._fs_current_version())) != directory:
raise ValueError("metadata does not match its owner/date/domain directory")
snapshot.save(force_insert=True)
_restore_dates(snapshot, record)
# Each insert is durable. An interruption after the Snapshot insert is not
# completion: the next pass still imports missing result IDs and tags.
for result in pending:
snapshot._create_archive_result_if_missing(result, {}, preserve_identity=True)
tags_before = set(snapshot.tags.values_list("name", flat=True))
snapshot._merge_tags_from_index(record)
tags_changed = tags_before != set(snapshot.tags.values_list("name", flat=True))
link = Path(snapshot.crawl.output_dir) / "snapshots" / snapshot.extract_domain_from_url(snapshot.url) / str(snapshot.id)
links_ok = link.is_symlink() and link.resolve() == directory.resolve()
if not links_ok:
snapshot.reconcile_filesystem_links()
if snapshot.fs_migration_needed:
snapshot.migrate_filesystem_to_current_version(source_dir=directory)
if imported:
_restore_dates(snapshot, record)
if imported_crawl:
_restore_dates(crawl, crawl_record)
# Leave source metadata untouched, including process/binary records that
# are historical evidence rather than runnable destination process state.
return "Imported" if imported else "Repaired" if pending or tags_changed or not links_ok else "Unchanged"
def reconcile_known_snapshots(snapshots, wait_for_turn=None):
"""Explicit metadata / asset repair; normal updates never call this scan."""
for snapshot in snapshots.filter(status="sealed").paged_iterator():
if wait_for_turn:
wait_for_turn()
if snapshot.fs_migration_needed:
snapshot.migrate_filesystem_to_current_version()
directory = Path(snapshot.output_dir)
if not directory.is_dir():
raise ValueError(f"Missing snapshot directory: {directory}")
_reconcile_directory(directory)
for result in snapshot.archiveresult_set.all():
result.update_output_metadata_from_filesystem(snapshot_dir=directory, full_scan=True)
def rescan_snapshots(wait_for_turn=None, reverse=False, scan_legacy=None):
stats = dict.fromkeys(("Imported", "Repaired", "Unchanged", "Errors"), 0)
print("[*] Rescanning snapshot directories (O(N), orphans first; completed records are reused)...")
known = {str(pk) for pk in Snapshot.objects.values_list("pk", flat=True)}
# Two streaming passes avoid enumerating the entire archive before the
# first import. Rows imported before interruption move to the second pass
# next time; only one day's directory names are held in memory at a time.
for existing in (False, True):
if existing and scan_legacy:
scan_legacy()
for directory in _snapshot_directories(reverse=reverse):
if (directory.name.replace("-", "") in known) != existing:
continue
if wait_for_turn:
wait_for_turn()
try:
outcome = _reconcile_directory(directory)
if directory.name.replace("-", "") in known:
for result in ArchiveResult.objects.filter(snapshot_id=uuid.UUID(directory.name)):
result.update_output_metadata_from_filesystem(snapshot_dir=directory, full_scan=True)
stats[outcome] += 1
if outcome != "Unchanged":
print(f" {outcome}: {directory}")
except (OSError, ValueError, KeyError, TypeError, ValidationError, IntegrityError) as err:
stats["Errors"] += 1
print(f"[yellow] Unresolved: {directory}: {err}[/yellow]")
if sum(stats.values()) % 100 == 0:
print(" " + ", ".join(f"{key}: {value}" for key, value in stats.items()))
print(" " + ", ".join(f"{key}: {value}" for key, value in stats.items()))
if stats["Errors"]:
raise SystemExit(1)
return stats
+2 -2
View File
@@ -17,7 +17,7 @@ CONFIG_FILE_HEADER = (
"# You can add options here manually in INI format, or automatically by running:\n"
"# archivebox config --set KEY=VALUE\n"
"#\n"
"# This file is kept in sync 1:1 with Machine.config in the index DB —\n"
"# This file is kept in sync 1:1 with the current Machine.config in the DB —\n"
"# editing either side propagates to the other. ``archivebox init`` reads\n"
"# this file on startup; the admin Machine.config editor writes both.\n"
"#\n"
@@ -160,7 +160,7 @@ def _coerce_from_str_dict(file_config: dict[str, str]) -> dict[str, Any]:
so they round-trip through INI's string-only storage. When reading the
file back into ``Machine.config`` (a JSONField that holds native types)
those strings have to be decoded — otherwise downstream consumers like
``_emit_machine_config`` → ``MachineEvent`` → abx-dl see a JSON string
``MachineEvent`` → abx-dl see a JSON string
where they expect a dict and raise ``TypeError``.
Declared fields go through pydantic-settings' own ``field_is_complex`` /
``prepare_field_value`` so they're decoded per annotation. Undeclared
+27 -21
View File
@@ -16,9 +16,9 @@ from pathlib import Path
from typing import Any, ClassVar, cast
from urllib.parse import quote, urlparse
from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs
from abx_plugins.plugins.base.utils import build_config_model
from django.db import DatabaseError
from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
from pydantic import BaseModel, Field, PrivateAttr, TypeAdapter, create_model, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console
@@ -83,12 +83,16 @@ def base_url_from_legacy_server_config(raw_config: Mapping[str, object]) -> str:
return candidate
listen_host = str(raw_config.get("LISTEN_HOST") or "").strip()
if listen_host and listen_host.lower() != "archivebox.localhost:8000":
if listen_host and listen_host.lower() not in {"archivebox.localhost:8000", "archivebox.localhost:5797"}:
return normalize(listen_host)
csrf_origins = [normalize(origin) for origin in str(raw_config.get("CSRF_TRUSTED_ORIGINS") or "").split(",")]
csrf_origins = [origin for origin in csrf_origins if origin]
nondefault_csrf_origins = [origin for origin in csrf_origins if origin != "http://admin.archivebox.localhost:8000"]
nondefault_csrf_origins = [
origin
for origin in csrf_origins
if origin not in {"http://admin.archivebox.localhost:8000", "http://admin.archivebox.localhost:5797"}
]
if len(nondefault_csrf_origins) == 1:
return nondefault_csrf_origins[0]
@@ -314,7 +318,7 @@ class ServerConfig(BaseConfigSet):
)
SECRET_KEY: str = Field(default_factory=lambda: "".join(secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789_") for _ in range(50)))
BIND_ADDR: str = Field(default="127.0.0.1:8000")
BIND_ADDR: str = Field(default="127.0.0.1:5797")
BASE_URL: str = Field(default="")
ALLOWED_HOSTS: str = Field(default="*")
CSRF_TRUSTED_ORIGINS: str = Field(default="")
@@ -441,6 +445,7 @@ class ArchivingConfig(BaseConfigSet):
default="",
description="Comma-separated plugin selection for this run. Empty means use enabled plugin defaults.",
)
PARSER: str = Field(default="auto", description="Parser used to read the submitted Crawl input document.")
ONLY_NEW: bool = Field(default=True)
INDEX_ONLY: bool = Field(default=False)
@@ -460,9 +465,16 @@ class ArchivingConfig(BaseConfigSet):
USER_AGENT: str = Field(
default=f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)",
)
COOKIES_FILE: Path | None = Field(default=None)
COOKIES_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
AUTH_STORAGE_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
URL_DENYLIST: str = Field(default=r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$", alias="URL_BLACKLIST")
URL_DENYLIST: str = Field(
default=(
r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$"
r"|^https?://news\.ycombinator\.com/(?:vote|hide)/?(?:[?#]|$)"
),
alias="URL_BLACKLIST",
)
URL_ALLOWLIST: str | None = Field(default=None, alias="URL_WHITELIST")
DEFAULT_PERSONA: str = Field(default="Default", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
@@ -601,13 +613,9 @@ def _explicit_plugin_enabled_keys(config: Mapping[str, object]) -> set[str]:
def _discover_plugin_config_schemas() -> PluginSchemaDocuments:
from archivebox.plugins.discovery import discover_plugin_configs
from archivebox.plugins.discovery import get_plugin_config_resolver
schemas: PluginSchemaDocuments = {}
if BASE_CONFIG_PATH.exists():
schemas["base"] = json.loads(BASE_CONFIG_PATH.read_text())
schemas.update(discover_plugin_configs())
return schemas
return get_plugin_config_resolver().schemas
def _plugin_config_properties(plugin_schemas: PluginSchemaDocuments) -> dict[str, dict[str, Any]]:
@@ -856,13 +864,11 @@ class ArchiveBoxBaseConfig(
disabled_plugins = [plugin_name for plugin_name, enabled_key in enabled_config_keys.items() if not getattr(self, enabled_key)]
selected_plugin_roots = plugin_names
if selected_plugin_roots:
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.plugins.discovery import get_plugin_catalog
selected_plugins = set(
filter_plugins(
discover_plugins(runtime="archivebox"),
get_plugin_catalog().select(
sorted(selected_plugin_roots),
include_providers=True,
disabled_names=disabled_plugins,
),
)
@@ -1195,7 +1201,6 @@ def get_config(
explicit_plugin_enabled_keys: set[str] = set()
if resolve_plugins:
plugin_schemas = {plugin_name: schema for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)}
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
crawl_selected_plugins = crawl_config_base and bool(_normalize_plugins_config_value(dict(crawl.config or {}).get("PLUGINS")))
# A frozen crawl-level PLUGINS selector is the exact extractor set for
@@ -1219,8 +1224,9 @@ def get_config(
**_plugin_user_config(_plugin_input_config(file_config)),
**plugin_user_config,
}
plugin_sections = resolve_plugin_configs(
plugin_schemas,
from archivebox.plugins.discovery import get_plugin_config_resolver
plugin_sections = get_plugin_config_resolver().resolve(
global_config=plugin_global_config,
user_config=plugin_user_config,
environ={},
@@ -1258,7 +1264,7 @@ def get_config(
config = config.model_copy(update={"SERVER_SECURITY_MODE": "safe-subdomains-fullreplay"})
for key in explicit_plugin_enabled_keys:
if key in config_data:
setattr(config, key, config_data[key])
setattr(config, key, TypeAdapter(bool).validate_python(config_data[key]))
if config.PLUGINS:
config._derive_plugin_enabled_config(respect_current_enabled=True)
if redact_sensitive:
+21 -13
View File
@@ -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
+58 -59
View File
@@ -2,11 +2,9 @@ __package__ = "archivebox.core"
import html
import json
import os
import shlex
from functools import reduce
from operator import and_
from pathlib import Path
from urllib.parse import quote
from django.contrib import admin
@@ -24,7 +22,13 @@ from django.utils.text import smart_split
from archivebox.base_models.admin import BaseModelAdmin
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.routes_util import build_snapshot_url
from archivebox.core.routes_util import (
build_snapshot_detail_url,
build_snapshot_files_url,
build_snapshot_url,
build_snapshot_zip_url,
get_snapshot_output_anchor,
)
from archivebox.core.widgets import InlineTagEditorWidget
from archivebox.machine.env_util import env_to_shell_exports
from archivebox.misc.logging_util import printable_filesize
@@ -186,7 +190,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None, can_del
if embed_path and result.status == "succeeded":
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
else:
output_link = build_snapshot_url(snapshot_id, "", config=config)
output_link = build_snapshot_detail_url(result.snapshot.archive_path_from_db, config=config)
output_link_attr = html.escape(output_link, quote=True)
# Get version - try cmd_version field
@@ -555,8 +559,8 @@ class ArchiveResultAdmin(BaseModelAdmin):
try:
queryset = self.get_queryset(request).filter(pk__in=selected)
if not queryset.exists():
snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
snapshot = Snapshot.objects.filter(pk=request.GET.get("snapshot")).first()
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request) if snapshot else request.path)
except (ValidationError, ValueError):
return redirect(request.path)
return delete_selected(self, request, queryset)
@@ -568,7 +572,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
request.GET.clear()
request.META["QUERY_STRING"] = ""
try:
handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
handoff_snapshot = Snapshot.objects.filter(pk=handoff_snapshot).first()
except (ValidationError, ValueError):
handoff_snapshot = None
saved_list_per_page = self.list_per_page
@@ -582,7 +586,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
).exists()
):
return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
return redirect(build_snapshot_detail_url(handoff_snapshot.archive_path_from_db, request=request))
return response
finally:
self.list_per_page = saved_list_per_page
@@ -656,22 +660,40 @@ class ArchiveResultAdmin(BaseModelAdmin):
def get_snapshot_view_url(self, result: ArchiveResult) -> str:
request = self.request
return build_snapshot_url(str(result.snapshot_id), request=request, config=request.archivebox_config)
return build_snapshot_detail_url(
result.snapshot.archive_path_from_db,
request=request,
config=request.archivebox_config,
)
def get_output_view_url(self, result: ArchiveResult) -> str:
request = self.request
config = request.archivebox_config
output_path = result.embed_path()
if not output_path:
output_path = result.plugin or ""
return build_snapshot_url(str(result.snapshot_id), output_path, request=request, config=config)
output_path = get_snapshot_output_anchor(result.plugin, result.embed_path() or "")
return build_snapshot_detail_url(
result.snapshot.archive_path_from_db,
output_path=output_path,
request=request,
config=config,
)
def get_output_files_url(self, result: ArchiveResult) -> str:
request = self.request
return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=request.archivebox_config)}/?files=1"
return build_snapshot_files_url(
str(result.snapshot_id),
result.plugin,
request=request,
config=request.archivebox_config,
)
def get_output_zip_url(self, result: ArchiveResult) -> str:
return f"{self.get_output_files_url(result)}&download=zip"
request = self.request
return build_snapshot_zip_url(
str(result.snapshot_id),
result.plugin,
request=request,
config=request.archivebox_config,
)
@admin.display(description="Details", ordering="id")
def details_link(self, result):
@@ -729,7 +751,11 @@ class ArchiveResultAdmin(BaseModelAdmin):
request = self.request
return format_html(
'<a href="{}"><b><code>[{}]</code></b> &nbsp; {} &nbsp; {}</a><br/>',
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
build_snapshot_detail_url(
result.snapshot.archive_path_from_db,
request=request,
config=request.archivebox_config,
),
snapshot_id[:8],
result.snapshot.bookmarked_at.strftime("%Y-%m-%d %H:%M"),
result.snapshot.url[:128],
@@ -827,11 +853,14 @@ class ArchiveResultAdmin(BaseModelAdmin):
config = request.archivebox_config
# Determine output link path - use embed_path() which checks output_files
embed_path = result.embed_path()
output_path = embed_path if (result.status == "succeeded" and embed_path) else "index.html"
snapshot_id = str(result.snapshot_id)
output_url = (
build_snapshot_url(str(result.snapshot_id), embed_path, request=request, config=config)
if result.status == "succeeded" and embed_path
else build_snapshot_detail_url(result.snapshot.archive_path_from_db, request=request, config=config)
)
return format_html(
'<a href="{}" class="output-link">↗️</a><pre>{}</pre>',
build_snapshot_url(snapshot_id, output_path, request=request, config=config),
output_url,
result.output_str_for_display(),
)
@@ -858,46 +887,16 @@ class ArchiveResultAdmin(BaseModelAdmin):
)
def output_summary(self, result):
snapshot_dir = Path(result.snapshot.output_dir)
output_html = format_html(
'<pre style="display: inline-block">{}</pre><br/>',
result.output_str_for_display(),
)
snapshot_id = str(result.snapshot_id)
request = self.request
output_html += format_html(
'<a href="{}#all">See result files ...</a><br/><pre><code>',
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
)
embed_path = result.embed_path() or ""
path_from_embed = snapshot_dir / (embed_path or "")
output_html += format_html(
'<i style="padding: 1px">{}</i><b style="padding-right: 20px">/</b><i>{}</i><br/><hr/>',
str(snapshot_dir),
str(embed_path),
)
if os.access(path_from_embed, os.R_OK):
root_dir = str(path_from_embed)
else:
root_dir = str(snapshot_dir)
for root, dirs, files in os.walk(root_dir):
depth = root.replace(root_dir, "").count(os.sep) + 1
if depth > 2:
continue
indent = " " * 4 * (depth)
output_html += format_html('<b style="padding: 1px">{}{}/</b><br/>', indent, os.path.basename(root))
indentation_str = " " * 4 * (depth + 1)
for filename in sorted(files):
is_hidden = filename.startswith(".")
output_html += format_html(
'<span style="opacity: {}.2">{}{}</span><br/>',
int(not is_hidden),
indentation_str,
filename.strip(),
)
return output_html + mark_safe("</code></pre>")
output_html = format_html('<pre style="display: inline-block">{}</pre>', result.output_str_for_display())
file_map = result.output_file_map()
paths = sorted({path for key in file_map if (path := result.output_file_path(key, output_file_map=file_map))})
if paths:
output_html += format_html(
'<br/><a href="{}">See result files ...</a><pre><code>{}</code></pre>',
self.get_output_files_url(result),
"\n".join(paths),
)
return output_html
def register_admin(admin_site):
+260 -189
View File
@@ -1,38 +1,56 @@
__package__ = "archivebox.core"
import json
from functools import lru_cache
from types import SimpleNamespace
from math import ceil
from typing import Any
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.urls import path, reverse
from django.shortcuts import get_object_or_404, redirect
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed, Http404
from django.utils import timezone
from django.utils.formats import date_format
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.db.models import Q, Count, Exists, F, OuterRef, Prefetch
from django import forms
from django.template import Template, RequestContext
from django.template import Context, Template, RequestContext
from django.contrib.admin.helpers import ActionForm
from archivebox.config.common import get_config
from archivebox.misc.util import htmldecode, urldecode
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.serve_static import serve_static_with_byterange_support
from archivebox.search.admin import SearchResultsAdminMixin, SearchResultsChangeList
from archivebox.search.views import admin_snapshot_search_stream_view
from archivebox.core.routes_util import build_snapshot_url, build_web_url
from archivebox.core.routes_util import (
build_snapshot_detail_url,
build_snapshot_files_url,
build_snapshot_role_output_url,
build_snapshot_url,
build_snapshot_zip_url,
get_snapshot_output_anchor,
)
from archivebox.core.tag_util import get_or_create_tag
from archivebox.core.templatetags.core_tags import snapshot_thumbnail, snapshot_url_text
from archivebox.plugins.hooks import discover_hooks
from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_plugins
from archivebox.plugins.discovery import get_plugin_icon, get_snapshot_role_names
from archivebox.plugins.output_groups import (
OUTPUT_GROUPS,
display_plugin_name,
output_group_for_plugin,
order_output_plugins,
plugin_icon_is_hidden,
plugin_output_sizes,
)
from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
from archivebox.core.models import Tag, Snapshot, ArchiveResult
from archivebox.crawls.models import Crawl
from archivebox.core.admin_archiveresults import render_archiveresults_list
from archivebox.core.preview_util import EXTENSION_SCREENSHOT_PLUGIN
from archivebox.progressmonitor.views import progress_endpoint
from archivebox.core.permissions import (
PERMISSIONS_CHOICES,
@@ -48,9 +66,12 @@ GLOBAL_CONTEXT = {}
SNAPSHOT_PERMISSION_META = PERMISSIONS_META
@lru_cache(maxsize=1)
def _plugin_sort_order() -> dict[str, int]:
return {get_plugin_name(plugin): idx for idx, plugin in enumerate(get_plugins())}
def _format_size_column(num_bytes: int) -> str:
size = num_bytes
for unit in ("B", "KB", "MB", "GB", "TB"):
if size < 1024 or unit == "TB":
return f"{size:.1f} {unit}" if unit == "GB" else f"{ceil(size)} {unit}"
size /= 1024
class SnapshotActionForm(ActionForm):
@@ -262,15 +283,28 @@ class SnapshotChangeList(SearchResultsChangeList):
rows = (
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
.order_by("snapshot_id", "plugin")
.values_list("snapshot_id", "plugin", "status", "output_size", "output_files")
.only("snapshot_id", "plugin", "status", "output_str", "output_size", "output_files")
)
for snapshot_id, plugin, status, output_size, output_files in rows.iterator(chunk_size=1000):
if plugin in seen_plugins[snapshot_id]:
for result in rows.iterator(chunk_size=1000):
if result.plugin in seen_plugins[result.snapshot_id]:
continue
seen_plugins[snapshot_id].add(plugin)
output_results_by_snapshot[snapshot_id].append(
SimpleNamespace(plugin=plugin, status=status, output_size=output_size, output_files=output_files),
seen_plugins[result.snapshot_id].add(result.plugin)
output_results_by_snapshot[result.snapshot_id].append(result)
card_results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
for result in (
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids)
.exclude(plugin="")
.only(
"snapshot_id",
"plugin",
"status",
"output_str",
"output_size",
"output_files",
)
):
card_results_by_snapshot[result.snapshot_id].append(result)
for obj in self.result_list:
counts = status_counts_by_snapshot.get(obj.pk, {})
@@ -294,6 +328,7 @@ class SnapshotChangeList(SearchResultsChangeList):
"is_sealed": obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED, obj.StatusChoices.PAUSED),
}
obj.__dict__["_admin_output_results"] = output_results_by_snapshot[obj.pk]
obj.__dict__["_snapshot_card_results"] = card_results_by_snapshot[obj.pk]
def get_results(self, request):
super().get_results(request)
@@ -306,6 +341,7 @@ class SnapshotChangeList(SearchResultsChangeList):
class SnapshotAdminForm(forms.ModelForm):
"""Custom form for Snapshot admin with tag editor widget."""
url = forms.CharField(widget=forms.TextInput)
tags_editor = forms.CharField(
label="Tags",
required=False,
@@ -346,19 +382,8 @@ class SnapshotAdminForm(forms.ModelForm):
# Parse and save tags from tags_editor
tags_str = self.cleaned_data.get("tags_editor", "")
if tags_str:
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
tags = []
for name in tag_names:
tag, _ = Tag.objects.get_or_create(
name__iexact=name,
defaults={"name": name},
)
tag = Tag.objects.filter(name__iexact=name).first() or tag
tags.append(tag)
instance.tags.set(tags)
else:
instance.tags.clear()
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
instance.save_tags(tag_names)
return instance
@@ -367,9 +392,19 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
form = SnapshotAdminForm
raw_id_fields = ("crawl", "parent_snapshot")
list_select_related = ()
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
tags_str = form.cleaned_data.get("tags_editor", "")
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
form.instance.save_tags(
tag_names,
created_by=request.user if request.user.is_authenticated else None,
)
list_display = (
"permissions_badge",
"created_at",
"created_at_display",
"preview_icon",
"title_str",
"tags_inline",
@@ -377,7 +412,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"files",
"size_with_stats",
)
list_display_links = ("created_at",)
list_display_links = ("created_at_display",)
sort_fields = ("title_str", "created_at", "status", "crawl")
readonly_fields = (
"admin_actions",
@@ -491,6 +526,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
inlines = [] # Removed TagInline, using TagEditorWidget instead
list_per_page = 50
@admin.display(description="Date", ordering="created_at")
def created_at_display(self, obj):
created_at = timezone.localtime(obj.created_at)
return format_html(
'<span class="snapshot-created-at"><span class="snapshot-created-at__date">{}</span><span class="snapshot-created-at__time">{}</span></span>',
date_format(created_at, "Y-m-d"),
date_format(created_at, "g:i A"),
)
action_form = SnapshotActionForm
paginator = AcceleratedPaginator
@@ -556,20 +600,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def get_snapshot_view_url(self, obj: Snapshot) -> str:
request = self.request
return build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)
return build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=request.archivebox_config)
def get_snapshot_files_url(self, obj: Snapshot) -> str:
request = self.request
return f"{build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)}/?files=1"
return build_snapshot_files_url(str(obj.id), request=request, config=request.archivebox_config)
def get_snapshot_zip_url(self, obj: Snapshot) -> str:
return f"{self.get_snapshot_files_url(obj)}&download=zip"
request = self.request
return build_snapshot_zip_url(str(obj.id), request=request, config=request.archivebox_config)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path("grid/", self.admin_site.admin_view(self.grid_view), name="grid"),
path("search-stream/", self.admin_site.admin_view(self.search_stream_view), name="core_snapshot_search_stream"),
path(
"<path:object_id>/preview/<str:plugin>/<path:filename>",
self.admin_site.admin_view(self.preview_view, cacheable=True),
name="core_snapshot_preview",
),
path("<path:object_id>/redo-failed/", self.admin_site.admin_view(self.redo_failed_view), name="core_snapshot_redo_failed"),
path(
"<path:object_id>/set-permissions/",
@@ -579,6 +629,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
]
return custom_urls + urls
def preview_view(self, request, object_id, plugin, filename):
"""Serve a saved image output from the authenticated admin origin."""
snapshot = get_object_or_404(Snapshot, pk=object_id)
if not self.has_view_or_change_permission(request, snapshot):
raise PermissionDenied
result = snapshot.archiveresult_set.filter(plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED).first()
try:
output_path = result.output_file_path(filename) if result else None
except (TypeError, ValueError, OverflowError):
output_path = None
if not output_path:
raise Http404("Snapshot image does not exist")
request.archivebox_cache_policy = "private"
response = serve_static_with_byterange_support(request, output_path, document_root=snapshot.output_dir)
if response.status_code != 304 and not response.headers.get("Content-Type", "").lower().startswith("image/"):
raise Http404("Snapshot preview is not an image")
response.headers["Vary"] = "Cookie"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
def search_stream_view(self, request):
return admin_snapshot_search_stream_view(self, request)
@@ -710,7 +780,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return qs
@admin.display(description="👁", ordering="permissions")
@admin.display(description="🔒", ordering="permissions")
def permissions_badge(self, obj):
permissions = obj.__dict__.get("snapshot_permissions")
if permissions is None:
@@ -856,7 +926,13 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def status_info(self, obj):
request = self.request
config = request.archivebox_config
favicon_url = build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)
favicon_url = build_snapshot_role_output_url(
obj,
"list_icon",
request=request,
config=config,
fallback_to_default=True,
)
return format_html(
"""
Archived: {} ({} files {}) &nbsp; &nbsp;
@@ -894,7 +970,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
show_title = bool(title_raw) and title_normalized != "pending..." and title_normalized != url_normalized
css_class = "fetched" if show_title else "pending"
detail_url = build_web_url(f"/{obj.archive_path_from_db}/index.html", request=request, config=config)
detail_url = build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config)
title_html = ""
if show_title:
title_html = format_html(
@@ -904,14 +980,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
urldecode(htmldecode(title_raw))[:128],
)
favicon = self._get_favicon_data(obj) or {}
favicon_html = (
format_html(
'<img class="snapshot-url-favicon" src="{}" alt="" loading="lazy" decoding="async" onerror="this.hidden=true">',
favicon["url"],
)
if favicon.get("url")
else ""
)
return format_html(
'<a class="snapshot-title-detail-hitbox" href="{}" aria-label="Open snapshot details"></a>'
"{}"
'<div style="font-size: 11px; color: #64748b; margin-top: 2px;">'
'<a href="{}"><code style="user-select: all;">{}</code></a>'
'<div class="snapshot-title-url">'
'<a class="snapshot-original-url" href="{}">{}<code style="user-select: all;">{}</code></a>'
"</div>",
detail_url,
title_html,
url_raw or obj.url,
(url_raw or obj.url)[:128],
favicon_html,
snapshot_url_text(url_raw or obj.url),
)
@admin.display(description="Tags", ordering="tag_count")
@@ -938,90 +1026,47 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
)
return mark_safe(f'<span class="tags-inline-editor">{tags_html}</span>')
def _get_preview_data(self, obj):
request = self.request
config = request.archivebox_config
def _get_favicon_data(self, obj):
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
results = self._get_prefetched_results(obj)
if results is not None:
results = [r for r in results if r.plugin in ("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")]
else:
results = list(
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).only(
"snapshot_id",
"plugin",
"status",
"output_files",
"output_str",
),
if results is None:
results = obj.archiveresult_set.filter(
plugin__in=list_icon_plugins,
status=ArchiveResult.StatusChoices.SUCCEEDED,
)
urls = []
for result in results:
if result.plugin not in list_icon_plugins or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
continue
try:
output_path = result.embed_path()
except (TypeError, ValueError, OverflowError):
continue
if output_path:
urls.append(
build_snapshot_url(str(obj.id), output_path, request=self.request, config=self.request.archivebox_config),
)
return {"url": urls[0], "fallbacks": ",".join(urls[1:])} if urls else None
def result_output_path(result, filename: str) -> str | None:
output_files = result.output_files or {}
file_info = output_files.get(filename)
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
return None
if file_info.get("root_relative"):
return filename
return f"{result.plugin}/{filename}"
def result_urls(plugin: str, filenames: tuple[str, ...]) -> list[str]:
urls: list[str] = []
for result in results:
if result.plugin != plugin or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
continue
for filename in filenames:
output_path = result_output_path(result, filename)
if output_path:
urls.append(build_snapshot_url(str(obj.id), output_path, request=request, config=config))
return urls
screenshot_urls = result_urls("screenshot", ("screenshot.png",))
extension_screenshot_urls = result_urls(EXTENSION_SCREENSHOT_PLUGIN, ("screenshot-1.png", "screenshot.png"))
favicon_urls = result_urls("favicon", ("favicon.ico",))
if not screenshot_urls and not extension_screenshot_urls and not favicon_urls:
return None
all_screenshot_urls = [*screenshot_urls, *extension_screenshot_urls]
if all_screenshot_urls:
img_url = all_screenshot_urls[0]
fallbacks = [*all_screenshot_urls[1:], *favicon_urls]
img_alt = "Screenshot"
preview_class = "screenshot"
else:
img_url = favicon_urls[0]
fallbacks = favicon_urls[1:]
img_alt = "Favicon"
preview_class = "favicon"
fallback_list = ",".join(fallbacks)
onerror_js = (
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
"(this.src=this.dataset.fallbacks.split(',').shift(), "
"this.dataset.fallbacks=this.dataset.fallbacks.split(',').slice(1).join(',')) : "
"this.remove()"
def _render_thumbnail(self, obj, width="100px", height="100px"):
return snapshot_thumbnail(
Context({"request": self.request, "CONFIG": self.request.archivebox_config}),
obj,
admin=True,
width=width,
height=height,
)
return {
"img_url": img_url,
"img_alt": img_alt,
"preview_class": preview_class,
"onerror_js": onerror_js,
"fallback_list": fallback_list,
"favicon_url": favicon_urls[0] if favicon_urls else "",
"favicon_fallback_list": ",".join(favicon_urls[1:]),
}
@admin.display(description="", empty_value="")
def url_favicon(self, obj):
preview = self._get_preview_data(obj)
if not preview:
favicon = self._get_favicon_data(obj)
if not favicon:
return ""
favicon_url = preview.get("favicon_url") or ""
favicon_url = favicon.get("url") or ""
if not favicon_url:
return ""
fallback_list = preview.get("favicon_fallback_list") or ""
fallback_list = favicon.get("fallbacks") or ""
onerror_js = (
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
"(this.src=this.dataset.fallbacks.split(',').shift(), "
@@ -1042,42 +1087,24 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="Preview", empty_value="")
def preview_icon(self, obj):
preview = self._get_preview_data(obj)
if not preview:
return None
return format_html(
'<img src="{}" alt="{}" class="snapshot-preview {}" decoding="async" loading="lazy" onerror="{}" data-fallbacks="{}">',
preview["img_url"],
preview["img_alt"],
preview["preview_class"],
preview["onerror_js"],
preview["fallback_list"],
'<a href="{}" title="Open snapshot details">{}</a>',
build_snapshot_detail_url(obj.archive_path_from_db, request=self.request, config=self.request.archivebox_config),
self._render_thumbnail(obj, height="38px"),
)
@admin.display(description=" ", empty_value="")
def snapshot_summary(self, obj):
request = self.request
config = request.archivebox_config
preview = self._get_preview_data(obj)
stats = self._get_progress_stats(obj)
archive_size = stats["output_size"] or 0
size_txt = printable_filesize(archive_size) if archive_size else "pending"
screenshot_html = ""
if preview:
screenshot_html = format_html(
'<a href="{href}" title="Open snapshot live view" style="display:block; flex:0 0 220px; width:220px;">'
'<img src="{src}" alt="{alt}" decoding="async" loading="lazy" onerror="{onerror}" data-fallbacks="{fallbacks}" '
'style="display:block; width:100%; max-width:220px; aspect-ratio: 16 / 10; object-fit: cover; object-position: top; '
'border-radius: 10px; border: 1px solid #e2e8f0; background: #f8fafc;">'
"</a>",
href=build_web_url(f"/{obj.archive_path}", request=request, config=config),
src=preview["img_url"],
alt=preview["img_alt"],
onerror=preview["onerror_js"],
fallbacks=preview["fallback_list"],
)
screenshot_html = format_html(
'<a href="{}" title="Open snapshot live view" style="display:block;flex:0 0 220px;">{}</a>',
build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config),
self._render_thumbnail(obj, width="220px", height="138px"),
)
return format_html(
'<div style="display:flex; gap:16px; align-items:flex-start;">'
@@ -1092,18 +1119,19 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"</div>",
screenshot_html,
size_txt,
build_web_url(f"/{obj.archive_path}", request=request, config=config),
obj.archive_path,
self.get_snapshot_files_url(obj),
obj.archive_path_from_db,
)
@admin.display(
description="Files Saved",
ordering="ar_succeeded_count",
empty_value="",
)
def files(self, obj):
results = self._get_prefetched_results(obj)
if results is None:
results = obj.archiveresult_set.only("plugin", "status", "output_size")
results = obj.archiveresult_set.only("plugin", "status", "output_size", "output_files")
plugins_with_output: dict[str, ArchiveResult] = {}
for result in results:
@@ -1114,56 +1142,111 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
plugins_with_output.setdefault(result.plugin, result)
if not plugins_with_output:
return mark_safe('<span style="opacity: 0.35;">...</span>')
return ""
sorted_results = sorted(
plugins_with_output.values(),
key=lambda result: (_plugin_sort_order().get(result.plugin, 9999), result.plugin),
)
visible_results = sorted_results[:14]
output = []
sorted_results = [plugins_with_output[plugin] for plugin in order_output_plugins(plugins_with_output, plugin_output_sizes(results))]
icons_by_group: dict[str, list[dict[str, Any]]] = {group_id: [] for group_id, _, _ in OUTPUT_GROUPS}
request = self.request
config = request.archivebox_config
for result in visible_results:
for result in sorted_results:
if plugin_icon_is_hidden(result.plugin):
continue
icon = mark_safe(get_plugin_icon(result.plugin))
if not icon.strip():
continue
output.append(
format_html(
'<a href="{}" class="exists-True" title="{}">{}</a>',
build_web_url(f"/{obj.archive_path_from_db}/{result.plugin}/", request=request, config=config),
result.plugin,
icon,
),
)
if len(sorted_results) > len(visible_results):
output.append(
format_html(
'<span title="{} more outputs">+{}</span>',
len(sorted_results) - len(visible_results),
len(sorted_results) - len(visible_results),
),
embed_path = result.embed_path()
if not embed_path:
continue
group_id = output_group_for_plugin(result.plugin)
anchor_path = get_snapshot_output_anchor(result.plugin, embed_path)
icons_by_group[group_id].append(
{
"plugin": result.plugin,
"icon": icon,
"href": build_snapshot_detail_url(
obj.archive_path_from_db,
output_path=anchor_path,
request=request,
config=config,
),
},
)
output = []
for group_id, label, _ in OUTPUT_GROUPS:
icons = icons_by_group[group_id]
if icons:
top = icons[0]
front_index = 0
pile_column = len(output)
top_html = format_html(
'<a href="{}" class="exists-True files-icon-pile-top files-icon-pile-cover files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-front-index:{};--files-icon-front-offset:{}px">{}</a>',
top["href"],
top["plugin"],
label,
label,
front_index,
front_index * 2,
top["icon"],
)
members = []
for icon_index, item in enumerate(icons):
row = icon_index // 5
row_count = min(5, len(icons) - row * 5)
member_top = 8 + row * 18
row_offset = (5 - row_count) * 11 + 3
member_left = 3 + row_offset + (icon_index % 5) * 22
members.append(
format_html(
'<a href="{}" class="exists-True files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-index:{};--files-icon-row:{};--files-icon-column:{};--files-icon-row-offset:{}px;--files-icon-member-left:{}px;--files-icon-member-top:{}px">{}</a>',
item["href"],
item["plugin"],
display_plugin_name(item["plugin"]),
item["plugin"],
icon_index,
row,
icon_index % 5,
row_offset,
member_left,
member_top,
item["icon"],
),
)
popup = format_html(
'<span class="files-icon-pile-popup" popover="manual">{}<span class="files-icon-pile-label">{}</span></span>',
mark_safe("".join(str(icon) for icon in members)),
label,
)
pile_contents = format_html("{}{}", top_html, popup)
output.append(
format_html(
'<span class="files-icon-pile files-icon-pile--{}" role="group" aria-label="{} saved outputs" style="--files-icon-popup-left:{}px;--files-icon-rows:{};--files-icon-card-width:{}px">{}</span>',
group_id,
label,
pile_column * -22 - front_index * 2,
(len(icons) + 4) // 5,
116,
pile_contents,
),
)
return format_html(
'<span class="files-icons files-icons--compact" style="font-size: 1em; opacity: 0.8;">{}</span>',
'<span class="files-icons files-icons--compact" style="font-size: 1em;">{}</span>',
mark_safe("".join(output)),
)
@admin.display()
@admin.display(empty_value="")
def size(self, obj):
request = self.request
config = request.archivebox_config
archive_size = self._get_progress_stats(obj)["output_size"] or 0
if archive_size:
size_txt = printable_filesize(archive_size)
size_txt = _format_size_column(archive_size)
if archive_size > 52428800:
size_txt = mark_safe(f"<b>{size_txt}</b>")
else:
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
return ""
return format_html(
'<a href="{}" title="View all files">{}</a>',
build_web_url(f"/{obj.archive_path}", request=request, config=config),
self.get_snapshot_files_url(obj),
size_txt,
)
@@ -1233,6 +1316,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(
description="Size",
ordering="output_size",
empty_value="",
)
def size_with_stats(self, obj):
"""Show archive size with output size from archive results."""
@@ -1241,24 +1325,11 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
size_bytes = output_size or 0
if size_bytes:
size_txt = printable_filesize(size_bytes)
size_txt = _format_size_column(size_bytes)
if size_bytes > 52428800: # 50MB
size_txt = mark_safe(f"<b>{size_txt}</b>")
else:
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
# Show hook statistics
if stats["total"] > 0:
return format_html(
'<a href="{}" title="View all files" style="white-space: nowrap;">'
"{}</a>"
'<div style="font-size: 10px; color: #94a3b8; margin-top: 2px;">'
"{}/{} hooks</div>",
self.get_snapshot_files_url(obj),
size_txt,
stats["succeeded"],
stats["total"],
)
return ""
return format_html(
'<a href="{}" title="View all files">{}</a>',
@@ -1386,9 +1457,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
)
def url_str(self, obj):
return format_html(
'<a href="{}"><code style="user-select: all;">{}</code></a>',
'<a class="snapshot-original-url" href="{}"><code style="user-select: all;">{}</code></a>',
obj.url,
obj.url[:128],
snapshot_url_text(obj.url),
)
@admin.display(description="Health", ordering="health")
+30 -4
View File
@@ -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],
),
-6
View File
@@ -10,7 +10,6 @@ class CoreConfig(AppConfig):
def ready(self):
"""Register the archivebox.core.admin_site as the main django admin site"""
import sys
from django.utils.autoreload import DJANGO_AUTORELOAD_ENV
from archivebox.core.admin_site import register_admin_site
@@ -28,11 +27,6 @@ class CoreConfig(AppConfig):
pre_save.connect(truncate_overlong_charfields, dispatch_uid="archivebox_truncate_overlong_charfields")
# Import models to register state machines with the registry
# Skip during makemigrations to avoid premature state machine access
if "makemigrations" not in sys.argv:
from archivebox.core import models # noqa: F401
def _should_prepare_runtime() -> bool:
if os.environ.get("ARCHIVEBOX_RUNSERVER") == "1":
if os.environ.get("ARCHIVEBOX_AUTORELOAD") == "1":
+19 -58
View File
@@ -7,70 +7,31 @@ For more information on this file, see
https://docs.djangoproject.com/en/stable/howto/deployment/asgi/
"""
from django.core.asgi import get_asgi_application
from django.core.handlers.asgi import ASGIHandler
from archivebox.config.django import setup_django
setup_django(check_db=True)
def _patch_thread_sensitive_context_shutdown() -> None:
"""Stop ``ThreadSensitiveContext.__aexit__`` from blocking the daphne loop.
Django 6.0's ASGIHandler wraps every request in ``async with
ThreadSensitiveContext():`` (django/core/handlers/asgi.py:169). On exit
asgiref calls ``executor.shutdown()`` with the default ``wait=True``
(asgiref/sync.py:148), which is a *synchronous* ``Thread.join()`` inside
an async function — so it blocks the daphne event loop until the
executor's worker thread exits.
That's normally fine because the request handler has already awaited
every ``sync_to_async`` it submitted, so the worker is idle and dies as
soon as the shutdown sentinel reaches it. The blocking turns into a
problem when a client disconnects mid-request:
* ``SyncToAsync.__call__`` shields the executor work with
``await asyncio.shield(exec_coro)`` (asgiref/sync.py:506) so that the
sync DB call doesn't get torn down halfway through.
* On cancellation it calls ``exec_coro.cancel()`` (line 522) which only
flips the asyncio ``Future`` to cancelled — the underlying thread
keeps running the SQL query.
* Control unwinds to ``__aexit__`` while the orphaned thread is still
mid-query. ``shutdown(wait=True)`` then blocks the event loop until
that orphan finishes.
Under heavy SQLite contention (the load-test scenario that surfaced
this on cabbage) those orphan threads can take 30 seconds each waiting
for write locks, and the daphne loop is single-threaded — so every
such orphan stalls every other in-flight request, healthchecks time
out, and the container goes ``unhealthy``.
Switching to ``shutdown(wait=False)`` queues the sentinel and returns
immediately; the worker thread still exits cleanly once its current
task finishes, and asgiref's ``WeakKeyDictionary`` releases the
executor as soon as the request's context is GC'd. No per-request
teardown guarantee is lost — there was no caller relying on it.
"""
from asgiref import sync as _asgiref_sync
original_aexit = _asgiref_sync.ThreadSensitiveContext.__aexit__
async def __aexit__(self, exc, value, tb): # type: ignore[no-redef]
if not self.token:
return
executor = _asgiref_sync.SyncToAsync.context_to_thread_executor.pop(self, None)
if executor is not None:
executor.shutdown(wait=False)
_asgiref_sync.SyncToAsync.thread_sensitive_context.reset(self.token)
# Idempotent: only patch once even if asgi.py is reloaded.
if getattr(original_aexit, "_archivebox_patched", False):
return
__aexit__._archivebox_patched = True # type: ignore[attr-defined]
_asgiref_sync.ThreadSensitiveContext.__aexit__ = __aexit__
class ArchiveBoxASGIHandler(ASGIHandler):
def create_request(self, scope, body_file):
# ASGI already spools the complete body. Django's multipart parser
# otherwise treats chunked uploads without Content-Length as empty.
if not any(name == b"content-length" for name, _value in scope.get("headers", [])):
body_file.seek(0, 2)
length = body_file.tell()
body_file.seek(0)
scope = {**scope, "headers": [*scope.get("headers", []), (b"content-length", str(length).encode("ascii"))]}
return super().create_request(scope, body_file)
_patch_thread_sensitive_context_shutdown()
django_application = ArchiveBoxASGIHandler()
# Standard Django ASGI application (no websockets/channels needed)
application = get_asgi_application()
async def application(scope, receive, send):
if scope["type"] == "websocket":
from archivebox.opencode.views import websocket_view
return await websocket_view(scope, receive, send)
return await django_application(scope, receive, send)
+5 -6
View File
@@ -12,9 +12,9 @@ from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
from archivebox.crawls.schedule_util import validate_schedule
from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes
from archivebox.personas.models import Persona
from archivebox.plugins.discovery import get_plugins
from archivebox.plugins.discovery import get_plugin_catalog
from archivebox.plugins.forms import (
PLUGIN_GROUP_DEFINITIONS,
PLUGIN_GROUPS,
TIMEOUT_INPUT_PATTERN,
PluginConfigFormMixin,
get_choice_field,
@@ -333,10 +333,9 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
if self.can_override_crawl_config:
self.build_plugin_groups(get_config(persona=selected_persona) if selected_persona else get_config())
else:
all_plugins = get_plugins()
for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS:
get_choice_field(self, field_name).choices = [(p, p) for p in all_plugins if p in plugin_names]
get_choice_field(self, "other_plugins").choices = [(p, p) for p in all_plugins]
grouped_plugins = get_plugin_catalog().groups()
for category, field_name, _title in PLUGIN_GROUPS:
get_choice_field(self, field_name).choices = [(plugin.name, plugin.name) for plugin in grouped_plugins.get(category, [])]
self.plugin_groups = []
def clean(self):
+30 -13
View File
@@ -13,6 +13,7 @@ from django.http import HttpResponseForbidden, HttpResponseNotModified
from django.shortcuts import redirect
from django.utils import timezone
from django.utils.http import http_date
from django.utils.cache import patch_cache_control, patch_vary_headers
from archivebox.config import VERSION
from archivebox.config.common import get_config, get_request_config
@@ -32,6 +33,7 @@ from archivebox.core.routes_util import (
split_host_port,
)
from archivebox.core.views import OriginalDomainHostView, SnapshotHostView
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL
ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
@@ -89,8 +91,9 @@ def AdminCookieIsolationMiddleware(get_response):
response = get_response(request)
if request.path == "/admin" or request.path.startswith("/admin/"):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
if not getattr(response, "xframe_options_exempt", False):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
config = request.__dict__.get("archivebox_config")
if config is None or config.SERVER_SECURITY_MODE == "auto":
@@ -122,13 +125,12 @@ def AdminCookieIsolationMiddleware(get_response):
def CacheControlMiddleware(get_response):
snapshot_path_re = re.compile(r"^/[^/]+/\\d{8}/[^/]+/[0-9a-fA-F-]{8,36}/")
static_cache_key = (get_COMMIT_HASH() or VERSION or "dev").strip()
def middleware(request):
response = get_response(request)
if request.path.startswith("/static/"):
if request.path.startswith("/static/") and response.status_code in (200, 304):
rel_path = request.path[len("/static/") :]
static_path = finders.find(rel_path)
if static_path:
@@ -153,15 +155,30 @@ def CacheControlMiddleware(get_response):
response.headers["Last-Modified"] = http_date(mtime)
return response
if ("/archive/" in request.path or "/static/" in request.path or snapshot_path_re.match(request.path)) and not response.get(
"Cache-Control",
):
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
policy = "private" if config.PERMISSIONS == "private" else "public"
response["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300"
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
# The same raw favicon bytes are shared across authenticated viewers.
# Session middleware may have added Cookie to Vary during authorization.
vary = [
value.strip()
for value in response.get("Vary", "").split(",")
if value.strip().lower() not in {"", "cookie", "authorization"}
]
if vary:
response["Vary"] = ", ".join(vary)
else:
response.headers.pop("Vary", None)
return response
# Other private/unlisted artifacts remain private; auth errors stay unstored.
snapshot_policy = getattr(request, "archivebox_cache_policy", None)
if snapshot_policy and response.status_code in (200, 206, 304):
if not response.get("Cache-Control"):
response["Cache-Control"] = f"{snapshot_policy}, max-age=60, stale-while-revalidate=300"
if snapshot_policy == "private":
patch_cache_control(response, private=True)
elif snapshot_policy or not response.get("Cache-Control"):
response["Cache-Control"] = "private, no-store"
patch_vary_headers(response, ("Cookie", "Authorization"))
return response
return middleware
@@ -1,71 +0,0 @@
from django.db import migrations, models
from django.db.models import Sum
def consolidate_archiveresults_per_plugin(apps, schema_editor):
ArchiveResult = apps.get_model("core", "ArchiveResult")
Snapshot = apps.get_model("core", "Snapshot")
duplicate_groups = ArchiveResult.objects.values("snapshot_id", "plugin").annotate(count=models.Count("id")).filter(count__gt=1)
affected_snapshot_ids = set()
for group in duplicate_groups.iterator(chunk_size=200):
rows = list(
ArchiveResult.objects.filter(
snapshot_id=group["snapshot_id"],
plugin=group["plugin"],
).order_by("created_at", "id"),
)
winner = max(
rows,
key=lambda row: (
bool(row.output_files),
int(row.output_size or 0),
row.modified_at,
str(row.id),
),
)
output_files = {}
mimetypes = set()
for row in rows:
output_files.update(row.output_files or {})
mimetypes.update(part.strip() for part in (row.output_mimetypes or "").split(",") if part.strip())
winner.output_files = output_files
winner.output_size = max(
sum(int(metadata.get("size") or 0) for metadata in output_files.values() if isinstance(metadata, dict)),
*(int(row.output_size or 0) for row in rows),
)
winner.output_mimetypes = ",".join(sorted(mimetypes))
winner.start_ts = min((row.start_ts for row in rows if row.start_ts), default=None)
winner.end_ts = max((row.end_ts for row in rows if row.end_ts), default=None)
winner.save(update_fields=["output_files", "output_size", "output_mimetypes", "start_ts", "end_ts"])
ArchiveResult.objects.filter(id__in=[row.id for row in rows if row.id != winner.id]).delete()
affected_snapshot_ids.add(group["snapshot_id"])
for snapshot_id in affected_snapshot_ids:
total = ArchiveResult.objects.filter(snapshot_id=snapshot_id).aggregate(total=Sum("output_size"))["total"] or 0
Snapshot.objects.filter(id=snapshot_id).update(output_size=total)
class Migration(migrations.Migration):
dependencies = [
("core", "0051_postgres_url_pattern_ops_index"),
]
operations = [
migrations.RemoveConstraint(
model_name="archiveresult",
name="unique_archiveresult_per_snapshot_hook",
),
migrations.RunPython(
consolidate_archiveresults_per_plugin,
reverse_code=migrations.RunPython.noop,
),
migrations.AddConstraint(
model_name="archiveresult",
constraint=models.UniqueConstraint(
fields=("snapshot", "plugin"),
name="unique_archiveresult_per_snapshot_plugin",
),
),
]
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0052_unique_archiveresult_per_snapshot_plugin"),
("core", "0051_postgres_url_pattern_ops_index"),
]
operations = [
@@ -0,0 +1,26 @@
# Generated by Django 6.1 on 2026-09-02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0053_alter_archiveresult_options"),
]
operations = [
migrations.RemoveField(
model_name="snapshot",
name="current_step",
),
migrations.AlterField(
model_name="snapshot",
name="fs_version",
field=models.CharField(
db_index=True,
default="0.9.0",
help_text='Filesystem version of this snapshot (e.g., "0.7.0", "0.8.0", "0.9.4").',
max_length=10,
),
),
]
+814 -1028
View File
File diff suppressed because it is too large. Load diff
-4
View File
@@ -1,4 +0,0 @@
__package__ = "archivebox.core"
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
+60 -76
View File
@@ -10,10 +10,15 @@ def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
def _canonical_hook_name(hook_name: str) -> str:
hook_name = Path(hook_name).name
return Path(hook_name).stem if Path(hook_name).suffix in {".py", ".js", ".sh"} else hook_name
def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str | None = None) -> dict[str, int]:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.services.archive_result_service import _collect_output_metadata
from abx_dl.output_files import OutputManifest
from archivebox.machine.models import Process
from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q, Subquery, Value
@@ -31,9 +36,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"crawls_queued_without_retry_at": 0,
"snapshots_queued_without_retry_at": 0,
"snapshots_sealed_with_extension_uploads_only": 0,
"archiveresults_backoff": 0,
"snapshots_queued_plugin_rows_waiting_on_stale_lease": 0,
"archiveresults_started_without_running_process": 0,
"archiveresults_interrupted_without_running_process": 0,
"archiveresults_missing_for_orphaned_hook_processes": 0,
"snapshots_started_without_running_results": 0,
"crawls_started_with_due_snapshots": 0,
@@ -41,10 +44,10 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"crawls_started_without_active_snapshots": 0,
}
running_archiveresults = ArchiveResult.objects.filter(
snapshot_id=OuterRef("pk"),
status=ArchiveResult.StatusChoices.STARTED,
process__status=Process.StatusChoices.RUNNING,
running_hook_processes = Process.objects.filter(
archiveresult__snapshot_id=OuterRef("pk"),
process_type=Process.TypeChoices.HOOK,
status=Process.StatusChoices.RUNNING,
)
active_child_snapshots = Snapshot.objects.filter(
crawl_id=OuterRef("pk"),
@@ -104,49 +107,18 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
retry_at=now,
modified_at=now,
)
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF, **result_filter)
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED, **result_filter).exclude(
process__status=Process.StatusChoices.RUNNING,
)
# ArchiveResult has no retry_at scheduler. Wake only the parent Snapshots
# for result rows we are about to repair, then requeue those exact rows.
# Using subqueries keeps million-row recovery in SQLite instead of building
# Python ID lists or scanning all sealed snapshots.
Snapshot.objects.filter(
id__in=backoff_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
retry_at__isnull=True,
).update(retry_at=now, modified_at=now)
# ArchiveResult rows are projections, never work items. Close interrupted
# projections as failed and wake their parent Snapshot so abx-dl can replay
# the snapshot-level sequence. Indexed subqueries keep this bounded.
Snapshot.objects.filter(
id__in=orphaned_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
retry_at__isnull=True,
status=Snapshot.StatusChoices.STARTED,
).update(retry_at=now, modified_at=now)
cleaned["archiveresults_backoff"] = backoff_results.update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
# Targeted plugin rows on final/paused Snapshots are scheduled through the
# parent Snapshot.retry_at. retry_at=NULL is the normal idle marker for a
# sealed Snapshot and must not be interpreted as queued work just because
# old/synthetic ArchiveResult rows exist. If takeover kills the runner
# after it leases the Snapshot but before queued ArchiveResult rows finish,
# the rows remain QUEUED while retry_at sits in the future. Recovery runs
# only after this runner has won the single-runner gate, so it can safely
# unlock those stale plugin leases for immediate processing instead of
# waiting out the previous owner's full lock timeout.
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED, **result_filter)
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
Snapshot.objects.filter(
id__in=queued_plugin_results.values("snapshot_id"),
status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
)
.filter(retry_at__gt=now)
.update(retry_at=now, modified_at=now)
)
# Impossible state repair: STARTED ArchiveResults without a live Process
# have no owner left to emit completion. Requeue only the result row; the
# snapshot/crawl schedulers will pick up normal retry processing.
cleaned["archiveresults_started_without_running_process"] = orphaned_results.update(
status=ArchiveResult.StatusChoices.QUEUED,
process=None,
cleaned["archiveresults_interrupted_without_running_process"] = orphaned_results.update(
status=ArchiveResult.StatusChoices.FAILED,
modified_at=now,
)
orphaned_hook_processes = Process.objects.filter(
@@ -168,14 +140,30 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
orphaned_hook_processes = orphaned_hook_processes.filter(snapshot_pwd_filter)
else:
orphaned_hook_processes = orphaned_hook_processes.none()
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status", "started_at", "ended_at").order_by(
"-started_at",
"-id",
):
else:
# Global startup recovery must stay database-only. Process.pwd points
# into archive payload storage, which may be a cold FUSE mount; scanning
# every historical unlinked hook here blocks the sole scheduler before
# it can claim newly queued work. Explicit crawl recovery below remains
# available for the work the caller asked to resume.
orphaned_hook_processes = orphaned_hook_processes.none()
for process in orphaned_hook_processes.only(
"id",
"pwd",
"cmd",
"process_type",
"status",
"exit_code",
"stdout",
"stderr",
"started_at",
"ended_at",
).order_by("-started_at", "-id"):
hook_script_name = process.hook_script_name
if not hook_script_name or not process.pwd:
continue
plugin_dir = Path(process.pwd)
hook_name = _canonical_hook_name(hook_script_name)
if crawl_snapshot_ids is not None and plugin_dir.parent.name not in crawl_snapshot_ids:
continue
try:
@@ -187,42 +175,44 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
continue
if snapshot is None:
continue
result, created = ArchiveResult.objects.get_or_create(
snapshot=snapshot,
plugin=plugin_dir.name,
result, created = ArchiveResult.get_or_create_by_hook(
snapshot,
plugin_dir.name,
hook_name,
defaults={
"hook_name": Path(hook_script_name).stem,
"status": ArchiveResult.StatusChoices.QUEUED,
"status": ArchiveResult.StatusChoices.FAILED,
},
)
if created or result.status == ArchiveResult.StatusChoices.QUEUED:
process_is_newer = bool(process.started_at and (result.start_ts is None or process.started_at >= result.start_ts))
if created or process_is_newer:
requeue_snapshot = False
# A runner can die after the hook Process exits but before the
# ProcessCompletedEvent projector links/finalizes ArchiveResult.
# Reconstruct the plugin row from its newest durable Process row.
output_files, output_size, output_mimetypes = _collect_output_metadata(plugin_dir)
# Reconstruct only that exact hook row from the durable Process row.
manifest = OutputManifest.scan(plugin_dir, containment_root=snapshot.output_dir)
output_files = manifest.as_mapping()
output_size = manifest.total_size
output_mimetypes = ",".join(manifest.mimetypes)
emitted_records = [
record
for record in Process.parse_records_from_text(process.stdout or "")
if record.get("type") == "ArchiveResult"
and (record.get("plugin") or plugin_dir.name) == plugin_dir.name
and (record.get("hook_name") or Path(hook_script_name).stem) == Path(hook_script_name).stem
and _canonical_hook_name(str(record.get("hook_name") or hook_name)) == hook_name
]
emitted_result = emitted_records[-1] if emitted_records else {}
result.hook_name = Path(hook_script_name).stem
result.process = process
result.start_ts = process.started_at
result.end_ts = process.ended_at
if _is_signal_interrupted_exit(process.exit_code):
# The owning runner died or was asked to stop while the hook was
# still active. Keep the work item queued so takeover retries the
# same hook; treating an unknown signal exit as success would
# silently skip unfinished side effects.
# The Process is a durable fact; record the interruption as a
# failure and wake the parent Snapshot for replay.
result.output_files = {}
result.output_size = 0
result.output_mimetypes = ""
result.output_str = ""
result.status = ArchiveResult.StatusChoices.QUEUED
result.output_json = None
result.status = ArchiveResult.StatusChoices.FAILED
requeue_snapshot = True
else:
result.output_files = output_files
@@ -246,7 +236,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
)
result.save(
update_fields=[
"hook_name",
"process",
"start_ts",
"end_ts",
@@ -263,7 +252,6 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
if created:
cleaned["archiveresults_missing_for_orphaned_hook_processes"] += 1
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
**snapshot_filter,
@@ -277,8 +265,8 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
# We only unlock scheduling; normal Snapshot runner code owns the next
# transition and side effects.
cleaned["snapshots_started_without_running_results"] = (
started_snapshots.annotate(has_running_results=Exists(running_archiveresults))
.filter(has_running_results=False)
started_snapshots.annotate(has_running_process=Exists(running_hook_processes))
.filter(has_running_process=False)
.update(
retry_at=now,
modified_at=now,
@@ -287,7 +275,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
# Broken lock repair: STARTED + retry_at=NULL is an orphaned ownership
# lease. Recovery only unlocks scheduling; the runner owns any subsequent
# state-machine transition, including sealing rows whose children/results
# lifecycle transition, including sealing rows whose children/results
# are already final.
recoverable_started_crawls = Crawl.objects.filter(status=Crawl.StatusChoices.STARTED).filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
@@ -335,13 +323,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
"Finishing {count} browser-extension Snapshot(s) that received uploaded files before server extractors started "
"(uploaded and server-created results will remain together on the same Snapshot)."
),
"archiveresults_backoff": (
"Retrying {count} extractor result(s) that were waiting to retry "
"(ArchiveBox may have been interrupted before it was able to try them again; affected outputs will be retried)."
),
"archiveresults_started_without_running_process": (
"Retrying {count} extractor result(s) that were interrupted before finishing "
"(ArchiveBox may have been interrupted before it was able to save their final status; partial files will be overwritten with fresh results upon retry)."
"archiveresults_interrupted_without_running_process": (
"Closing {count} interrupted extractor result projection(s) "
"(their parent Snapshots are resumed through the normal snapshot-level runner)."
),
"snapshots_started_without_running_results": (
"Resuming {count} Snapshot(s) that were interrupted before finishing "
+114 -23
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlparse
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlsplit, urlunsplit
from django.utils.http import url_has_allowed_host_and_scheme
@@ -11,6 +11,8 @@ from archivebox.config.common import get_config, get_request_config
_SNAPSHOT_ID_RE = re.compile(r"^[0-9a-fA-F-]{8,36}$")
_SNAPSHOT_SUBDOMAIN_RE = re.compile(r"^snap-(?P<suffix>[0-9a-fA-F]{12})$")
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api")
_URL_PATH_SAFE = "/@-._~!$&'()*+,;="
_URL_FRAGMENT_SAFE = "/@-._~!$&'()*+,;="
def split_host_port(host: str) -> tuple[str, str | None]:
@@ -60,13 +62,6 @@ def _csrf_trusted_origins(config) -> list[str]:
return seen
def _allowed_hosts(config) -> set[str]:
raw = (config.ALLOWED_HOSTS or "").strip()
if not raw:
return set()
return {entry.strip().lower() for entry in raw.split(",") if entry.strip() and entry.strip() != "*"}
def derive_base_url_from_csrf(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
"""Pick a single CSRF_TRUSTED_ORIGINS entry to act as the implicit BASE_URL.
@@ -74,7 +69,7 @@ def derive_base_url_from_csrf(config: dict[str, Any] | None = None, **config_kwa
had ``CSRF_TRUSTED_ORIGINS=https://archive.example.com`` set (required for
admin login to work). On upgrade, ``BASE_URL`` is the new knob — but it
defaults to empty, and falling through to ``BIND_ADDR`` produces an
unreachable URL like ``http://0.0.0.0:8000``. If the user has exactly one
unreachable URL like ``http://0.0.0.0:5797``. If the user has exactly one
CSRF origin we treat it as the implicit BASE_URL so links/redirects keep
pointing at the public hostname they already configured.
@@ -93,11 +88,6 @@ def get_listen_host(config: dict[str, Any] | None = None, **config_kwargs: Any)
return (config.BIND_ADDR or "").strip()
def get_listen_parts(config: dict[str, Any] | None = None, **config_kwargs: Any) -> tuple[str, str | None]:
config = config or get_config(**config_kwargs)
return split_host_port(get_listen_host(config=config))
def _with_port(host: str, port: str | None) -> str:
return f"{host}:{port}" if port else host
@@ -133,7 +123,7 @@ def canonical_base_host_for_request(request_host: str) -> str:
"""Strip role subdomains and remap loopback hostnames to ``archivebox.localhost``.
Used by the banner suggestion and the in-browser pin endpoint: when the
user is hitting the server on raw ``localhost:9292`` or ``127.0.0.1:9292``
user is hitting the server on raw ``localhost:5797`` or ``127.0.0.1:5797``
we want to suggest the wildcard-friendly ``archivebox.localhost`` family
instead, so the eventual pinned ``BASE_URL`` plays nicely with subdomain
routing without forcing the user to add a /etc/hosts entry.
@@ -146,7 +136,7 @@ def canonical_base_host_for_request(request_host: str) -> str:
def _root_host_from_listen(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
listen_host, listen_port = get_listen_parts(config=config)
listen_host, listen_port = split_host_port(get_listen_host(config=config))
root_host = "archivebox.localhost" if _is_local_bind_host(listen_host) else listen_host
return _with_port(root_host, listen_port) if root_host else ""
@@ -380,11 +370,6 @@ def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any]
)
def get_original_base_url(domain: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
return _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
def build_admin_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
return _build_url(get_admin_base_url(request, config=config, **config_kwargs), path)
@@ -394,11 +379,109 @@ def build_web_url(path: str = "", request=None, config: dict[str, Any] | None =
def build_snapshot_url(snapshot_id: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), path)
# `path` is a filesystem-relative output path, not an already-encoded URL.
# In particular, wget can save literal `%3A` in filenames; emitting that
# unchanged makes the browser request `:`, which cannot match the file.
output_path = quote(str(path).lstrip("/"), safe=_URL_PATH_SAFE)
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), output_path)
def build_snapshot_detail_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
"""Build the live Django snapshot-detail path and optional output anchor."""
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
detail_path = f"{prefix}{normalized_archive_path}"
normalized_output_path = str(output_path or "").lstrip("#/")
if not normalized_output_path:
return detail_path
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
return f"{detail_path}#{fragment}"
def build_snapshot_index_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
"""Build a path to the literal exported ``index.html`` snapshot file."""
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
index_path = f"{prefix}{normalized_archive_path}/index.html"
normalized_output_path = str(output_path or "").lstrip("#/")
if not normalized_output_path:
return index_path
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
return f"{index_path}#{fragment}"
def get_snapshot_output_anchor(plugin: str, output_path: str) -> str:
"""Map root-relative legacy outputs to the plugin card that displays them."""
normalized_output_path = str(output_path or "").lstrip("/")
return normalized_output_path if normalized_output_path.startswith(f"{plugin}/") else plugin
def build_snapshot_detail_url(
archive_path: str,
output_path: str = "",
request=None,
config: dict[str, Any] | None = None,
**config_kwargs: Any,
) -> str:
"""Build a request-aware URL for trusted snapshot UI on the web origin."""
return build_web_url(
build_snapshot_detail_path(archive_path, output_path=output_path),
request=request,
config=config,
**config_kwargs,
)
def build_snapshot_role_output_url(
snapshot,
role: str,
request=None,
config: dict[str, Any] | None = None,
archive_results=None,
fallback_to_default: bool = False,
**config_kwargs: Any,
) -> str:
"""Build the first available output URL for a plugin-owned snapshot role."""
from archivebox.plugins.discovery import get_plugin_default_output_path, get_snapshot_role_names
for plugin in get_snapshot_role_names(role):
output_path = snapshot.plugin_output_path(plugin, archive_results=archive_results)
if not output_path and fallback_to_default:
output_path = get_plugin_default_output_path(plugin)
if output_path:
return build_snapshot_url(str(snapshot.id), output_path, request=request, config=config, **config_kwargs)
return ""
def build_snapshot_files_url(
snapshot_id: str,
path: str = "",
request=None,
config: dict[str, Any] | None = None,
**config_kwargs: Any,
) -> str:
"""Build a request-aware directory-browser URL on the isolated replay origin."""
directory_url = build_snapshot_url(snapshot_id, path, request=request, config=config, **config_kwargs)
parts = urlsplit(directory_url)
normalized_directory_path = f"{parts.path.rstrip('/')}/"
directory_url = urlunsplit((parts.scheme, parts.netloc, normalized_directory_path, parts.query, parts.fragment))
return _replace_url_query_params(directory_url, files="1")
def build_snapshot_zip_url(
snapshot_id: str,
path: str = "",
request=None,
config: dict[str, Any] | None = None,
**config_kwargs: Any,
) -> str:
"""Build a request-aware ZIP download URL for a snapshot directory."""
files_url = build_snapshot_files_url(snapshot_id, path, request=request, config=config, **config_kwargs)
return _replace_url_query_params(files_url, download="zip")
def build_original_url(domain: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
return _build_url(get_original_base_url(domain, request=request, config=config, **config_kwargs), path)
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
base_url = _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
return _build_url(base_url, path)
def _build_url(base_url: str, path: str) -> str:
@@ -409,3 +492,11 @@ def _build_url(base_url: str, path: str) -> str:
if not path:
return base_url
return f"{base_url}{path if path.startswith('/') else f'/{path}'}"
def _replace_url_query_params(url: str, **params: str) -> str:
parts = urlsplit(url)
replacement_keys = set(params)
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key not in replacement_keys]
query.extend((key, value) for key, value in params.items())
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
+2 -3
View File
@@ -76,7 +76,6 @@ INSTALLED_APPS = [
"archivebox.crawls", # handles Crawl and CrawlSchedule models and management (depends on core)
"archivebox.progressmonitor", # live progress endpoint and admin monitor template
"archivebox.api", # Django-Ninja-based Rest API interfaces, config, APIToken model, etc.
"archivebox.opencode",
# 3rd-party apps from PyPI that need to be loaded last
"admin_data_views", # handles rendering some convenient automatic read-only views of data in Django admin
"django_extensions", # provides Django Debug Toolbar (and other non-debug helpers)
@@ -88,6 +87,7 @@ DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST"
MIDDLEWARE = [
"archivebox.core.middleware.TimezoneMiddleware",
"django.middleware.security.SecurityMiddleware",
"archivebox.core.middleware.CacheControlMiddleware",
"archivebox.core.middleware.AdminCookieIsolationMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -98,7 +98,6 @@ MIDDLEWARE = [
"archivebox.core.middleware.ServerSecurityModeMiddleware",
"archivebox.core.middleware.HostRoutingMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"archivebox.core.middleware.CacheControlMiddleware",
# Additional middlewares from plugins (if any)
]
@@ -391,7 +390,7 @@ SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
# get_base_url() also covers deployments that only set CSRF_TRUSTED_ORIGINS (the
# implicit-BASE_URL fallback used on 0.7.x->0.9.x upgrades), so HTTPS hardening
# isn't lost until BASE_URL is migrated. An explicit plain-http base (e.g. local
# http://archivebox.localhost:8000) disables proxy HTTPS handling.
# http://archivebox.localhost:5797) disables proxy HTTPS handling.
BASE_URL_IS_HTTPS = get_base_url(config=CONFIG).strip().lower().startswith("https://")
if BASE_URL_IS_HTTPS or not CONFIG.BASE_URL:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+16 -7
View File
@@ -101,6 +101,7 @@ def foreground_shutdown_signals(
*,
first_signal_message: str | None = "\n[🛑] Got {signal_name}, stopping gracefully...\n",
on_signal: Callable[[signal.Signals], None] | None = None,
interrupt_handlers: dict[signal.Signals, Callable[[], None]] | None = None,
raise_on_first_signal: bool = True,
) -> Iterator[ShutdownSignalState]:
"""Install foreground signal handlers that print an immediate exit notice.
@@ -118,10 +119,20 @@ def foreground_shutdown_signals(
state = ShutdownSignalState()
previous_active_state = _active_shutdown_state
previous_handlers = {sig: signal.getsignal(sig) for sig in handled_signals}
previous_handlers = {sig: signal.getsignal(sig) for sig in (*handled_signals, *(interrupt_handlers or {}))}
def raise_keyboard_interrupt(signum, _frame):
sig = signal.Signals(signum)
if interrupt_handlers and sig in interrupt_handlers:
# Shield resumable terminal intent from sticky shutdown state.
# Ctrl+C is delegated to abx-dl's pause/abort controller; SIGUSR1/2
# carry prompt answers. Recording any of these as shutdown here
# makes a later skip/retry fail raise_if_shutdown_requested(), or
# makes a second Ctrl+C hard-exit before hooks can flush output.
# SIGTERM/SIGHUP deliberately bypass this shield: ownership takeover
# and parent loss must cancel the old worker without asking for input.
interrupt_handlers[sig]()
return
already_requested = state.signal_name is not None
if not already_requested:
state.signal_name = sig.name
@@ -129,11 +140,9 @@ def foreground_shutdown_signals(
os.write(sys.stdout.fileno(), first_signal_message.format(signal_name=state.signal_name).encode())
if on_signal is not None:
on_signal(sig)
# Foreground `archivebox add` uses the first signal to abort the active
# hook through the bus-facing runner code, then reserves the second
# signal for hard foreground-command shutdown. Server/update/run and
# other non-interactive commands raise immediately so their finally
# blocks can stop owned children without prompting.
# Actual shutdown requests remain sticky and a second signal forces
# exit. Resumable interactive actions were handled above without
# marking this context as shutting down.
if already_requested:
os.write(sys.stdout.fileno(), f"\n[🛑] Got {sig.name} again, exiting immediately.\n".encode())
os._exit(130)
@@ -142,7 +151,7 @@ def foreground_shutdown_signals(
try:
_active_shutdown_state = state
for sig in handled_signals:
for sig in previous_handlers:
signal.signal(sig, raise_keyboard_interrupt)
yield state
finally:
+31 -14
View File
@@ -6,15 +6,16 @@ from typing import Any
from urllib.parse import unquote
from django.contrib.auth.models import User
from django.db.models import Count, Exists, F, OuterRef, QuerySet
from django.db.models import Count, Exists, F, OuterRef, Prefetch, QuerySet
from django.db.models.functions import Lower
from django.http import HttpRequest
from django.urls import reverse
from archivebox.config.common import get_config
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag, Tag
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
from archivebox.misc.util import sanitize_html_text
from archivebox.core.routes_util import build_snapshot_url, build_web_url
from archivebox.core.models import Snapshot, SnapshotTag, Tag
from archivebox.plugins.discovery import get_snapshot_role_names
TAG_SNAPSHOT_PREVIEW_LIMIT = 10
@@ -152,15 +153,8 @@ def get_or_create_tag(name: str, created_by: User | None = None) -> tuple[Tag, b
if not normalized_name:
raise ValueError("Tag name is required")
existing = Tag.objects.filter(name__iexact=normalized_name).first()
if existing:
return existing, False
tag = Tag.objects.create(
name=normalized_name,
created_by=created_by,
)
return tag, True
defaults = {"created_by": created_by} if created_by is not None else None
return Tag.get_or_create_by_name(normalized_name, defaults=defaults)
def rename_tag(tag: Tag, name: str) -> Tag:
@@ -209,9 +203,16 @@ def _build_snapshot_preview(snapshot: Snapshot, request: HttpRequest | None = No
"id": str(snapshot.pk),
"title": _display_snapshot_title(snapshot),
"url": snapshot.url,
"favicon_url": build_snapshot_url(str(snapshot.pk), "favicon.ico", request=request, config=config),
"favicon_url": build_snapshot_role_output_url(
snapshot,
"list_icon",
request=request,
config=config,
archive_results=snapshot._list_icon_results,
fallback_to_default=True,
),
"admin_url": reverse("admin:core_snapshot_change", args=[snapshot.pk]),
"archive_url": build_web_url(f"/{snapshot.archive_path_from_db}/index.html", request=request, config=config),
"archive_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=config),
"downloaded_at": snapshot.downloaded_at.isoformat() if snapshot.downloaded_at else None,
}
@@ -228,6 +229,22 @@ def _build_snapshot_preview_map(
snapshot_tags = (
SnapshotTag.objects.filter(tag_id__in=tag_ids)
.select_related("snapshot__crawl__created_by")
.prefetch_related(
Prefetch(
"snapshot__archiveresult_set",
queryset=ArchiveResult.objects.filter(
plugin__in=get_snapshot_role_names("list_icon"),
status=ArchiveResult.StatusChoices.SUCCEEDED,
).only(
"snapshot_id",
"plugin",
"status",
"output_str",
"output_files",
),
to_attr="_list_icon_results",
),
)
.order_by(
"tag_id",
F("snapshot__downloaded_at").desc(nulls_last=True),
+27 -6
View File
@@ -76,6 +76,8 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
"""Return the newest live local parent allowed to borrow runner/sonic."""
from django.db.models import Q
from archivebox.machine.models import Machine, Process
machine = Machine.current()
@@ -83,7 +85,13 @@ def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
machine=machine,
status=Process.StatusChoices.RUNNING,
pwd=str(data_dir),
process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE),
).filter(
Q(process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE))
# A direct `run` owns its own hooks, just as add/update own theirs via
# a supervised one-shot worker. Respawning the server daemon must not
# steal the gate back from that newer foreground command. Supervised
# runner children cannot elect themselves above their parent command.
| (Q(process_type=Process.TypeChoices.ORCHESTRATOR) & ~Q(parent__process_type=Process.TypeChoices.SUPERVISORD)),
)
if exclude_id is not None:
qs = qs.exclude(id=exclude_id)
@@ -126,12 +134,25 @@ def ensure_daemon_stack(*, reason: str = ""):
supervisor = get_existing_supervisord_process() or get_or_create_supervisord_process(daemonize=False)
worker = get_worker(supervisor, sonic_worker["name"])
if isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING"):
return worker
if not (isinstance(worker, dict) and worker.get("statename") in ("STARTING", "RUNNING")):
if reason:
rprint(f"[yellow][*] Starting daemon stack for {reason}...[/yellow]")
worker = start_worker(supervisor, sonic_worker)
if reason:
rprint(f"[yellow][*] Starting daemon stack for {reason}...[/yellow]")
return start_worker(supervisor, sonic_worker)
# Supervisord marks this worker RUNNING with startsecs=0, before Sonic has
# opened its search socket. A query launched at that point fails even though
# the daemon is about to become ready.
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
if is_port_listening(sonic_event.host, sonic_event.port):
if time.monotonic() < deadline:
return worker
break
worker = get_worker(supervisor, sonic_worker["name"])
if not isinstance(worker, dict) or worker.get("statename") not in ("STARTING", "RUNNING"):
raise RuntimeError(f"Sonic search backend worker failed to start: {worker}")
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
raise RuntimeError(f"Sonic search backend is not listening at {sonic_event.url}")
def live_runner_processes(*, data_dir: str | Path):
+347 -140
View File
@@ -1,38 +1,96 @@
import hashlib
import logging
import math
import os
import re
from html import unescape
from pathlib import Path
from typing import Any
from urllib.parse import quote
from urllib.parse import quote, urlparse
from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target
from django import template
from django.templatetags.static import static
from django.utils import timezone
from django.utils.html import escape
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.text import Truncator
from archivebox.config import CONSTANTS
from archivebox.core.routes_util import (
build_snapshot_detail_url,
build_snapshot_files_url,
build_snapshot_role_output_url,
build_snapshot_url,
build_snapshot_zip_url,
get_admin_base_url,
get_snapshot_base_url,
get_web_base_url,
)
from archivebox.core.setup_wizard import get_base_url_mismatch_context, get_setup_wizard_context
from archivebox.plugins.discovery import (
get_plugin_catalog,
get_plugin_icon,
get_plugin_name,
get_plugin_template,
plugin_card_is_interactive,
)
register = template.Library()
register.filter("plugin_card_is_interactive", plugin_card_is_interactive)
# Preview cards are clipped windows onto their content, never scroll areas.
# Apply inside the document too: parent overflow cannot constrain an iframe.
CARD_OVERFLOW_STYLE = (
"<style>html,body{height:100%!important;min-height:0!important}"
"html,body,body *{overflow:clip!important;scrollbar-width:none!important}</style>"
)
@register.filter
def snapshot_url_text(url):
"""Emphasize the hostname while preserving the exact selectable URL."""
url = str(url or "")
match = re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//([^/?#]*)", url)
if not match:
return escape(url)
netloc = match[1]
authority = netloc.rsplit("@", 1)[-1]
start = match.start(1) + len(netloc) - len(authority)
host_end = authority.find("]") + 1 if authority.startswith("[") else authority.find(":")
hostname = authority[:host_end] if host_end > 0 else authority
if hostname.lower().startswith("www."):
start += 4
hostname = hostname[4:]
remainder = url[start + len(hostname) :]
suffix = re.search(r"[?&#]", remainder)
suffix_start = suffix.start() if suffix else len(remainder)
return format_html(
'<span class="snapshot-url-prefix">{}</span><span class="snapshot-url-domain">{}</span>'
'<span class="snapshot-url-path">{}</span><span class="snapshot-url-suffix">{}</span>',
url[:start],
hostname,
remainder[:suffix_start],
remainder[suffix_start:],
)
@register.simple_tag(takes_context=True)
def plugin_ui(context, plugin: str, slot: str):
"""Optional plugin UI must never prevent the host page from rendering."""
try:
source = get_plugin_template(plugin, slot, fallback=False)
if not source:
return ""
return template.engines["django"].from_string(source).render(context.flatten())
except Exception:
logging.getLogger(__name__).exception("Unable to render optional plugin UI: %s/%s", plugin, slot)
return ""
_TEXT_PREVIEW_EXTS = (".json", ".jsonl", ".txt", ".csv", ".tsv", ".xml", ".yml", ".yaml", ".md", ".log")
_IMAGE_PREVIEW_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".avif")
_STATIC_URL_SAFE = "/@-._~!$&'()*+,;="
_MEDIA_FILE_EXTS = {
_VIDEO_FILE_EXTS = {
".mp4",
".webm",
".mkv",
@@ -49,6 +107,9 @@ _MEDIA_FILE_EXTS = {
".3gp",
".3g2",
".ogv",
}
_AUDIO_FILE_EXTS = {
".mp3",
".m4a",
".aac",
@@ -66,16 +127,30 @@ _MEDIA_FILE_EXTS = {
".dts",
}
_MEDIA_FILE_EXTS = _VIDEO_FILE_EXTS | _AUDIO_FILE_EXTS
_BROWSER_VIDEO_FILE_EXTS = {".mp4", ".webm", ".m4v", ".ogv"}
_BROWSER_AUDIO_FILE_EXTS = {".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".wav", ".flac"}
def _is_web_subdomain_alias(browser_url: str, configured_base_url: str) -> bool:
"""Hide the informational mismatch banner on the conventional public web alias.
This is deliberately presentation-only: request routing and the restriction
on state-changing requests continue to use the configured BASE_URL.
"""
browser = urlparse(browser_url)
configured = urlparse(configured_base_url)
return (
browser.scheme.lower() == configured.scheme.lower()
and browser.hostname == f"web.{configured.hostname}"
and browser.port == configured.port
)
def _normalize_output_files(output_files: Any) -> dict[str, dict[str, Any]]:
if isinstance(output_files, dict):
normalized: dict[str, dict[str, Any]] = {}
for path, metadata in output_files.items():
if not path:
continue
normalized[str(path)] = dict(metadata) if isinstance(metadata, dict) else {}
return normalized
return {}
from abx_dl.output_files import OutputManifest
return OutputManifest.from_value(output_files).as_mapping()
def _snapshot_id(value: Any) -> Any:
@@ -122,10 +197,10 @@ def _count_media_files(result) -> int:
return count
def _list_media_files(result) -> list[dict]:
def _list_media_files(result, *, include_filesystem_fallback=True) -> list[dict]:
media_files: list[dict] = []
try:
plugin_dir = Path(result.snapshot_dir) / result.plugin
plugin_dir = Path(result.snapshot_dir) / result.plugin if include_filesystem_fallback else None
except (AttributeError, TypeError, ValueError):
return media_files
@@ -137,7 +212,7 @@ def _list_media_files(result) -> list[dict]:
if rel_path.suffix.lower() in _MEDIA_FILE_EXTS:
candidates.append((rel_path, _coerce_output_file_size(metadata.get("size"))))
if not candidates and plugin_dir.exists():
if not candidates and plugin_dir is not None and plugin_dir.exists():
scanned = 0
max_scan = 2000
for file_path in plugin_dir.rglob("*"):
@@ -158,16 +233,34 @@ def _list_media_files(result) -> list[dict]:
candidates.append((rel_path, size))
for rel_path, size in candidates:
href = str(Path(result.plugin) / rel_path)
if callable(getattr(result, "output_file_path", None)):
href = result.output_file_path(str(rel_path), output_file_map=output_files)
else:
metadata = output_files.get(str(rel_path)) or {}
href = str(rel_path) if metadata.get("root_relative") else str(Path(result.plugin) / rel_path)
if not href:
continue
suffix = rel_path.suffix.lower()
media_type = "video" if suffix in _VIDEO_FILE_EXTS else "audio"
media_files.append(
{
"name": rel_path.name,
"path": href,
"size": size,
"media_type": media_type,
"is_video": media_type == "video",
"is_audio": media_type == "audio",
"is_browser_playable": suffix in _BROWSER_VIDEO_FILE_EXTS or suffix in _BROWSER_AUDIO_FILE_EXTS,
},
)
media_files.sort(key=lambda item: item["name"].lower())
media_files.sort(
key=lambda item: (
0 if item["is_video"] and item["is_browser_playable"] else 1 if item["is_audio"] and item["is_browser_playable"] else 2,
-int(item.get("size") or 0),
item["name"].lower(),
),
)
return media_files
@@ -225,12 +318,14 @@ def _snapshot_base_url_for_context(context, snapshot) -> str:
def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
if not context.get("STATIC_EXPORT"):
return build_snapshot_url(
path_part, separator, query = str(path or "").partition("?")
url = build_snapshot_url(
str(_snapshot_id(snapshot)),
path,
path_part,
request=context.get("request"),
config=context.get("CONFIG"),
)
return f"{url}?{query}" if separator else url
base_url = _static_snapshot_base_url(context, snapshot)
raw_path = str(path or "")
@@ -242,14 +337,6 @@ def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
return f"{base_url.rstrip('/')}/{quoted_path}{suffix}"
def _build_snapshot_files_url(snapshot_id: str, request=None, config=None, base_url: str | None = None) -> str:
return (
f"{base_url.rstrip('/')}/index.jsonl"
if base_url
else build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
)
def _build_snapshot_preview_url(
snapshot_id: str,
path: str = "",
@@ -261,20 +348,25 @@ def _build_snapshot_preview_url(
if path == "about:blank":
return path
if _is_root_snapshot_output_path(path):
return _build_snapshot_files_url(snapshot_id, request=request, config=config, base_url=base_url)
return (
f"{base_url.rstrip('/')}/index.jsonl"
if base_url
else build_snapshot_files_url(str(snapshot_id), request=request, config=config)
)
if base_url:
path_part, separator, query = str(path).lstrip("/").partition("?")
url = f"{base_url.rstrip('/')}/{quote(path_part, safe=_STATIC_URL_SAFE)}"
if separator:
url = f"{url}?{query}"
else:
url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
path_part, separator, query = str(path).partition("?")
url = build_snapshot_url(str(snapshot_id), path_part, request=request, config=config)
if separator:
url = f"{url}?{query}"
path_parts = Path(path).parts
plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "")
has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False))
if not (
_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview or is_archivewebpage_replay_target(path or "")
):
if not (_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview):
return url
separator = "&" if "?" in url else "?"
return f"{url}{separator}preview=1"
@@ -285,7 +377,7 @@ def _render_text_preview(plugin: str, icon_html: str, snippet: str) -> str:
plugin_label = escape(plugin or "")
escaped = escape(snippet)
return (
f'<div class="thumbnail-text" data-plugin="{plugin_attr}" data-compact="1">'
f'<div class="thumbnail-text" data-plugin="{plugin_attr}">'
f'<div class="thumbnail-text-header">'
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
f'<span class="thumbnail-text-title">{plugin_label}</span>'
@@ -300,7 +392,7 @@ def _render_fallback_card(plugin: str, icon_html: str, fallback_label: str) -> s
plugin_label = escape(plugin or "")
fallback_attr = escape(fallback_label)
return (
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}" data-compact="1">'
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}">'
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
f'<span class="thumbnail-compact-label">{plugin_label}</span>'
f'<span class="thumbnail-compact-meta">{fallback_attr}</span>'
@@ -340,12 +432,29 @@ def index(value, position):
@register.filter
def file_size(num_bytes: float) -> str:
def file_size(num_bytes: float, decimal_places: int = 1) -> str:
for count in ["Bytes", "KB", "MB", "GB"]:
if num_bytes > -1024.0 and num_bytes < 1024.0:
return f"{num_bytes:3.1f} {count}"
if num_bytes >= 0:
precision = 10 ** max(0, int(decimal_places))
num_bytes = math.ceil(num_bytes * precision) / precision
return f"{num_bytes:.{decimal_places}f} {count}"
num_bytes /= 1024.0
return "{:3.1f} {}".format(num_bytes, "TB")
if num_bytes >= 0:
precision = 10 ** max(0, int(decimal_places))
num_bytes = math.ceil(num_bytes * precision) / precision
return f"{num_bytes:.{decimal_places}f} TB"
@register.filter
def grid_file_size(num_bytes: float | None) -> str:
if not num_bytes or num_bytes <= 0:
return ""
if num_bytes < 1024**2:
return f"{max(1, num_bytes / 1024):.0f}kb"
if num_bytes < 1024**3:
return f"{num_bytes / 1024**2:.0f}mb"
return f"{num_bytes / 1024**3:.1f}gb"
@register.filter
@@ -416,9 +525,9 @@ def system_warnings_banner(context):
"""Render the top-of-page warning banner for one of the conditions below,
in priority order (highest first):
1. ``mode="unconfigured"``— ``BASE_URL`` is empty. Security/correctness
issue: until it's pinned, generated URLs can echo any Host the client
sends, and admin/web/api routing has no canonical anchor.
1. ``mode="unconfigured"``— ``BASE_URL`` is empty and the operator has
not selected safe single-domain routing. That mode intentionally uses
each device's connection address, including LAN and Tailscale IPs.
2. ``mode="base_url_mismatch"`` — the browser reached ArchiveBox through
an origin that differs from the configured canonical ``BASE_URL``.
3. ``mode="unsafe"`` — ``SERVER_SECURITY_MODE`` explicitly enables
@@ -445,10 +554,10 @@ def system_warnings_banner(context):
config = get_config(resolve_plugins=False)
if not config.BASE_URL:
if not config.BASE_URL and config.SERVER_SECURITY_MODE != "safe-onedomain-nojsreplay":
return get_setup_wizard_context(context.get("request"), config)
mismatch = get_base_url_mismatch_context(context.get("request"), config)
if mismatch:
if mismatch and not _is_web_subdomain_alias(mismatch["browser_url"], mismatch["configured_base_url"]):
return mismatch
if config.IS_LOWER_SECURITY_MODE:
return {"mode": "unsafe"}
@@ -512,66 +621,140 @@ def snapshot_base_url(context, snapshot) -> str:
return _snapshot_base_url_for_context(context, snapshot)
@register.simple_tag(takes_context=True)
def snapshot_detail_url(context, snapshot) -> str:
"""Keep the trusted snapshot detail page on web.*, not the replay host."""
if context.get("STATIC_EXPORT"):
return _snapshot_url_for_context(context, snapshot, "index.html")
return build_snapshot_detail_url(
snapshot.archive_path_from_db,
request=context.get("request"),
config=context.get("CONFIG"),
)
@register.simple_tag(takes_context=True)
def snapshot_url(context, snapshot, path: str = "") -> str:
return _snapshot_url_for_context(context, snapshot, path)
@register.simple_tag(takes_context=True)
def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
snapshot_id = str(_snapshot_id(snapshot))
url_cache = snapshot.__dict__.setdefault("_snapshot_archiveresult_url_cache", {})
def snapshot_files_url(context, snapshot, path: str = "") -> str:
if context.get("STATIC_EXPORT"):
return _snapshot_url_for_context(context, snapshot, "index.jsonl")
return build_snapshot_files_url(
str(_snapshot_id(snapshot)),
path,
request=context.get("request"),
config=context.get("CONFIG"),
)
@register.simple_tag(takes_context=True)
def snapshot_zip_url(context, snapshot, path: str = "") -> str:
if context.get("STATIC_EXPORT"):
return ""
return build_snapshot_zip_url(
str(_snapshot_id(snapshot)),
path,
request=context.get("request"),
config=context.get("CONFIG"),
)
@register.simple_tag(takes_context=True)
def snapshot_role_output_url(context, snapshot, role: str, fallback_to_default: bool = False) -> str:
url_cache = snapshot.__dict__.setdefault("_snapshot_role_output_url_cache", {})
cache_key = (
plugin,
filename,
role,
bool(context.get("STATIC_EXPORT")),
str(context.get("STATIC_EXPORT_DIR") or ""),
fallback_to_default,
)
if cache_key in url_cache:
return url_cache[cache_key]
results = None
if "_admin_archiveresults" in snapshot.__dict__:
results = snapshot.__dict__["_admin_archiveresults"]
elif "archiveresult_set" in snapshot.__dict__.get("_prefetched_objects_cache", {}):
results = snapshot.archiveresult_set.all()
elif "_snapshot_archiveresult_url_results" in snapshot.__dict__:
results = snapshot.__dict__["_snapshot_archiveresult_url_results"]
from archivebox.plugins.discovery import get_snapshot_role_names
if results is None:
from archivebox.core.models import ArchiveResult
results = list(
ArchiveResult.objects.filter(
snapshot_id=snapshot_id,
plugin__in=("screenshot", "chrome_extension_screenshot", "favicon"),
status=ArchiveResult.StatusChoices.SUCCEEDED,
).only(
"plugin",
"status",
"output_files",
),
if context.get("STATIC_EXPORT"):
output_path = None
for plugin in get_snapshot_role_names(role):
output_path = snapshot.plugin_output_path(plugin)
if output_path:
break
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path) if output_path else ""
else:
url_cache[cache_key] = build_snapshot_role_output_url(
snapshot,
role,
request=context.get("request"),
config=context.get("CONFIG"),
fallback_to_default=fallback_to_default,
)
snapshot.__dict__["_snapshot_archiveresult_url_results"] = results
return url_cache[cache_key]
for result in results:
if result.plugin != plugin or result.status != "succeeded":
continue
output_files = result.output_files or {}
file_info = output_files.get(filename)
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
continue
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path)
return url_cache[cache_key]
url_cache[cache_key] = ""
return ""
@register.simple_tag(takes_context=True)
def snapshot_result_file_url(context, snapshot, result, path: str) -> str:
"""Build a saved-file URL from one ArchiveResult declaration."""
try:
output_path = result.output_file_path(path)
except (AttributeError, TypeError, ValueError, OverflowError):
return ""
if not output_path:
return ""
return _snapshot_url_for_context(context, snapshot, output_path)
@register.simple_tag(takes_context=True)
def snapshot_thumbnail(context, snapshot, admin=False, width="100px", height="100px"):
from django.template.loader import render_to_string
from archivebox.plugins.output_groups import snapshot_thumbnail_result
results = snapshot.__dict__.get("_snapshot_card_results")
if results is None:
results = snapshot.__dict__.get("_admin_output_results")
if results is None:
results = snapshot.__dict__.get("_admin_archiveresults")
if results is None:
results = snapshot.__dict__.get("_prefetched_objects_cache", {}).get("archiveresult_set")
if results is None:
results = snapshot.archiveresult_set.exclude(plugin="").only(
"id",
"snapshot_id",
"plugin",
"status",
"output_str",
"output_files",
"output_size",
)
try:
host = urlparse(snapshot.url).hostname or "Archive"
except ValueError:
host = "Archive"
host = host.removeprefix("www.")
hue = int(hashlib.sha256(host.encode()).hexdigest()[:6], 16) % 360
return mark_safe(
render_to_string(
"core/snapshot_thumbnail.html",
{
**context.flatten(),
"card_result": snapshot_thumbnail_result(results),
"host": host,
"initial": host[:1].upper(),
"hue": hue,
"width": width,
"height": height,
},
),
)
@register.simple_tag(takes_context=True)
def snapshot_index_row(context, link) -> str:
snapshot_base = _snapshot_base_url_for_context(context, link)
detail_url = snapshot_detail_url(context, link)
status = getattr(link, "status", None) or "unknown"
bookmarked_at = getattr(link, "bookmarked_at", None)
@@ -590,7 +773,6 @@ def snapshot_index_row(context, link) -> str:
url = getattr(link, "url", "") or ""
title = unescape(getattr(link, "title", "") or "")
is_pending = status in {"queued", "started", "backoff"}
title_text = title or url
tags_str = link.tags_str() if callable(getattr(link, "tags_str", None)) else getattr(link, "tags_str", "")
tag_html = "".join(f'<span class="snapshot-tag">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag)
@@ -608,50 +790,14 @@ def snapshot_index_row(context, link) -> str:
archive_size = int(getattr(link, "archive_size", 0) or 0)
size_cell = file_size(archive_size) if archive_size else '<span class="empty-value">...</span>'
output_plural = "" if num_outputs == 1 else "s"
files_url = _snapshot_url_for_context(context, link, "index.jsonl") if context.get("STATIC_EXPORT") else f"{snapshot_base}/?files=1"
files_url = snapshot_files_url(context, link)
if is_pending:
preview_html = '<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress"></span>'
if not context.get("STATIC_EXPORT"):
preview_html = (
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
"</span>"
)
elif "_public_preview_paths" in link.__dict__:
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
if preview_paths:
preview_urls = [_snapshot_url_for_context(context, link, path) for path in preview_paths]
preview_html = (
f'<img src="{escape(preview_urls[0])}" '
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
)
else:
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
else:
preview_urls = [
url
for url in (
snapshot_archiveresult_url(context, link, "screenshot", "screenshot.png"),
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot-1.png"),
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot.png"),
)
if url
]
if preview_urls:
preview_html = (
f'<img src="{escape(preview_urls[0])}" '
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
)
else:
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
preview_html = snapshot_thumbnail(context, link)
if "_public_favicon_paths" in link.__dict__:
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
if favicon_paths:
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in favicon_paths]
if "_public_list_icon_paths" in link.__dict__:
list_icon_paths = list(getattr(link, "_public_list_icon_paths", []) or [])
if list_icon_paths:
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in list_icon_paths]
favicon_html = (
f'<img src="{escape(favicon_urls[0])}" '
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
@@ -661,7 +807,7 @@ def snapshot_index_row(context, link) -> str:
else:
favicon_html = '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
else:
favicon_url = snapshot_archiveresult_url(context, link, "favicon", "favicon.ico")
favicon_url = snapshot_role_output_url(context, link, "list_icon")
favicon_html = (
(
f'<img src="{escape(favicon_url)}" '
@@ -676,27 +822,24 @@ def snapshot_index_row(context, link) -> str:
html = f"""
<tr class="snapshot-row status-{escape(status)}">
<td class="snapshot-time" title="{escape(title_time)}" data-sort="{escape(sort_value)}">
<a href="{escape(snapshot_base)}/index.html">
<a href="{escape(detail_url)}">
<span>{escape(date_text)}</span>
<small>{escape(time_text)}</small>
</a>
</td>
<td class="snapshot-preview-cell">
<a href="{escape(snapshot_base)}/index.html" title="Open archived snapshot">
<a href="{escape(detail_url)}" title="Open archived snapshot">
{preview_html}
</a>
</td>
<td class="snapshot-title-cell" title="{escape(title or url)}">
<div class="snapshot-title-line">
<a href="{escape(snapshot_base)}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
{favicon_html}
</a>
<a href="{escape(snapshot_base)}/index.html" class="snapshot-title">
{escape(Truncator(title_text).chars(110))}
<a href="{escape(detail_url)}" class="snapshot-title">
{escape(Truncator(title_text).chars(110) if title_text != url else url)}
</a>
</div>
<a href="{escape(url)}" class="snapshot-url" title="{escape(url)}" target="_blank" rel="noopener noreferrer">
{escape(url)}
{favicon_html}<span class="snapshot-url-text">{snapshot_url_text(url)}</span>
</a>
<span class="snapshot-mobile-saved">Saved {escape(date_text)} at {escape(time_text)}</span>
</td>
@@ -768,6 +911,28 @@ def plugin_card(context, result) -> str:
return ""
plugin = get_plugin_name(result.plugin)
if not context.get("STATIC_EXPORT"):
# The cover and expanded stack both embed this one card document. Run
# plugin code on the snapshot origin so it can read saved files without
# exposing raw archives or the admin session to the collection page.
card_url = build_snapshot_url(
str(_snapshot_id(result.snapshot)),
f"_card/{result.id}",
request=context.get("request"),
config=context.get("CONFIG"),
)
return mark_safe(
f'<iframe src="{escape(card_url)}" title="{escape(plugin)} card" '
'loading="lazy" fetchpriority="low" sandbox="allow-scripts allow-same-origin" '
'style="width:100% !important;height:100% !important;transform:none !important;border:0"></iframe>',
)
return render_plugin_card_document(context, result)
def render_plugin_card_document(context, result) -> str:
"""Render one plugin card template for the trusted snapshot-origin frame."""
plugin = get_plugin_name(result.plugin)
has_card_template = bool(get_plugin_template(plugin, "card", fallback=False))
template_str = get_plugin_template(plugin, "card")
# Use embed_path() for the display path
@@ -775,9 +940,8 @@ def plugin_card(context, result) -> str:
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path or "")
icon_html = get_plugin_icon(plugin)
plugin_lower = (plugin or "").lower()
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
media_files = _list_media_files(result, include_filesystem_fallback=bool(context.get("STATIC_EXPORT")))
media_file_count = len(media_files)
if context.get("STATIC_EXPORT") and media_files:
media_files = [
item
@@ -796,12 +960,22 @@ def plugin_card(context, result) -> str:
output_lower = (raw_output_path or "").lower()
force_text_preview = output_lower.endswith(_TEXT_PREVIEW_EXTS)
# Static exports cannot execute live preview routes; keep their text snippets.
if context.get("STATIC_EXPORT") and force_text_preview:
template_str = None
# Create a mini template and render it with context
try:
if template_str and raw_output_path and str(raw_output_path).strip() not in (".", "/", "./") and not force_text_preview:
if (
template_str
and raw_output_path
and str(raw_output_path).strip() not in (".", "/", "./")
and (has_card_template or not force_text_preview)
):
tpl = template.Template(template_str)
ctx = template.Context(
{
**context.flatten(),
"result": result,
"snapshot": result.snapshot,
"output_path": output_url,
@@ -815,11 +989,39 @@ def plugin_card(context, result) -> str:
rendered = tpl.render(ctx)
# Only return non-empty content (strip whitespace to check)
if rendered.strip():
if rendered.lstrip().lower().startswith("<!doctype html"):
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "</head>", 1)
if context.get("STATIC_EXPORT") and rendered.lstrip().lower().startswith("<!doctype html"):
# Static exports have no _card route. Keep the same card
# document in both stack positions, with relative saved-file
# URLs resolved against the exported index page.
return mark_safe(
f'<iframe srcdoc="{escape(rendered)}" title="{escape(plugin)} card" '
'loading="lazy" sandbox="allow-scripts allow-same-origin" '
'style="width:100% !important;height:100% !important;max-width:100% !important;'
'transform:none !important;border:0"></iframe>',
)
return mark_safe(rendered)
except (template.TemplateSyntaxError, AttributeError, TypeError, ValueError):
rendered = ""
if force_text_preview:
if force_text_preview and not context.get("STATIC_EXPORT"):
# Read archived text only when the browser requests its preview, never
# while rendering the outer snapshot HTML (payloads may live on B2).
preview_url = _build_snapshot_preview_url(
str(_snapshot_id(result.snapshot)),
raw_output_path,
request=context.get("request"),
config=context.get("CONFIG"),
plugin=plugin,
)
return mark_safe(
f'<iframe src="{escape(preview_url)}" title="{escape(plugin)} preview" '
'loading="lazy" fetchpriority="low" sandbox="allow-scripts allow-same-origin" '
'style="border:0;pointer-events:none"></iframe>',
)
if force_text_preview and context.get("STATIC_EXPORT"):
preview = _render_text_file_preview(result.snapshot_dir, raw_output_path, plugin, icon_html)
if preview:
return mark_safe(preview)
@@ -872,7 +1074,11 @@ def plugin_full(context, result) -> str:
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path)
try:
tpl = template.Template(template_str)
catalog = get_plugin_catalog()
tpl = template.Engine(
dirs=[catalog[plugin].path] if plugin in catalog else [],
libraries=template.Engine.get_default().libraries,
).from_string(template_str)
ctx = template.Context(
{
"result": result,
@@ -880,6 +1086,7 @@ def plugin_full(context, result) -> str:
"output_path": output_url,
"output_path_raw": raw_output_path,
"plugin": plugin,
"plugin_icon": get_plugin_icon(plugin),
},
)
rendered = tpl.render(ctx)
+262 -134
View File
@@ -6,8 +6,8 @@ import posixpath
from pathlib import Path
from typing import ClassVar, cast
from urllib.parse import quote, urlparse
from uuid import UUID
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
from admin_data_views.typing import ItemContext, SectionData, TableContext
from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view
from django import template
@@ -23,6 +23,7 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidde
from django.shortcuts import redirect, render
from django.utils.decorators import method_decorator
from django.utils.html import format_html, format_html_join
from django.utils.cache import patch_cache_control, patch_vary_headers
from django.utils.safestring import mark_safe
from django.views import View
from django.views.decorators.csrf import csrf_exempt, csrf_protect
@@ -58,6 +59,8 @@ from archivebox.core.permissions import (
)
from archivebox.core.routes_util import (
build_admin_url,
build_snapshot_detail_url,
build_snapshot_files_url,
build_snapshot_url,
build_web_url,
get_admin_host,
@@ -68,7 +71,7 @@ from archivebox.core.routes_util import (
)
from archivebox.crawls.models import Crawl
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.serve_static import serve_static_with_byterange_support
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL, serve_static_with_byterange_support
from archivebox.misc.util import (
base_url,
filter_queryset_by_uuid_substring,
@@ -77,7 +80,15 @@ from archivebox.misc.util import (
validate_url,
without_fragment,
)
from archivebox.plugins.discovery import get_plugin_name, get_plugin_template
from archivebox.plugins.discovery import (
get_plugin_catalog,
get_plugin_icon,
get_plugin_name,
get_plugin_template,
get_snapshot_role_names,
plugin_has_custom_full_response,
serve_plugin_replay_asset,
)
from archivebox.plugins.forms import get_plugin_config_binary_urls
from archivebox.plugins.views import get_config_definition_link
from archivebox.progressmonitor.views import live_progress_view
@@ -110,7 +121,10 @@ def _find_snapshot_by_ref(snapshot_ref: str) -> Snapshot | None:
snapshots = Snapshot.objects.select_related("crawl", "crawl__created_by")
if len(lookup) == 12 and "-" not in lookup:
return snapshots.filter(id__endswith=lookup).order_by("-created_at", "-downloaded_at").first()
# Resolve suffixes using the covering primary-key index before fetching
# wide snapshot rows and joining their crawl/user data.
matches = list(Snapshot.objects.filter(id__endswith=lookup).order_by().values_list("id", flat=True))
return snapshots.filter(pk__in=matches).order_by("-created_at", "-downloaded_at").first()
try:
return snapshots.get(pk=lookup)
@@ -327,6 +341,20 @@ class SnapshotView(View):
@staticmethod
def render_live_index(request, snapshot):
if request.GET.get("discover_outputs") == "1":
# Reuse the existing output discovery and card/badge classification.
request.archivebox_cache_policy = "private"
initial_context = snapshot.get_html_details_context(request=request)
known_paths = {output["path"] for output in initial_context["archiveresults"]}
context = snapshot.get_html_details_context(request=request, discover_files=True)
context["archiveresults"] = [output for output in context["archiveresults"] if output["path"] not in known_paths]
for output in context["archiveresults"]:
output["output_group"] = "other"
output["folder_path"] = str(Path(output["path"]).parent)
response = render(request, "core/snapshot_output_cards.html", context)
patch_cache_control(response, private=True, no_store=True)
return response
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
return render(
template_name="core/snapshot.html",
request=request,
@@ -343,18 +371,19 @@ class SnapshotView(View):
# slug is a timestamp
if slug.replace(".", "").isdigit():
# missing trailing slash -> redirect to index
if "/" not in path:
return redirect(f"{path}/index.html")
try:
try:
snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
if not can_view_snapshot(request, snapshot):
return _private_snapshot_auth_redirect(request, snapshot, archivefile or "index.html")
canonical_base = snapshot.url_path
if canonical_base != snapshot.legacy_archive_path:
target_path = f"/{canonical_base}/{archivefile or 'index.html'}"
target_path = (
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
if not archivefile or archivefile == "index.html"
else build_snapshot_url(str(snapshot.id), archivefile, request=request)
)
query = request.META.get("QUERY_STRING")
if query:
target_path = f"{target_path}?{query}"
@@ -369,8 +398,9 @@ class SnapshotView(View):
show_indexes=True,
is_archive_replay=True,
)
elif archivefile == "index.html":
# if they requested snapshot index, serve live rendered template instead of static html
elif archivefile in ("", "index.html"):
# Both the canonical extensionless URL and the legacy
# index.html URL render the live Django snapshot page.
response = self.render_live_index(request, snapshot)
else:
target = build_snapshot_url(str(snapshot.id), archivefile, request=request)
@@ -404,15 +434,15 @@ class SnapshotView(View):
except Snapshot.MultipleObjectsReturned:
snapshot_hrefs = mark_safe("<br/>").join(
format_html(
'{} <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
'{} <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
snap.archive_path,
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
snap.timestamp,
snap.url,
snap.title_stripped[:64] or "",
)
for snap in direct_snapshots_queryset(request, Snapshot.objects.filter(timestamp__startswith=slug))
.only("url", "timestamp", "title", "bookmarked_at")
.select_related("crawl__created_by")
.order_by("-bookmarked_at")
)
return HttpResponse(
@@ -436,27 +466,27 @@ class SnapshotView(View):
<title>Snapshot Not Found</title>
</head><body>
<center><br/><br/><br/>
Snapshot <a href="/{}/index.html" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
was queued on {}, but no files have been saved yet in:<br/><b><a href="/{}/" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
Snapshot <a href="{}" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
was queued on {}, but no files have been saved yet in:<br/><b><a href="{}" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
It's possible {} during the last capture on {},<br/>or that the archiving process has not completed yet.<br/>
<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>
<code style="user-select: all; color: #333">archivebox update -t timestamp {}</code></pre><br/><br/>
<div class="text-align: left; width: 100%; max-width: 400px">
<i><b>Next steps:</i></b><br/>
- list all the <a href="/{}/" target="_top">Snapshot files <code>.*</code></a><br/>
- view the <a href="/{}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>
- list all the <a href="{}" target="_top">Snapshot files <code>.*</code></a><br/>
- view the <a href="{}" target="_top">Snapshot <code>./index.html</code></a><br/>
- go to the <a href="/admin/core/snapshot/{}/change/" target="_top">Snapshot admin</a> to edit<br/>
- go to the <a href="/admin/core/snapshot/?id__exact={}" target="_top">Snapshot actions</a> to re-archive<br/>
- or return to <a href="/" target="_top">the main index...</a></div>
</center>
</body></html>
""",
snapshot.archive_path,
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
snapshot.timestamp,
snapshot.url,
snapshot.url,
str(snapshot.bookmarked_at).split(".")[0],
snapshot.archive_path,
build_snapshot_files_url(str(snapshot.id), request=request),
snapshot.timestamp,
archivefile if str(archivefile) != "None" else "",
f"the {archivefile} resource could not be fetched"
@@ -464,8 +494,8 @@ class SnapshotView(View):
else "the original site was not available",
str(snapshot.bookmarked_at).split(".")[0],
snapshot.timestamp,
snapshot.archive_path,
snapshot.archive_path,
build_snapshot_files_url(str(snapshot.id), request=request),
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
snapshot.pk,
snapshot.id,
),
@@ -484,7 +514,7 @@ class SnapshotView(View):
return id_qs
return SnapshotView.find_snapshots_for_url(slug)
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path))
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path)).select_related("crawl__created_by")
try:
if "://" in path:
snapshot = snapshots.order_by("-bookmarked_at").first()
@@ -512,15 +542,15 @@ class SnapshotView(View):
except Snapshot.MultipleObjectsReturned:
snapshot_hrefs = mark_safe("<br/>").join(
format_html(
'{} <code style="font-size: 0.8em">{}</code> <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
'{} <code style="font-size: 0.8em">{}</code> <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
str(snap.id)[:8],
snap.archive_path,
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
snap.timestamp,
snap.url,
snap.title_stripped[:64] or "",
)
for snap in snapshots.only("url", "timestamp", "title", "bookmarked_at").order_by("-bookmarked_at")
for snap in snapshots.select_related("crawl__created_by").order_by("-bookmarked_at")
)
return HttpResponse(
format_html(
@@ -533,7 +563,7 @@ class SnapshotView(View):
status=404,
)
target_path = build_snapshot_url(str(snapshot.id), "index.html", request=request)
target_path = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
query = request.META.get("QUERY_STRING")
if query:
target_path = f"{target_path}?{query}"
@@ -629,6 +659,7 @@ class SnapshotPathView(View):
status=404,
)
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
canonical_base = snapshot.url_path
if date:
requested_base = f"{username}/{date}/{domain or url or ''}"
@@ -637,7 +668,9 @@ class SnapshotPathView(View):
if snapshot_id:
requested_base = f"{requested_base}/{snapshot_id}"
if canonical_base != requested_base:
target = f"/{canonical_base}/{path or 'index.html'}"
target = f"/{canonical_base}"
if path:
target = f"{target}/{path}"
query = request.META.get("QUERY_STRING")
if query:
target = f"{target}?{query}"
@@ -683,34 +716,57 @@ def _safe_archive_relpath(path: str) -> str | None:
return cleaned
def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, ArchiveResult | None]:
"""Resolve plugin-relative output paths through ArchiveResult.output_files."""
def _resolve_archiveresult_relpath(
snapshot: Snapshot,
rel_path: str,
) -> tuple[str, ArchiveResult | None, tuple[str, ...]]:
"""Resolve a declared output path and its exact historical fallbacks."""
parts = Path(rel_path).parts
if len(parts) < 2:
return rel_path, None
return rel_path, None, ()
plugin = parts[0]
plugin_relpath = posixpath.join(*parts[1:])
result = (
ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED)
.only("plugin", "output_files")
.first()
results = list(
ArchiveResult.objects.filter(
snapshot=snapshot,
plugin=plugin,
status=ArchiveResult.StatusChoices.SUCCEEDED,
).only("plugin", "output_files"),
)
if not result:
return rel_path, None
if not result.output_files:
return rel_path, result
if not results:
from archivebox.plugins.discovery import get_plugin_default_output_path
output_files = result.output_files or {}
for candidate in (plugin_relpath, rel_path):
file_info = output_files.get(candidate)
if not isinstance(file_info, dict):
continue
if file_info.get("root_relative"):
return candidate, result
return rel_path, result
fallbacks = (plugin_relpath,) if get_plugin_default_output_path(plugin) == rel_path else ()
return rel_path, None, fallbacks
return rel_path, result
declared_paths: list[tuple[str, ArchiveResult]] = []
historical_fallbacks: list[str] = []
for result in results:
output_files = result.output_file_map()
for candidate in (plugin_relpath, rel_path):
output_path = result.output_file_path(candidate, output_file_map=output_files)
if output_path and output_path not in {path for path, _result in declared_paths}:
declared_paths.append((output_path, result))
# Partially migrated 0.7 rows can declare ``singlefile.html`` while
# the old file still lives at SNAP_DIR/singlefile.html, without the
# later root_relative metadata bit. Keep that one exact declared key as
# a fallback after the normal path returns 404. Do not preflight either
# path: the static response performs the only happy-path filesystem IO.
if plugin_relpath in output_files and plugin_relpath not in historical_fallbacks:
historical_fallbacks.append(plugin_relpath)
if declared_paths:
primary_path, primary_result = declared_paths[0]
fallbacks = tuple(
dict.fromkeys(
path for path in (*[path for path, _result in declared_paths[1:]], *historical_fallbacks) if path != primary_path
),
)
return primary_path, primary_result, fallbacks
return rel_path, results[0], ()
def _plugin_full_preview_response(
@@ -720,7 +776,7 @@ def _plugin_full_preview_response(
result: ArchiveResult | None,
) -> HttpResponse | None:
"""Render an explicit plugin full template as a trusted preview wrapper."""
if not request.GET.get("preview"):
if not request.GET.get("preview") or request.GET.get("raw") or request.GET.get("files"):
return None
path_parts = Path(rel_path).parts
@@ -728,23 +784,25 @@ def _plugin_full_preview_response(
if not plugin:
return None
# ReplayWeb.page needs plugin-owned WACZ inspection and service-worker
# context, so it remains the one narrow preview exception below.
if plugin == "archivewebpage" and archivewebpage_replay.is_replay_target(rel_path):
return None
template_str = get_plugin_template(plugin, "full", fallback=False)
if not template_str:
return None
if plugin_has_custom_full_response(plugin):
# The plugin owns path selection and rendering for this full template.
# Defer to the file-serving response hook so unrelated outputs from the
# same plugin continue through their normal MIME-specific renderer.
return None
raw_query = request.GET.copy()
raw_query.pop("preview", None)
raw_query.pop("titlebar", None)
output_url = request.path
if raw_query:
output_url = f"{output_url}?{raw_query.urlencode()}"
catalog = get_plugin_catalog()
rendered = (
template.Engine(debug=False)
template.Engine(debug=False, dirs=[catalog[plugin].path] if plugin in catalog else [])
.from_string(template_str)
.render(
template.Context(
@@ -753,18 +811,40 @@ def _plugin_full_preview_response(
"snapshot": snapshot,
"output_path": output_url,
"output_path_raw": rel_path,
"snapshot_details_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
"plugin": plugin,
"plugin_icon": get_plugin_icon(plugin),
"preview_base": f"{request.path.rsplit('/', 1)[0]}/",
},
),
)
)
if request.GET.get("titlebar") == "0":
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE
# The card already has a result header and actions. Only its embedded
# trusted full viewer drops the duplicate top bar; opening the same
# output directly keeps the complete viewer and controls.
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "<style>body > header { display: none !important; }</style></head>", 1)
response = HttpResponse(rendered, content_type="text/html; charset=utf-8")
response.headers["Content-Disposition"] = f'inline; filename="{Path(rel_path).stem}.html"'
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-ArchiveBox-Security-Mode"] = request.archivebox_config.SERVER_SECURITY_MODE
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Content-Security-Policy"] = (
# Full plugin viewers are trusted code, but must fetch saved data from the
# isolated snapshot origin. The outer collection UI stays on web.*.
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
return response
def _trusted_snapshot_frame_csp(request: HttpRequest) -> str:
# A trusted plugin frame on snap-* may be embedded by web.* or admin.*.
# Archived HTML/JS/CSS is still served only inside that snapshot origin;
# neither this CSP nor the frame grants it admin cookies or cross-snapshot
# reads. In one-domain no-JS mode the hosts coincide, but raw replay scripts
# remain blocked by the separate replay response policy.
viewer_origins = " ".join(dict.fromkeys((build_web_url(request=request), build_admin_url(request=request))))
return (
"default-src 'self' data: blob:; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
"style-src 'unsafe-inline' data: blob: 'self'; "
@@ -777,8 +857,49 @@ def _plugin_full_preview_response(
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
"frame-ancestors 'self';"
f"frame-ancestors 'self' {viewer_origins};"
)
def _plugin_card_document_response(request: HttpRequest, snapshot: Snapshot, result_id: str) -> HttpResponse:
"""Serve the existing card template from the snapshot origin, not web.*.
srcdoc and inline card scripts inherit the containing page's origin. This
frame route gives them same-origin access to this snapshot's saved files
without opening CORS on raw archives or sharing the admin session cookie.
The parent web page receives only the one-bit login hint; delete buttons
navigate to admin.* for a real authenticated confirmation.
"""
try:
result_pk = UUID(result_id)
except (TypeError, ValueError):
raise Http404 from None
result = snapshot.archiveresult_set.filter(pk=result_pk).first()
if result is None:
raise Http404
# There is exactly one plugin card template. The stack cover and expanded
# tray point at this same document; only their containing card width differs.
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE, render_plugin_card_document
fragment = render_plugin_card_document(
template.Context({"request": request, "CONFIG": get_request_config(request, resolve_plugins=False)}),
result,
)
if str(fragment).lstrip().lower().startswith("<!doctype html"):
card_html = str(fragment)
else:
card_html = (
'<!doctype html><html><head><meta charset="utf-8"><style>'
"html,body{margin:0;width:100%;height:100%;overflow:hidden}"
"body>iframe{display:block;width:100%;height:100%;border:0}"
f"</style>{CARD_OVERFLOW_STYLE}</head><body>"
f"{fragment}</body></html>"
)
response = HttpResponse(card_html, content_type="text/html; charset=utf-8")
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
return response
@@ -885,7 +1006,7 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind
return None
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, path: str = ""):
rel_path = path or ""
request_config = get_request_config(
request,
@@ -893,10 +1014,11 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
)
request.archivebox_config = request_config
request.archivebox_snapshot_url = snapshot.url
request.archivebox_cache_policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
snapshot._runtime_config = request_config
if rel_path.startswith("replay/") or rel_path == "replay":
response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse)
response = serve_plugin_replay_asset(rel_path, request_config, HttpResponse)
if response is not None:
return response
@@ -905,11 +1027,26 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
# /progress.json on through to the same view used everywhere else. The caller
# passes snapshot_id explicitly in the query string — we don't read it from the
# subdomain (this keeps the endpoint identical across all security modes).
return live_progress_view(request)
return live_progress_view(request, authorized_snapshot=snapshot)
if rel_path.startswith("_card/"):
# SnapshotHostView/SnapshotReplayView have already authorized this
# snapshot. Look up the result through its relation so a card ID from
# another isolated snapshot can never render here.
return _plugin_card_document_response(request, snapshot, rel_path.removeprefix("_card/"))
is_directory_request = bool(path) and path.endswith("/")
show_indexes = bool(request.GET.get("files")) or (request_config.USES_SUBDOMAIN_ROUTING and is_directory_request)
if not show_indexes and (not rel_path or rel_path == "index.html"):
if request_config.CONTROL_PLANE_ENABLED and snapshot.permissions in (PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED):
# Snapshot detail is trusted application UI and belongs on web.*.
# snap-* is for isolated saved outputs and plugin frames. Keep the
# private replay-cookie path below: its grant is host-only and must
# never be mistaken for an admin session on web.*.
target = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=request_config)
if request.META.get("QUERY_STRING"):
target = f"{target}?{request.META['QUERY_STRING']}"
return redirect(target)
return SnapshotView.render_live_index(request, snapshot)
if not rel_path or rel_path.endswith("/"):
@@ -921,22 +1058,23 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
if rel_path is None:
raise Http404
rel_path, archive_result = _resolve_archiveresult_relpath(snapshot, rel_path)
rel_path, archive_result, fallback_relpaths = _resolve_archiveresult_relpath(snapshot, rel_path)
plugin_preview = _plugin_full_preview_response(request, snapshot, rel_path, archive_result)
if plugin_preview is not None:
return plugin_preview
try:
return serve_static_with_byterange_support(
request,
rel_path,
document_root=snapshot.output_dir,
show_indexes=show_indexes,
is_archive_replay=True,
)
except Http404:
pass
for candidate_path in (rel_path, *fallback_relpaths):
try:
return serve_static_with_byterange_support(
request,
candidate_path,
document_root=snapshot.output_dir,
show_indexes=show_indexes,
is_archive_replay=True,
)
except Http404:
continue
host = urlparse(snapshot.url).hostname or snapshot.domain
responses_root = Path(snapshot.output_dir) / "responses" / host
@@ -948,6 +1086,22 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
raise Http404
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
response = _build_snapshot_replay_response(request, snapshot, path)
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
return response
if path != "progress.json" and response.status_code in (200, 206, 304):
if not response.get("Cache-Control"):
policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
response.headers["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300"
if snapshot.permissions != PERMISSIONS_PUBLIC:
patch_cache_control(response, private=True)
else:
response.headers["Cache-Control"] = "private, no-store"
patch_vary_headers(response, ("Cookie", "Authorization"))
return response
def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str = ""):
request_config = get_request_config(request, resolve_plugins=False)
request.archivebox_config = request_config
@@ -971,6 +1125,8 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
root_match = (match[0], match[1]) if match else _latest_responses_root(snapshots, domain)
responses_root = root_match[1] if root_match else None
if root_match:
request.archivebox_cache_policy = "public" if root_match[0].permissions == PERMISSIONS_PUBLIC else "private"
if request_config.USES_SUBDOMAIN_ROUTING:
snapshot = root_match[0] if root_match else (snapshots[0] if requested_root_index and snapshots else None)
if snapshot:
@@ -1148,10 +1304,13 @@ class PublicIndexView(ListView):
and context["paginator"].count == 0,
)
snapshots = list(context.get("object_list") or ())
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
from archivebox.plugins.output_groups import plugin_output_sizes
all_results_by_snapshot = {str(snapshot.id): [] for snapshot in snapshots}
icons_by_snapshot: dict[str, dict[str, ArchiveResult]] = {str(snapshot.id): {} for snapshot in snapshots}
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
preview_paths_by_snapshot: dict[str, list[tuple[int, int, str]]] = {str(snapshot.id): [] for snapshot in snapshots}
favicon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
list_icon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
progress_by_snapshot: dict[str, dict[str, int]] = {
str(snapshot.id): {
"total": 0,
@@ -1172,78 +1331,47 @@ class PublicIndexView(ListView):
):
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
preview_plugin_order = {
"screenshot": 0,
"chrome_extension_screenshot": 1,
}
preview_candidates = {
"screenshot": ("screenshot.png",),
"chrome_extension_screenshot": ("screenshot-1.png", "screenshot.png"),
}
def result_output_path(result: ArchiveResult, filename: str) -> str | None:
output_files = result.output_files or {}
file_info = output_files.get(filename)
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
return None
if file_info.get("root_relative"):
return filename
return f"{result.plugin}/{filename}"
for snapshot_id, plugin, status in (
ArchiveResult.objects.filter(
snapshot_id__in=icons_by_snapshot.keys(),
)
results = (
ArchiveResult.objects.filter(snapshot_id__in=icons_by_snapshot.keys())
.exclude(plugin="")
.values_list("snapshot_id", "plugin", "status")
.iterator(chunk_size=1000)
):
snapshot_key = str(snapshot_id)
.only(
"snapshot_id",
"plugin",
"status",
"output_str",
"output_files",
"output_size",
)
)
for result in results.iterator(chunk_size=1000):
snapshot_key = str(result.snapshot_id)
all_results_by_snapshot[snapshot_key].append(result)
progress = progress_by_snapshot[snapshot_key]
progress["total"] += 1
if status == ArchiveResult.StatusChoices.SUCCEEDED:
icons_by_snapshot[snapshot_key].add(plugin)
if result.status == ArchiveResult.StatusChoices.SUCCEEDED:
icons_by_snapshot[snapshot_key].setdefault(result.plugin, result)
progress["succeeded"] += 1
elif status == ArchiveResult.StatusChoices.FAILED:
elif result.status == ArchiveResult.StatusChoices.FAILED:
progress["failed"] += 1
elif status == ArchiveResult.StatusChoices.STARTED:
elif result.status == ArchiveResult.StatusChoices.STARTED:
progress["running"] += 1
elif status == ArchiveResult.StatusChoices.SKIPPED:
elif result.status == ArchiveResult.StatusChoices.SKIPPED:
progress["skipped"] += 1
elif status == ArchiveResult.StatusChoices.NORESULTS:
elif result.status == ArchiveResult.StatusChoices.NORESULTS:
progress["noresults"] += 1
for result in (
ArchiveResult.objects.filter(
snapshot_id__in=icons_by_snapshot.keys(),
status=ArchiveResult.StatusChoices.SUCCEEDED,
plugin__in=(*preview_candidates, "favicon"),
)
.only("snapshot_id", "plugin", "output_files")
.iterator(chunk_size=1000)
):
snapshot_key = str(result.snapshot_id)
if result.plugin in preview_candidates:
plugin_rank = preview_plugin_order[result.plugin]
for filename_rank, filename in enumerate(preview_candidates[result.plugin]):
output_path = result_output_path(result, filename)
if output_path:
preview_paths_by_snapshot[snapshot_key].append((plugin_rank, filename_rank, output_path))
elif result.plugin == "favicon":
output_path = result_output_path(result, "favicon.ico")
if output_path:
favicon_paths_by_snapshot[snapshot_key].append(output_path)
if result.plugin in list_icon_plugins and result.status == ArchiveResult.StatusChoices.SUCCEEDED:
if output_path := result.embed_path():
list_icon_paths_by_snapshot[snapshot_key].append(output_path)
for snapshot in snapshots:
snapshot._icons_compact = True
snapshot._icons_output_sizes = plugin_output_sizes(all_results_by_snapshot[str(snapshot.id)])
snapshot._icons_archive_results = icons_by_snapshot.get(str(snapshot.id), set())
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
snapshot._public_preview_paths = [
output_path for _plugin_rank, _filename_rank, output_path in sorted(preview_paths_by_snapshot.get(str(snapshot.id), []))
]
snapshot._public_favicon_paths = favicon_paths_by_snapshot.get(str(snapshot.id), [])
snapshot._snapshot_card_results = all_results_by_snapshot[str(snapshot.id)]
snapshot._public_list_icon_paths = list_icon_paths_by_snapshot.get(str(snapshot.id), [])
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
context["object_list"] = snapshots
return context
@@ -1533,7 +1661,7 @@ class WebAddView(AddView):
if requested_url:
snapshot = self._latest_snapshot_for_url(requested_url)
if snapshot:
return redirect(f"/{snapshot.url_path}")
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
request_host = (request.get_host() or "").lower()
request_config = get_request_config(request)
@@ -1573,7 +1701,7 @@ class WebAddView(AddView):
snapshot = self._latest_snapshot_for_url(requested_url)
if snapshot:
return redirect(f"/{snapshot.url_path}")
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
add_url = self._normalize_add_url(requested_url)
assert self.form_class is not None
@@ -1605,7 +1733,7 @@ class WebAddView(AddView):
crawl = self._create_crawl_from_form(form)
snapshot = Snapshot.from_json({"url": add_url, "tags": form.cleaned_data.get("tag", "")}, overrides={"crawl": crawl})
assert snapshot is not None
return redirect(f"/{snapshot.url_path}")
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
class HealthCheckView(View):
+55 -12
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlencode, urlparse
from django import forms
from django.contrib import admin, messages
from django.core.paginator import Paginator
from django.db.models import Count, F, Q
from django.db.models import Count, F, Prefetch, Q
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
@@ -30,9 +30,11 @@ from archivebox.core.permissions import (
PERMISSIONS_VALUES,
normalize_permissions,
)
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.plugins.discovery import get_snapshot_role_names
from archivebox.progressmonitor.views import progress_endpoint
from archivebox.workers.models import RETRY_AT_MAX
@@ -72,13 +74,32 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
# Keep ArchiveResult counters as scalar subqueries so the paginated
# Snapshot queryset does not become a join+GROUP BY over every result row.
snapshots_qs = filtered_qs.order_by("-created_at").annotate(
total_results=ArchiveResult.snapshot_count_expr(),
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
snapshot_permissions=F("permissions"),
snapshots_qs = (
filtered_qs.order_by("-created_at")
.annotate(
total_results=ArchiveResult.snapshot_count_expr(),
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
snapshot_permissions=F("permissions"),
)
.prefetch_related(
Prefetch(
"archiveresult_set",
queryset=ArchiveResult.objects.filter(
plugin__in=get_snapshot_role_names("list_icon"),
status=ArchiveResult.StatusChoices.SUCCEEDED,
).only(
"snapshot_id",
"plugin",
"status",
"output_str",
"output_files",
),
to_attr="_list_icon_results",
),
)
)
page_number = request.GET.get(page_param, 1) if request is not None else 1
@@ -219,6 +240,14 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
# Format date
date_str = snapshot.created_at.strftime("%Y-%m-%d %H:%M") if snapshot.created_at else "-"
detail_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
favicon_url = build_snapshot_role_output_url(
snapshot,
"list_icon",
request=request,
archive_results=snapshot._list_icon_results,
fallback_to_default=True,
)
rows.append(f'''
<tr style="border-bottom: 1px solid #eee;">
@@ -231,8 +260,8 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
<span title="{permissions}" style="display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:999px; font-size:12px; color:{permission_fg}; background:{permission_bg};">{permission_icon}</span>
</td>
<td style="padding: 6px 8px; white-space: nowrap;">
<a href="/{snapshot.archive_path}/" style="text-decoration: none;">
<img src="/{snapshot.archive_path}/favicon.ico"
<a href="{escape(detail_url)}" style="text-decoration: none;">
<img src="{escape(favicon_url)}"
style="width: 16px; height: 16px; vertical-align: middle; margin-right: 4px;"
onerror="this.style.display='none'"/>
</a>
@@ -1230,7 +1259,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
(
"Configuration",
{
"fields": ("schedule", "template"),
"fields": ("is_enabled", "schedule", "template"),
"classes": ("card",),
},
),
@@ -1276,7 +1305,21 @@ class CrawlScheduleAdmin(BaseModelAdmin):
def change_view(self, request, object_id, form_url="", extra_context=None):
self.request = request
return super().change_view(request, object_id, form_url, extra_context)
from django.db import connections, router
connection = connections[router.db_for_write(self.model)]
if request.method != "POST" or connection.vendor != "sqlite":
return super().change_view(request, object_id, form_url, extra_context)
# Reserve the writer before Django's atomic admin form reads. A
# deferred read transaction cannot safely upgrade while the runner
# writes, even when SQLite's existing busy timeout has time left.
connection.ensure_connection()
previous_mode = connection.transaction_mode
connection.transaction_mode = "IMMEDIATE"
try:
return super().change_view(request, object_id, form_url, extra_context)
finally:
connection.transaction_mode = previous_mode
def add_view(self, request, form_url="", extra_context=None):
return redirect("/add/#schedule")
-8
View File
@@ -5,11 +5,3 @@ class CrawlsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "archivebox.crawls"
label = "crawls"
def ready(self):
"""Import models to register state machines with the registry"""
import sys
# Skip during makemigrations to avoid premature state machine access
if "makemigrations" not in sys.argv:
from archivebox.crawls.models import CrawlMachine # noqa: F401
+116 -450
View File
@@ -19,7 +19,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.conf import settings
from django.urls import reverse_lazy
from django.utils import timezone
from statemachine import State, registry
from archivebox.config.common import rprint as print
from archivebox.core.permissions import PERMISSIONS_VALUES, normalize_permissions
@@ -32,7 +31,7 @@ from archivebox.base_models.models import (
ModelWithHealthStats,
get_or_create_system_user_pk,
)
from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
from archivebox.workers.models import ModelWithQueue
from archivebox.crawls.schedule_util import next_run_for_schedule, validate_schedule
from archivebox.misc.util import parse_date, sanitize_html_text, validate_url, validate_url_length
@@ -91,6 +90,8 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
@property
def last_run_at(self):
if self.kind == "update":
return self.modified_at
latest_crawl = self.crawl_set.order_by("-created_at").first()
if latest_crawl:
return latest_crawl.created_at
@@ -106,6 +107,22 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
now = now or timezone.now()
return self.is_enabled and self.next_run_at <= now
@property
def kind(self) -> str:
return str((self.config or {}).get("SCHEDULE_KIND") or "crawl")
def dispatch(self, queued_at=None) -> "Crawl | None":
"""Run maintenance directly or enqueue one ordinary Crawl."""
queued_at = queued_at or timezone.now()
if self.kind == "update":
from archivebox.cli.archivebox_update import run_scheduled_maintenance
run_scheduled_maintenance()
type(self).objects.filter(pk=self.pk).update(modified_at=queued_at)
self.modified_at = queued_at
return None
return self.enqueue(queued_at=queued_at)
def enqueue(self, queued_at=None) -> "Crawl":
from archivebox.config.common import build_crawl_config_snapshot
@@ -113,10 +130,11 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
template = self.template
label = template.label or self.label
persona = template.persona if template.persona_id else None
crawl_config = {key: value for key, value in (self.config or {}).items() if key != "SCHEDULE_KIND"}
return Crawl.objects.create(
urls=template.urls,
config=build_crawl_config_snapshot(persona=persona, overrides=self.config or {}),
config=build_crawl_config_snapshot(persona=persona, overrides=crawl_config),
max_depth=template.max_depth,
tags_str=template.tags_str,
persona_id=template.persona_id,
@@ -129,7 +147,7 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
)
class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithStateMachine):
class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithHealthStats, ModelWithQueue):
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False)
@@ -158,16 +176,19 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
notes = models.TextField(blank=True, null=False, default="")
schedule = models.ForeignKey(CrawlSchedule, on_delete=models.SET_NULL, null=True, blank=True, editable=True)
status = ModelWithStateMachine.StatusField(
choices=ModelWithStateMachine.StatusChoices,
default=ModelWithStateMachine.StatusChoices.QUEUED,
status = ModelWithQueue.StatusField(
choices=ModelWithQueue.StatusChoices,
default=ModelWithQueue.StatusChoices.QUEUED,
)
retry_at = ModelWithStateMachine.RetryAtField(default=timezone.now)
retry_at = ModelWithQueue.RetryAtField(default=timezone.now)
state_machine_name = "archivebox.crawls.models.CrawlMachine"
retry_at_field_name = "retry_at"
state_field_name = "status"
StatusChoices = ModelWithStateMachine.StatusChoices
StatusChoices = ModelWithQueue.StatusChoices
INITIAL_STATE = StatusChoices.QUEUED
ACTIVE_STATE = StatusChoices.STARTED
FINAL_STATES = (StatusChoices.SEALED,)
FINAL_OR_ACTIVE_STATES = (*FINAL_STATES, ACTIVE_STATE)
active_state = StatusChoices.STARTED
delete_after_final_statuses = (StatusChoices.SEALED,)
RUNNABLE_STATES = (StatusChoices.QUEUED, StatusChoices.STARTED)
@@ -177,17 +198,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
snapshot_set: models.Manager["Snapshot"]
if TYPE_CHECKING:
@property
def sm(self) -> "CrawlMachine": ...
class Meta(
ModelWithDeleteAfter.Meta,
ModelWithOutputDir.Meta,
ModelWithConfig.Meta,
ModelWithHealthStats.Meta,
ModelWithStateMachine.Meta,
ModelWithQueue.Meta,
):
app_label = "crawls"
verbose_name = "Crawl"
@@ -220,7 +236,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
def resume(self, *, when=None, save: bool = True) -> bool:
resumed = super().resume(when=when, save=save)
if resumed and self.pk:
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.models import Snapshot
resume_at = when or timezone.now()
active_snapshots = self.snapshot_set.filter(
@@ -231,7 +247,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
retry_at=resume_at,
modified_at=timezone.now(),
)
ArchiveResult.resume_queryset(ArchiveResult.objects.filter(snapshot__crawl=self), when=resume_at)
return resumed
def cancel(self) -> None:
@@ -255,7 +270,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
now = timezone.now()
# Cancellation seals the Crawl first, then lets the runner seal each
# child Snapshot through its own state machine. Active children that
# child Snapshot through its own lifecycle. Active children that
# are already due need no write; the runner will claim them as-is.
active_children = self.snapshot_set.filter(
status__in=Snapshot.OPEN_STATES,
@@ -272,8 +287,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
now = timezone.now()
# Parent pause is a scheduler command. Wake child rows only; each
# Snapshot runner claim performs the real pause transition and cascades
# its own ArchiveResults, keeping request/admin transactions tiny.
# Snapshot runner claim performs the real pause transition, keeping
# request/admin transactions tiny.
active_children = self.snapshot_set.filter(
status__in=Snapshot.RUNNABLE_STATES,
)
@@ -452,6 +467,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
"tags_str": self.tags_str,
"label": self.label,
"created_at": self.created_at.isoformat() if self.created_at else None,
"modified_at": self.modified_at.isoformat() if self.modified_at else None,
"created_by": self.created_by.username,
}
@staticmethod
@@ -518,18 +535,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return []
return [url for _raw_line, url in self._iter_url_lines() if url]
def has_internal_input_root(self) -> bool:
"""Return True when Crawl.urls is preserved source text, not the work queue.
The runner creates a synthetic root snapshot for raw import text so
parser hooks use the same Snapshot lifecycle as every other extractor.
In that mode the submitted text must remain in Crawl.urls forever;
parsed URLs live as child Snapshot rows and should not be appended back.
"""
from archivebox.core.models import Snapshot
return self.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL, depth=0).exists()
@staticmethod
def normalize_domain(value: str) -> str:
candidate = (value or "").strip().lower()
@@ -799,15 +804,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
"deleted_snapshots": filter_result["deleted_snapshots"],
}
def get_system_task(self) -> str | None:
urls = self.get_urls_list()
if len(urls) != 1:
return None
system_url = urls[0].strip().lower()
if system_url.startswith("archivebox://"):
return system_url
return None
def resolve_persona(self):
from archivebox.personas.models import Persona
@@ -850,24 +846,31 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
output_dir: Path | None = None,
num_snapshots: int | None = None,
) -> str:
from abx_dl.limits import CrawlLimitState
from abx_dl.limits import parse_filesize_to_bytes
if output_dir is None:
output_dir = self.output_dir
if config is None:
from archivebox.config.common import get_config
config = get_config(crawl=self, include_machine=False).for_crawl_runtime(
crawl=self,
persona=self.resolve_persona(),
crawl_output_dir=output_dir,
)
config = get_config(crawl=self, include_machine=False)
limits_path = output_dir / ".abx-dl" / "limits.json"
if limits_path.exists():
stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
if stop_reason:
return stop_reason
# The DB is the shared authority for workers and the UI. A second
# ledger beside the artifacts can disagree after cancellation/retry and
# would require filesystem access just to render a crawl's status.
# Recompute against the current limits so increasing a cap permits work.
timeout = int(self._config_value(config, "CRAWL_TIMEOUT", 0) or 0)
if timeout:
# Snapshot creation already supplies a durable crawl clock. Using
# that existing fact avoids another timestamp to keep in sync and
# survives cancellation, which deliberately removes unfinished
# ArchiveResult rows while preserving the Snapshot for retry.
first_snapshot_time = self.snapshot_set.aggregate(first=models.Min("created_at"))["first"]
if first_snapshot_time and (timezone.now() - first_snapshot_time).total_seconds() >= timeout:
return "crawl_timeout"
max_size = parse_filesize_to_bytes(self._config_value(config, "CRAWL_MAX_SIZE", 0) or 0)
if max_size:
total_size = self.snapshot_set.aggregate(total=models.Sum("output_size"))["total"] or 0
if total_size >= max_size:
return "crawl_max_size"
max_urls = int(self._config_value(config, "CRAWL_MAX_URLS", 0) or 0)
if num_snapshots is None:
@@ -971,14 +974,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
if self.status == self.StatusChoices.SEALED:
return []
# Internal-input crawls preserve the submitted text verbatim in
# Crawl.urls. The root snapshot's parser hooks are the only supported
# path for turning that text into child snapshots, otherwise a later
# runner pass could reinterpret plain URL-looking lines as direct
# depth-0 work and bypass format-specific metadata parsing.
if self.has_internal_input_root():
return []
created_snapshots = []
crawl_tag_names = self.current_tag_names()
tags_by_name: dict[str, Tag] = {}
@@ -1194,7 +1189,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
),
}
if tag_names:
tag_ids = [Tag.objects.get_or_create(name=tag_name)[0].pk for tag_name in tag_names]
tag_ids = [Tag.get_or_create_by_name(tag_name)[0].pk for tag_name in tag_names]
snapshot.add_tag_ids(tag_ids)
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
@@ -1251,16 +1246,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
if not created_snapshots:
return []
crawl_urls = {url for _raw_line, url in self._iter_url_lines() if url}
new_url_lines = [snapshot.url for snapshot in created_snapshots if snapshot.url not in crawl_urls]
# For internal-input crawls, Crawl.urls is the immutable source text.
# Child snapshots are the parsed/indexed representation, so appending
# discovered URLs here would both duplicate state and destroy the exact
# import artifact users submitted through CLI/API/UI.
if new_url_lines and not self.has_internal_input_root():
self.urls = (self.urls.rstrip() + "\n" + "\n".join(new_url_lines)).lstrip("\n")
self.save(update_fields=["urls", "modified_at"])
tag_names_by_url: dict[str, set[str]] = {}
for snapshot in created_snapshots:
tag_names = {
@@ -1272,10 +1257,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
}
if tag_names:
tag_names_by_url[snapshot.url] = tag_names
# Snapshot.save() handles model-level validation. The crawl symlink
# can still wait until after commit so SQLite does not hold a write
# lock while touching the filesystem.
transaction.on_commit(lambda snapshot=snapshot: snapshot.ensure_crawl_symlink())
tag_names = {tag for tags in tag_names_by_url.values() for tag in tags}
if tag_names:
@@ -1296,170 +1277,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return created_snapshots
def install_declared_binaries(self, binary_names: set[str], machine=None) -> None:
"""Install crawl-declared binaries through their unified state machine."""
from archivebox.crawls.locks import binary_lifecycle_lock
from archivebox.machine.models import Binary, Machine
if not binary_names:
return
machine = machine or Machine.current()
binaries = Binary.objects.filter(machine=machine, name__in=binary_names).order_by("name")
for binary in binaries:
with binary_lifecycle_lock(str(binary.id)):
binary.refresh_from_db()
if binary.status == Binary.StatusChoices.INSTALLED:
continue
binary.update_and_requeue(retry_at=timezone.now())
binary.refresh_from_db()
binary.tick_claimed(lock_seconds=600)
unresolved_binaries = list(
Binary.objects.filter(
machine=machine,
name__in=binary_names,
)
.exclude(
status=Binary.StatusChoices.INSTALLED,
)
.order_by("name"),
)
if unresolved_binaries:
binary_details = ", ".join(
f"{binary.name} (status={binary.status}, retry_at={binary.retry_at})" for binary in unresolved_binaries
)
raise RuntimeError(
f"Crawl dependencies failed to install before continuing: {binary_details}",
)
def run(self) -> "Snapshot | None":
"""
Execute this Crawl: run hooks, process JSONL, create snapshots.
Called by the state machine when entering the 'started' state.
Returns:
The root Snapshot for this crawl, or None for system crawls that don't create snapshots
"""
import time
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records
from archivebox.config.common import get_config
from archivebox.machine.models import Machine
def get_runtime_config():
return get_config(crawl=self).for_crawl_runtime(
crawl=self,
persona=persona,
runtime_overrides=persona_runtime_overrides,
)
system_task = self.get_system_task()
if system_task == "archivebox://update":
from archivebox.cli.archivebox_update import process_all_db_snapshots
process_all_db_snapshots()
return None
machine = Machine.current()
declared_binary_names: set[str] = set()
persona_runtime_overrides: dict[str, str] = {}
persona = self.resolve_persona()
if persona:
base_runtime_config = get_config(crawl=self, persona=persona)
chrome_binary = str(base_runtime_config.get("CHROME_BINARY") or "")
persona_runtime_overrides = persona.prepare_runtime_for_crawl(
crawl=self,
chrome_binary=chrome_binary,
)
def run_crawl_hook(hook: Path) -> set[str]:
primary_url = next(
(line.strip() for line in self.urls.splitlines() if line.strip()),
self.urls.strip(),
)
hook_start = time.time()
plugin_name = hook.parent.name
output_dir = self.output_dir / plugin_name
output_dir.mkdir(parents=True, exist_ok=True)
process = run_hook(
hook,
output_dir=output_dir,
config=get_runtime_config(),
crawl_id=str(self.id),
source_url=self.urls,
url=primary_url,
snapshot_id=str(self.id),
)
hook_elapsed = time.time() - hook_start
if hook_elapsed > 0.5:
print(f"[yellow]⏱️ Hook {hook.name} took {hook_elapsed:.2f}s[/yellow]")
if process.status == process.StatusChoices.RUNNING:
if process.poll() is None:
return set()
from archivebox.plugins.hooks import extract_records_from_process
records = []
# A hook can exit before its completed Process metadata is visible.
# Give successful hooks a brief chance to flush JSONL stdout into
# the Process row before downstream hooks.
for delay in (0.0, 0.05, 0.1, 0.25, 0.5):
if delay:
time.sleep(delay)
records = extract_records_from_process(process)
if records:
break
if records:
print(f"[cyan]📝 Processing {len(records)} records from {hook.name}[/cyan]")
for record in records[:3]:
print(f" Record: type={record.get('type')}, keys={list(record.keys())[:5]}")
if system_task:
records = [record for record in records if record.get("type") in ("BinaryRequest", "Binary")]
overrides = {"crawl": self}
stats = process_hook_records(records, overrides=overrides)
if stats:
print(f"[green]✓ Created: {stats}[/green]")
hook_binary_names = {
str(record.get("name")).strip()
for record in records
if record.get("type") in ("BinaryRequest", "Binary") and record.get("name")
}
hook_binary_names.discard("")
if hook_binary_names:
declared_binary_names.update(hook_binary_names)
return hook_binary_names
hooks = discover_hooks("Crawl", config=get_runtime_config())
for hook in hooks:
hook_binary_names = run_crawl_hook(hook)
if hook_binary_names:
self.install_declared_binaries(hook_binary_names, machine=machine)
# Safety check: don't create snapshots if any crawl-declared dependency
# is still unresolved after all crawl hooks have run.
self.install_declared_binaries(declared_binary_names, machine=machine)
# Create snapshots from all URLs in self.urls
if system_task:
leaked_snapshots = self.snapshot_set.all()
if leaked_snapshots.exists():
leaked_count = leaked_snapshots.count()
leaked_snapshots.delete()
print(f"[yellow]⚠️ Removed {leaked_count} leaked snapshot(s) created during system crawl {system_task}[/yellow]")
return None
self.create_snapshots_from_urls()
# Return first snapshot for this crawl (newly created or existing)
# This ensures the crawl doesn't seal if snapshots exist, even if they weren't just created
return self.snapshot_set.first()
def is_finished(self) -> bool:
"""Check if crawl is finished (all snapshots sealed or no snapshots exist)."""
from archivebox.core.models import Snapshot
@@ -1483,11 +1300,64 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return True
def cleanup(self):
"""Clean up background hooks and run on_CrawlEnd hooks."""
from archivebox.plugins.hooks import run_hook, discover_hooks
def can_start(self) -> bool:
return bool(self.urls and self.get_urls_list())
# Clean up .pid files from output directory
def has_finished_snapshots(self) -> bool:
from archivebox.core.models import Snapshot
snapshots = self.snapshot_set.all()
return snapshots.exists() and not snapshots.exclude(status=Snapshot.StatusChoices.SEALED).exists()
def mark_started(self) -> bool:
now = timezone.now()
updated = self.safe_update(
{
"status": self.StatusChoices.STARTED,
"retry_at": now + timedelta(seconds=2),
},
extra_filter={"status": self.StatusChoices.QUEUED},
)
return updated
def seal(self) -> bool:
"""Finalize a runner-owned Crawl without dispatching hooks directly."""
now = timezone.now()
updated = self.safe_update(
{
"status": self.StatusChoices.SEALED,
"retry_at": None,
"modified_at": now,
},
refresh=False,
extra_filter={"status__in": (*self.RUNNABLE_STATES, self.StatusChoices.SEALED)},
)
if not updated:
self.refresh_from_db()
return False
self.status = self.StatusChoices.SEALED
self.retry_at = None
self.modified_at = now
self.schedule_child_snapshots_for_sealing()
self.cleanup_runtime()
return True
def advance_lifecycle(self) -> bool:
"""Advance one explicit lifecycle step after the runner claims this row."""
if self.status == self.StatusChoices.PAUSED:
return False
if self.status == self.StatusChoices.QUEUED:
if self.has_finished_snapshots():
return self.seal()
if not self.can_start():
return False
return self.mark_started()
if self.status == self.StatusChoices.STARTED and self.is_finished():
return self.seal()
return False
def cleanup_runtime(self) -> None:
"""Remove runner-owned runtime artifacts after abx-dl cleanup hooks finish."""
if self.output_dir.exists():
for pid_file in self.output_dir.glob("**/*.pid"):
pid_file.unlink(missing_ok=True)
@@ -1495,207 +1365,3 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
persona = self.resolve_persona()
if persona:
persona.cleanup_runtime_for_crawl(self)
# Run on_CrawlEnd hooks
from archivebox.config.common import get_config
config = get_config(crawl=self)
hooks = discover_hooks("CrawlEnd", config=config)
for hook in hooks:
plugin_name = hook.parent.name
output_dir = self.output_dir / plugin_name
output_dir.mkdir(parents=True, exist_ok=True)
process = run_hook(
hook,
output_dir=output_dir,
config=config,
crawl_id=str(self.id),
source_url=self.urls, # Pass full newline-separated URLs
)
# Log failures but don't block
if process.exit_code != 0:
print(f"[yellow]⚠️ CrawlEnd hook failed: {hook.name}[/yellow]")
# =============================================================================
# State Machines
# =============================================================================
class CrawlMachine(BaseStateMachine):
crawl: Crawl
"""
State machine for managing Crawl lifecycle.
Hook Lifecycle:
┌─────────────────────────────────────────────────────────────┐
│ QUEUED State │
│ • Waiting for crawl to be ready (has URLs) │
└─────────────────────────────────────────────────────────────┘
↓ tick() when can_start()
┌─────────────────────────────────────────────────────────────┐
│ STARTED State → enter_started() │
│ 1. crawl.run() │
│ • discover_hooks('Crawl') → finds all crawl hooks │
│ • For each hook: │
│ - run_hook(script, output_dir, ...) │
│ - Parse JSONL from hook output │
│ - process_hook_records() → creates Snapshots │
│ • create_snapshots_from_urls() → from self.urls field │
│ │
│ 2. Snapshots process independently with their own │
│ state machines (see SnapshotMachine) │
└─────────────────────────────────────────────────────────────┘
↓ tick() when is_finished()
┌─────────────────────────────────────────────────────────────┐
│ SEALED State → enter_sealed() │
│ • cleanup() → runs on_CrawlEnd hooks, kills background │
│ • Set retry_at=None (no more processing) │
└─────────────────────────────────────────────────────────────┘
"""
model_attr_name = "crawl"
# States
queued = State(value=Crawl.StatusChoices.QUEUED, initial=True)
started = State(value=Crawl.StatusChoices.STARTED)
paused = State(value=Crawl.StatusChoices.PAUSED)
sealed = State(value=Crawl.StatusChoices.SEALED, final=True)
# Tick Event (polled by workers)
tick = (
queued.to(sealed, cond="has_finished_snapshots")
| queued.to.itself(unless="can_start")
| queued.to(started, cond="can_start")
| started.to(sealed, cond="is_finished")
| paused.to.itself()
)
# Manual event (triggered by last Snapshot sealing, or by direct
# index-only/bg creation when every requested URL is rejected before any
# Snapshot rows exist).
seal = queued.to(sealed) | started.to(sealed) | paused.to(sealed)
pause_requested = queued.to(paused) | started.to(paused)
resume_requested = paused.to(queued)
def can_start(self) -> bool:
if not self.crawl.urls:
print(f"[red]⚠️ Crawl {self.crawl.id} cannot start: no URLs[/red]")
return False
urls_list = self.crawl.get_urls_list()
if not urls_list:
print(f"[red]⚠️ Crawl {self.crawl.id} cannot start: no valid URLs in urls field[/red]")
return False
return True
def is_finished(self) -> bool:
"""Check if all Snapshots for this crawl are finished."""
return self.crawl.is_finished()
def has_finished_snapshots(self) -> bool:
"""A queued crawl with only final Snapshot rows was interrupted before sealing."""
from archivebox.core.models import Snapshot
snapshots = self.crawl.snapshot_set.all()
return snapshots.exists() and not snapshots.exclude(status=Snapshot.StatusChoices.SEALED).exists()
@queued.enter
def enter_queued(self):
self.crawl.update_and_requeue(
retry_at=timezone.now(),
status=Crawl.StatusChoices.QUEUED,
)
@started.enter
def enter_started(self):
import sys
print(f"[cyan]🔄 CrawlMachine.enter_started() - creating snapshots for {self.crawl.id}[/cyan]", file=sys.stderr)
try:
# Run the crawl - runs hooks, processes JSONL, creates snapshots
first_snapshot = self.crawl.run()
if first_snapshot:
print(
f"[cyan]🔄 Created {self.crawl.snapshot_set.count()} snapshot(s), first: {first_snapshot.url}[/cyan]",
file=sys.stderr,
)
# Update status to STARTED
# Set retry_at to near future so tick() can poll and check is_finished()
self.crawl.update_and_requeue(
retry_at=timezone.now() + timedelta(seconds=2),
status=Crawl.StatusChoices.STARTED,
)
else:
# No snapshots (system crawl that only runs setup hooks)
print("[cyan]🔄 No snapshots created, sealing crawl immediately[/cyan]", file=sys.stderr)
# Seal immediately since there's no work to do
self.seal()
except Exception as e:
print(f"[red]⚠️ Crawl {self.crawl.id} failed to start: {e}[/red]")
import traceback
traceback.print_exc()
raise
@paused.enter
def enter_paused(self):
paused = self.crawl.safe_update(
{
"retry_at": RETRY_AT_MAX,
"status": Crawl.StatusChoices.PAUSED,
},
extra_filter={"status__in": Crawl.RUNNABLE_STATES},
)
if paused:
self.crawl.schedule_child_snapshots_for_pause()
@sealed.enter
def enter_sealed(self):
now = timezone.now()
self.crawl.status = Crawl.StatusChoices.SEALED
self.crawl.retry_at = None
# Guard: never seal a row that a concurrent writer flipped to PAUSED.
# Sealing is idempotent (SEALED→SEALED is a no-op rewrite), so
# status__in covers both the QUEUED/STARTED→SEALED transition and the
# rare re-entry case.
updated = self.crawl.safe_update(
{
"status": Crawl.StatusChoices.SEALED,
"retry_at": None,
"modified_at": now,
},
refresh=False,
extra_filter={
"status__in": [
Crawl.StatusChoices.QUEUED,
Crawl.StatusChoices.STARTED,
Crawl.StatusChoices.SEALED,
],
},
)
if not updated:
self.crawl.refresh_from_db()
return
self.crawl.modified_at = now
self.crawl.schedule_child_snapshots_for_sealing()
# Clean up background hooks and run on_CrawlEnd hooks after the final
# state is visible so cleanup projectors cannot resurrect the crawl.
self.crawl.cleanup()
# =============================================================================
# Register State Machines
# =============================================================================
# Manually register state machines with python-statemachine registry
# (normally auto-discovered from statemachines.py, but we define them here for clarity)
registry.register(CrawlMachine)
-8
View File
@@ -10,14 +10,6 @@ class MachineConfig(AppConfig):
label = "machine" # Explicit label for migrations
verbose_name = "Machine Info"
def ready(self):
"""Import models to register state machines with the registry"""
import sys
# Skip during makemigrations to avoid premature state machine access
if "makemigrations" not in sys.argv:
from archivebox.machine import models # noqa: F401
def register_admin(admin_site):
from archivebox.machine.admin import register_admin
+8 -3
View File
@@ -57,7 +57,10 @@ def get_vm_info():
hw_manufacturer = "Docker" if hw_in_docker else "Unknown"
hw_product = "Container" if hw_in_docker else "Unknown"
hw_uuid = machineid.id()
try:
hw_uuid = machineid.id()
except machineid.MachineIdNotFound:
hw_uuid = "Unknown"
if platform.system().lower() == "darwin":
# Get macOS machine info
@@ -88,7 +91,7 @@ def get_vm_info():
try:
# Getting SMBIOS data from sysfs.
# SMBIOS 2.8 present.
# argo-1 | 2024-10-01T10:40:51Z ERR error="Incoming request ended abruptly: context canceled" connIndex=2 event=1 ingressRule=0 originService=http://archivebox:8000 │
# argo-1 | 2024-10-01T10:40:51Z ERR error="Incoming request ended abruptly: context canceled" connIndex=2 event=1 ingressRule=0 originService=http://archivebox:5797 │
# Handle 0x0100, DMI type 1, 27 bytes
# System Information
# Manufacturer: DigitalOcean
@@ -301,7 +304,9 @@ def get_host_stats() -> dict[str, Any]:
def get_host_guid() -> str:
return machineid.hashed_id("archivebox")
from archivebox.config.paths import get_machine_id
return get_machine_id(length=64)
# Example usage
+83 -240
View File
@@ -12,8 +12,6 @@ from archivebox.uuid_compat import CompactUUIDField, uuid7
from datetime import timedelta, datetime
from typing import TYPE_CHECKING, Any, cast
from statemachine import State, registry
from django.db import IntegrityError, transaction
from django.db import models
from django.db.models import Q, QuerySet
@@ -23,7 +21,7 @@ from django.utils.functional import cached_property
from archivebox.config import CONSTANTS
from archivebox.config.common import rprint
from archivebox.base_models.models import ModelWithDeleteAfter, ModelWithHealthStats, normalize_config_json_values
from archivebox.workers.models import BaseStateMachine, ModelWithStateMachine
from archivebox.workers.models import ModelWithQueue
from .detect import get_host_guid, get_os_info, get_vm_info, get_host_network, get_host_stats
_psutil: Any | None = None
@@ -229,6 +227,9 @@ class Machine(ModelWithHealthStats):
_CURRENT_MACHINE = None
if _CURRENT_MACHINE:
if timezone.now() < _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
# Reconciliation can save() and clear the shared cache; keep
# this call's instance so we never return None after that save.
machine = _CURRENT_MACHINE
# One-time-per-process reconciliation between ArchiveBox.conf
# and Machine.config. Fast-path: bool check + early-return when
# the sync has already run, so the cached-machine return path
@@ -236,16 +237,21 @@ class Machine(ModelWithHealthStats):
try:
from archivebox.config.collection import sync_machine_and_file
sync_machine_and_file(_CURRENT_MACHINE)
sync_machine_and_file(machine)
except Exception:
pass
return _CURRENT_MACHINE
return machine
else:
_CURRENT_MACHINE = None
host_guid = get_host_guid()
# save() intentionally invalidates _CURRENT_MACHINE so config edits
# made through other instances become visible in this process. Host
# refresh (after seven days), sanitation, and reconciliation can all
# save here, so the working instance must not live only in that cache.
# Otherwise a routine refresh leaves startup dereferencing None.
try:
_CURRENT_MACHINE = cls.objects.get(guid=host_guid)
machine = cls.objects.get(guid=host_guid)
except cls.DoesNotExist:
config = {}
try:
@@ -255,7 +261,7 @@ class Machine(ModelWithHealthStats):
config = _coerce_from_str_dict(file_config)
except Exception:
config = {}
_CURRENT_MACHINE = cls.objects.create(
machine = cls.objects.create(
guid=host_guid,
hostname=socket.gethostname(),
config=config,
@@ -264,15 +270,15 @@ class Machine(ModelWithHealthStats):
stats=get_host_stats(),
)
else:
if timezone.now() >= _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
if timezone.now() >= machine.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
for key, value in {
"hostname": socket.gethostname(),
**get_os_info(),
**get_vm_info(),
"stats": get_host_stats(),
}.items():
setattr(_CURRENT_MACHINE, key, value)
_CURRENT_MACHINE.save(
setattr(machine, key, value)
machine.save(
update_fields=[
"hostname",
"hw_in_docker",
@@ -289,16 +295,19 @@ class Machine(ModelWithHealthStats):
"modified_at",
],
)
machine = cls._sanitize_config(_CURRENT_MACHINE)
machine = cls._sanitize_config(machine)
# Same one-time sync as the cached-return path. Triggers here on the
# very first ``Machine.current()`` call in a process before any
# cached return can occur.
try:
from archivebox.config.collection import sync_machine_and_file
sync_machine_and_file(_CURRENT_MACHINE)
sync_machine_and_file(machine)
except Exception:
pass
# Publish only after all save-capable steps; publishing earlier would
# let those steps invalidate the cache we are in the middle of filling.
_CURRENT_MACHINE = machine
return machine
@classmethod
@@ -515,19 +524,21 @@ class BinaryManager(models.Manager):
)
class Binary(ModelWithHealthStats, ModelWithStateMachine):
class Binary(ModelWithHealthStats, ModelWithQueue):
"""
Tracks a binary on a specific machine.
Simple state machine with 2 states:
Simple queue lifecycle with 2 states:
- queued: Binary needs to be installed
- installed: Binary installed successfully (abspath, version, sha256 populated)
Installation is synchronous during queued→installed transition.
If installation fails, Binary stays in queued with retry_at set for later retry.
State machine calls run(), which emits an abxpkg BinaryRequestEvent through
the ArchiveBox runner and installs the binary using the specified providers.
BinaryService claims queued rows with a conditional update, then run()
emits an abxpkg BinaryRequestEvent and persists the resolved installation.
The database row is the only lifecycle state; there is intentionally no
second in-memory state machine to reconcile after a worker interruption.
"""
class StatusChoices(models.TextChoices):
@@ -566,9 +577,9 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
version = models.CharField(max_length=32, default="", null=False, blank=True)
sha256 = models.CharField(max_length=64, default="", null=False, blank=True)
# State machine fields
status = ModelWithStateMachine.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED, max_length=16)
retry_at = ModelWithStateMachine.RetryAtField(
# Durable queue lifecycle fields
status = ModelWithQueue.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED, max_length=16)
retry_at = ModelWithQueue.RetryAtField(
default=timezone.now,
help_text="When to retry this binary installation",
)
@@ -579,18 +590,16 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
machine_id: uuid.UUID
state_machine_name: str | None = "archivebox.machine.models.BinaryMachine"
INITIAL_STATE = StatusChoices.QUEUED
ACTIVE_STATE = StatusChoices.QUEUED
FINAL_STATES = (StatusChoices.INSTALLED,)
FINAL_OR_ACTIVE_STATES = (*FINAL_STATES, ACTIVE_STATE)
active_state: str = StatusChoices.QUEUED
warn_on_save_outside_runner = False
objects = BinaryManager() # pyright: ignore[reportIncompatibleVariableOverride]
if TYPE_CHECKING:
@property
def sm(self) -> BinaryMachine: ...
class Meta(ModelWithHealthStats.Meta, ModelWithStateMachine.Meta):
class Meta(ModelWithHealthStats.Meta, ModelWithQueue.Meta):
app_label = "machine"
verbose_name = "Binary"
verbose_name_plural = "Binaries"
@@ -604,6 +613,10 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
"""A binary is valid if it has a resolved path and is marked installed."""
return bool(self.abspath) and self.status == self.StatusChoices.INSTALLED
@property
def can_install(self) -> bool:
return bool(self.name and self.binproviders)
@cached_property
def binary_info(self) -> dict:
"""Return info about the binary."""
@@ -748,11 +761,43 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
run_binary(str(self.id))
def install(self) -> bool:
"""Run one synchronous installation attempt for a claimed Binary."""
if self.status == self.StatusChoices.INSTALLED:
return True
if not self.can_install:
return False
rprint(f"[cyan] 🔄 installing {self.name}[/cyan]", file=sys.stderr)
self.run()
self.refresh_from_db()
if self.status != self.StatusChoices.INSTALLED:
self.update_and_requeue(
retry_at=timezone.now() + timedelta(seconds=300),
status=self.StatusChoices.QUEUED,
)
self.increment_health_stats(success=False)
raise RuntimeError(f"Binary {self.name} installation failed")
self.update_and_requeue(retry_at=None, status=self.StatusChoices.INSTALLED)
self.increment_health_stats(success=True)
return True
def advance_lifecycle(self) -> bool:
"""Advance the explicit binary lifecycle after its queue row is claimed."""
return self.install()
def install_claimed(self, *, lock_seconds: int = 600) -> bool:
if not self.claim_processing_lock(lock_seconds=lock_seconds):
return False
self.refresh_from_db()
return self.advance_lifecycle()
def cleanup(self):
"""
Clean up background binary installation hooks.
Called by state machine if needed (not typically used for binaries
Called after an installation attempt if needed (not typically used for binaries
since installations are foreground, but included for consistency).
"""
@@ -953,12 +998,14 @@ class Process(ModelWithDeleteAfter, models.Model):
One Process can optionally be associated with an ArchiveResult (via OneToOne),
but Process can also exist standalone for internal operations.
Follows the unified state machine pattern:
Follows the unified process lifecycle:
- queued: Process ready to launch
- running: Process actively executing
- exited: Process completed (check exit_code for success/failure)
State machine calls launch() to spawn the process and monitors its lifecycle.
Direct Process methods and the abx-dl event projector own these transitions.
Keeping the DB row as the only lifecycle state makes interrupted subprocess
recovery observable without reconciling a second in-memory state machine.
"""
class StatusChoices(models.TextChoices):
@@ -1117,7 +1164,7 @@ class Process(ModelWithDeleteAfter, models.Model):
# Reverse relation to ArchiveResult (OneToOne from AR side)
# archiveresult: OneToOneField defined on ArchiveResult model
# State machine fields
# Durable process lifecycle fields
status = models.CharField(
max_length=16,
choices=StatusChoices.choices,
@@ -1138,7 +1185,6 @@ class Process(ModelWithDeleteAfter, models.Model):
children: models.Manager[Process]
archiveresult: ArchiveResult
state_machine_name: str = "archivebox.machine.models.ProcessMachine"
delete_after_final_statuses = (StatusChoices.EXITED,)
objects = ProcessManager() # pyright: ignore[reportIncompatibleVariableOverride]
@@ -1308,8 +1354,8 @@ class Process(ModelWithDeleteAfter, models.Model):
"""
Compare-and-swap update for short Process scheduler writes.
Process is not a ModelWithStateMachine subclass yet, but its
state-machine methods still need the same modified_at CAS behavior as
Process is not a ModelWithQueue subclass, but its scheduler methods
still need the same modified_at CAS behavior as
Crawl/Snapshot/Binary without falling back to save().
"""
values = dict(update_fields)
@@ -2233,8 +2279,8 @@ class Process(ModelWithDeleteAfter, models.Model):
"""
Gracefully terminate process: SIGTERM → wait → SIGKILL.
This consolidates the scattered SIGTERM/SIGKILL logic from:
- crawls/models.py Crawl.cleanup()
This consolidates SIGTERM/SIGKILL logic used by:
- workers/management/commands/runner_watch.py
- workers/pid_utils.py stop_worker()
- supervisord_util.py stop_existing_supervisord_process()
@@ -2297,8 +2343,8 @@ class Process(ModelWithDeleteAfter, models.Model):
Uses parallel polling approach - sends SIGTERM to all processes at once,
then polls all simultaneously with individual deadline tracking.
This consolidates the scattered child-killing logic from:
- crawls/models.py Crawl.cleanup() os.killpg()
This consolidates child-killing logic used by:
- core/takeover_util.py
- supervisord_util.py stop_existing_supervisord_process()
Args:
@@ -2598,206 +2644,3 @@ class Process(ModelWithDeleteAfter, models.Model):
if cleaned:
rprint(f"[yellow]🧹 Cleaned up {cleaned} orphaned worker/hook process record(s)[/yellow]")
return cleaned
# =============================================================================
# Binary State Machine
# =============================================================================
class BinaryMachine(BaseStateMachine):
"""
State machine for managing Binary installation lifecycle.
Simple 2-state machine:
┌─────────────────────────────────────────────────────────────┐
│ QUEUED State │
│ • Binary needs to be installed │
└─────────────────────────────────────────────────────────────┘
↓ tick() when can_install()
↓ Synchronous installation during transition
┌─────────────────────────────────────────────────────────────┐
│ INSTALLED State │
│ • Binary installed (abspath, version, sha256 set) │
│ • Health stats incremented │
└─────────────────────────────────────────────────────────────┘
If installation fails, Binary stays in QUEUED with retry_at bumped.
"""
model_attr_name = "binary"
binary: Binary
# States
queued = State(value=Binary.StatusChoices.QUEUED, initial=True)
installed = State(value=Binary.StatusChoices.INSTALLED, final=True)
# Tick Event - install happens during transition
tick = queued.to.itself(unless="can_install") | queued.to(installed, cond="can_install", on="on_install")
def can_install(self) -> bool:
"""Check if binary installation can start."""
return bool(self.binary.name and self.binary.binproviders)
@queued.enter
def enter_queued(self):
"""Binary is queued for installation."""
self.binary.update_and_requeue(
retry_at=timezone.now(),
status=Binary.StatusChoices.QUEUED,
)
def on_install(self):
"""Called during queued→installed transition. Runs installation synchronously."""
import sys
rprint(f"[cyan] 🔄 BinaryMachine.on_install() - installing {self.binary.name}[/cyan]", file=sys.stderr)
# Run installation hooks (synchronous, updates abspath/version/sha256 and sets status)
self.binary.run()
# Check if installation succeeded by looking at updated status
# Note: Binary.run() updates self.binary.status internally but doesn't refresh our reference
self.binary.refresh_from_db()
if self.binary.status != Binary.StatusChoices.INSTALLED:
# Installation failed - abort transition, stay in queued
rprint(f"[red] ❌ BinaryMachine - {self.binary.name} installation failed, retrying later[/red]", file=sys.stderr)
# Bump retry_at to try again later
self.binary.update_and_requeue(
retry_at=timezone.now() + timedelta(seconds=300), # Retry in 5 minutes
status=Binary.StatusChoices.QUEUED, # Ensure we stay queued
)
# Increment health stats for failure
self.binary.increment_health_stats(success=False)
# Abort the transition - this will raise an exception and keep us in queued
raise Exception(f"Binary {self.binary.name} installation failed")
rprint(f"[cyan] ✅ BinaryMachine - {self.binary.name} installed successfully[/cyan]", file=sys.stderr)
@installed.enter
def enter_installed(self):
"""Binary installed successfully."""
self.binary.update_and_requeue(
retry_at=None,
status=Binary.StatusChoices.INSTALLED,
)
# Increment health stats
self.binary.increment_health_stats(success=True)
# =============================================================================
# Process State Machine
# =============================================================================
class ProcessMachine(BaseStateMachine):
"""
State machine for managing Process (OS subprocess) lifecycle.
Process Lifecycle:
┌─────────────────────────────────────────────────────────────┐
│ QUEUED State │
│ • Process ready to launch, waiting for resources │
└─────────────────────────────────────────────────────────────┘
↓ tick() when can_start()
┌─────────────────────────────────────────────────────────────┐
│ RUNNING State → enter_running() │
│ 1. process.launch() │
│ • Spawn subprocess with cmd, pwd, env, timeout │
│ • Set pid, started_at │
│ • Process runs in background or foreground │
│ 2. Monitor process completion │
│ • Check exit code when process completes │
└─────────────────────────────────────────────────────────────┘
↓ tick() checks is_exited()
┌─────────────────────────────────────────────────────────────┐
│ EXITED State │
│ • Process completed (exit_code set) │
│ • Health stats incremented │
│ • stdout/stderr captured │
└─────────────────────────────────────────────────────────────┘
Note: This is a simpler state machine than ArchiveResult.
Process is just about execution lifecycle. ArchiveResult handles
the archival-specific logic (status, output parsing, etc.).
"""
model_attr_name = "process"
process: Process
# States
queued = State(value=Process.StatusChoices.QUEUED, initial=True)
running = State(value=Process.StatusChoices.RUNNING)
exited = State(value=Process.StatusChoices.EXITED, final=True)
# Tick Event - transitions based on conditions
tick = (
queued.to.itself(unless="can_start")
| queued.to(running, cond="can_start")
| running.to.itself(unless="is_exited")
| running.to(exited, cond="is_exited")
)
# Additional events (for explicit control)
launch = queued.to(running)
kill = running.to(exited)
def can_start(self) -> bool:
"""Check if process can start (has cmd and machine)."""
return bool(self.process.cmd and self.process.machine)
def is_exited(self) -> bool:
"""Check if process has exited (exit_code is set)."""
return self.process.exit_code is not None
@queued.enter
def enter_queued(self):
"""Process is queued for execution."""
self.process.update_and_requeue(
retry_at=timezone.now(),
status=Process.StatusChoices.QUEUED,
)
@running.enter
def enter_running(self):
"""Start process execution."""
# Lock the process while it runs
self.process.update_and_requeue(
retry_at=timezone.now() + timedelta(seconds=self.process.timeout),
status=Process.StatusChoices.RUNNING,
started_at=timezone.now(),
)
# Launch the subprocess
# NOTE: This is a placeholder - actual launch logic would
# be implemented based on how hooks currently spawn processes
# For now, Process is a data model that tracks execution metadata
# The actual subprocess spawning is still handled by run_hook()
# Mark as immediately exited for now (until we refactor run_hook)
# In the future, this would actually spawn the subprocess
self.process.exit_code = 0 # Placeholder
self.process.save()
@exited.enter
def enter_exited(self):
"""Process has exited."""
self.process.update_and_requeue(
retry_at=None,
status=Process.StatusChoices.EXITED,
ended_at=timezone.now(),
)
# =============================================================================
# State Machine Registration
# =============================================================================
# Manually register state machines with python-statemachine registry
registry.register(BinaryMachine)
registry.register(ProcessMachine)
+1 -1
View File
@@ -255,7 +255,7 @@ def check_data_dir_permissions(config=None, **config_kwargs):
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#permissions]https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#permissions[/link]",
)
STDERR.print(
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid]https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid[/link]",
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#puid--pgid]https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#puid--pgid[/link]",
)
STDERR.print(
" [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#filesystem-doesnt-support-fsync-eg-network-mounts]https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#filesystem-doesnt-support-fsync-eg-network-mounts[/link]",
+18 -7
View File
@@ -380,29 +380,40 @@ def log_sqlite_lock_holders(console: Any, *, db_path: Path = CONSTANTS.DATABASE_
def sqlite_lock_error(error: BaseException) -> bool:
from django.db import OperationalError as DjangoOperationalError
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and "database is locked" in str(error).lower()
message = str(error).lower()
return isinstance(error, (SQLiteOperationalError, DjangoOperationalError)) and (
"database is locked" in message or "database table is locked" in message
)
def retry_sqlite_locks(action: Callable[[], Any], *, label: str, stderr: TextIO | None = None) -> Any:
from django.conf import settings
from django.db import OperationalError, connections
from rich.console import Console
console = Console(file=stderr or None, stderr=stderr is None)
started_at = time.monotonic()
retry_timeout = settings.CONFIG.SQLITE_LOCK_RETRY_TIMEOUT
retry_interval = settings.CONFIG.SQLITE_LOCK_RETRY_INTERVAL
while True:
try:
return action()
except OperationalError as err:
if "database is locked" not in str(err).lower():
raise
except SQLiteOperationalError as err:
except (OperationalError, SQLiteOperationalError) as err:
if not sqlite_lock_error(err):
raise
if retry_timeout:
remaining = retry_timeout - (time.monotonic() - started_at)
if remaining <= 0:
raise
sleep_for = min(retry_interval, remaining)
else:
sleep_for = retry_interval
connections.close_all()
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in 5s...[/yellow]")
console.print(f"[yellow][*] SQLite database is locked while {label}; retrying in {sleep_for:g}s...[/yellow]")
log_sqlite_lock_holders(console)
with console.status("[yellow]Waiting for SQLite database lock to clear...[/yellow]", spinner="dots"):
time.sleep(5.0)
time.sleep(sleep_for)
@contextmanager
-260
View File
@@ -1,260 +0,0 @@
# Bootable hashing helpers (file content + mtime/size cache, mime detection).
# MUST NOT import archivebox.config, archivebox.core, or Django — stdlib only.
import hashlib
import mimetypes
from functools import lru_cache
from pathlib import Path
from collections.abc import Callable
from datetime import datetime
@lru_cache(maxsize=1024)
def _cached_file_hash(filepath: str, size: int, mtime: float) -> str:
"""Internal function to calculate file hash with cache key based on path, size and mtime."""
sha256_hash = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
return sha256_hash.hexdigest()
@lru_cache(maxsize=10)
def hash_file(file_path: Path, pwd: Path | None = None) -> str:
"""Calculate SHA256 hash of a file with caching based on path, size and mtime."""
pwd = Path(pwd) if pwd else None
file_path = Path(file_path)
if not file_path.is_absolute():
file_path = pwd / file_path if pwd else file_path.absolute()
abs_path = file_path.resolve()
stat_info = abs_path.stat()
return _cached_file_hash(
str(abs_path),
stat_info.st_size,
stat_info.st_mtime,
)
@lru_cache(maxsize=10)
def get_dir_hashes(dir_path: Path, pwd: Path | None = None, filter_func: Callable | None = None, max_depth: int = -1) -> dict[str, str]:
"""Calculate SHA256 hashes for all files and directories recursively."""
pwd = Path(pwd) if pwd else None
dir_path = Path(dir_path)
if not dir_path.is_absolute():
dir_path = pwd / dir_path if pwd else dir_path.absolute()
if not dir_path.is_dir():
raise ValueError(f"Not a directory: {dir_path}")
if max_depth < -1:
raise ValueError(f"max_depth must be >= -1, got {max_depth}")
# Get all files recursively
all_files = get_dir_entries(
dir_path,
pwd=pwd,
recursive=True,
include_files=True,
include_dirs=False,
filter_func=filter_func,
)
hashes: dict[str, str] = {}
hashable_summary = []
# Calculate hashes for all files
for subfile in all_files:
subfile_path = dir_path / subfile
sha256_hash = hash_file(subfile_path)
hashes[subfile] = sha256_hash
hashable_summary.append(f"{sha256_hash} ./{subfile}")
# Calculate hashes for all directories
subdirs = get_dir_entries(
dir_path,
pwd=pwd,
recursive=True,
include_files=False,
include_dirs=True,
include_hidden=False,
filter_func=filter_func,
max_depth=max_depth,
)
for subdir in subdirs:
subdir_path = dir_path / subdir
subdir_hashes = get_dir_hashes(
subdir_path,
filter_func=filter_func,
max_depth=0,
)
hashes[subdir] = subdir_hashes["."]
# Filter results by max_depth
if max_depth >= 0:
hashes = {path: value for path, value in hashes.items() if len(Path(path).parts) <= max_depth + 1}
# Calculate root directory hash
hashable_summary.sort()
root_sha256 = hashlib.sha256("\n".join(hashable_summary).encode()).hexdigest()
hashes["."] = root_sha256
return hashes
@lru_cache(maxsize=128)
def get_dir_entries(
dir_path: Path,
pwd: Path | None = None,
recursive: bool = True,
include_files: bool = True,
include_dirs: bool = True,
include_hidden: bool = False,
filter_func: Callable | None = None,
max_depth: int = -1,
) -> tuple[str, ...]:
"""Get filtered list of directory entries."""
pwd = Path(pwd) if pwd else None
dir_path = Path(dir_path)
if not dir_path.is_absolute():
dir_path = pwd / dir_path if pwd else dir_path.absolute()
results = []
def process_path(path: Path, depth: int):
if not include_hidden and path.name.startswith("."):
return False
if max_depth >= 0 and depth > max_depth:
return False
if filter_func:
info = {
"abspath": str(path.absolute()),
"relpath": str(path.relative_to(dir_path)),
}
if not filter_func(info):
return False
return True
for path in dir_path.rglob("*") if recursive else dir_path.glob("*"):
current_depth = len(path.relative_to(dir_path).parts)
if path.is_file() and include_files and process_path(path, current_depth):
results.append(str(path.relative_to(dir_path)))
elif path.is_dir() and include_dirs and process_path(path, current_depth):
results.append(str(path.relative_to(dir_path)))
if not recursive:
break
return tuple(sorted(results)) # Make immutable for caching
@lru_cache(maxsize=1024)
def get_dir_sizes(dir_path: Path, pwd: Path | None = None, **kwargs) -> dict[str, int]:
"""Calculate sizes for all files and directories recursively."""
sizes: dict[str, int] = {}
hashes = get_dir_hashes(dir_path, pwd=pwd, **kwargs)
dir_path = Path(dir_path)
for path_key in hashes:
full_path = dir_path / path_key
if full_path.is_file():
sizes[path_key] = full_path.stat().st_size
else:
total = 0
for file_path in full_path.rglob("*"):
if file_path.is_file() and not file_path.name.startswith("."):
total += file_path.stat().st_size
sizes[path_key + "/"] = total
return sizes
@lru_cache(maxsize=10)
def get_dir_info(dir_path: Path, pwd: Path | None = None, filter_func: Callable | None = None, max_depth: int = -1) -> dict:
"""Get detailed information about directory contents including hashes and sizes."""
pwd = Path(pwd) if pwd else None
dir_path = Path(dir_path)
if not dir_path.is_absolute():
dir_path = pwd / dir_path if pwd else dir_path.absolute()
hashes = get_dir_hashes(dir_path, pwd=pwd, filter_func=filter_func, max_depth=max_depth)
sizes = get_dir_sizes(str(dir_path), pwd=pwd, filter_func=filter_func, max_depth=max_depth)
num_total_subpaths = sum(1 for name in hashes if name != ".")
details = {}
for filename, sha256_hash in sorted(hashes.items()):
abs_path = (dir_path / filename).resolve()
stat_info = abs_path.stat()
num_subpaths = sum(1 for p in hashes if p.startswith(filename + "/"))
is_dir = abs_path.is_dir()
if is_dir:
mime_type = "inode/directory"
basename = abs_path.name
extension = ""
num_bytes = sizes[filename + "/"]
if filename == ".":
num_subpaths = num_total_subpaths
else:
filename += "/"
num_subpaths = num_subpaths
else: # is_file
num_subpaths = None
mime_type = mimetypes.guess_type(str(abs_path))[0]
extension = abs_path.suffix
basename = abs_path.name.rsplit(extension, 1)[0]
num_bytes = sizes[filename]
details[filename] = {
"basename": basename,
"mime_type": mime_type,
"extension": extension,
"num_subpaths": num_subpaths,
"num_bytes": num_bytes,
"hash_sha256": sha256_hash,
"created_at": datetime.fromtimestamp(stat_info.st_ctime).isoformat(),
"modified_at": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
}
if filter_func and not filter_func(details[filename]):
del details[filename]
return details
if __name__ == "__main__":
import json
dir_info = get_dir_info(Path("."), max_depth=6)
with open(".hashes.json", "w") as f:
json.dump(dir_info, f, indent=4)
print("Wrote .hashes.json")
# Example output:
# {
# ".": {
# "basename": "misc",
# "mime_type": "inode/directory",
# "extension": "",
# "num_subpaths": 25,
# "num_bytes": 214677,
# "hash_sha256": "addfacf88b2ff6b564846415fb7b21dcb7e63ee4e911bc0aec255ee354958530",
# "created_at": "2024-12-04T00:08:38.537449",
# "modified_at": "2024-12-04T00:08:38.537449"
# },
# "__init__.py": {
# "basename": "__init__",
# "mime_type": "text/x-python",
# "extension": ".py",
# "num_subpaths": null,
# "num_bytes": 32,
# "hash_sha256": "b0e5e7ff17db3b60535cf664282787767c336e3e203a43e21b6326c6fe457551",
# "created_at": "2024-10-08T00:51:41.001359",
# "modified_at": "2024-10-08T00:51:41.001359"
# },
# ...
# }
-14
View File
@@ -32,7 +32,6 @@ import sys
import json
from typing import Any, TextIO
from collections.abc import Iterable, Iterator
from pathlib import Path
# Type constants for JSONL records
@@ -120,19 +119,6 @@ def read_stdin(stream: TextIO | None = None) -> Iterator[dict[str, Any]]:
yield record
def read_file(path: Path) -> Iterator[dict[str, Any]]:
"""
Read JSONL or plain URLs from a file.
Yields parsed records as dicts.
"""
with open(path) as f:
for line in f:
record = parse_line(line)
if record:
yield record
def read_args_or_stdin(args: Iterable[str], stream: TextIO | None = None) -> Iterator[dict[str, Any]]:
"""
Read from CLI arguments if provided, otherwise from stdin.
+145 -48
View File
@@ -14,10 +14,10 @@ import time
import zipfile
from collections.abc import Callable
from datetime import UTC, datetime
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import quote, urlencode, urljoin
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
from django.contrib.staticfiles import finders
from django.core.handlers.asgi import ASGIRequest
from django.http import Http404, HttpResponse, HttpResponseNotModified, StreamingHttpResponse
@@ -29,10 +29,32 @@ from django.views import static
from archivebox.config.common import get_config
from archivebox.misc.logging_util import printable_filesize
from archivebox.plugins.discovery import render_plugin_full_response
_HASHES_CACHE: dict[Path, tuple[float, dict[str, str]]] = {}
FAVICON_CACHE_CONTROL = "public, max-age=31536000, s-maxage=31536000, immutable"
IMG_SRC_ATTR_RE = re.compile(r'(<img\b[^>]*?\s(?:src|data-src)=["\'])([^"\']+)(["\'])', re.IGNORECASE)
TRANSFORMED_HTML_PREVIEW_STYLE = """<style id="archivebox-static-html-preview-style">
html {
width: 100%;
min-width: 100%;
background: #fff;
}
body {
box-sizing: border-box;
width: min(100%, 72rem);
max-width: none;
min-height: 100vh;
margin: 0 auto;
padding: clamp(1rem, 3vw, 2rem);
background: #fff;
color: #111827;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55;
}
body > * {
max-width: 100%;
}
img:not([width]):not([height]) {
max-width: min(100%, 12rem);
max-height: 12rem;
@@ -109,7 +131,7 @@ def _resolve_archive_path(document_root: str | Path, rel_path: str) -> tuple[Pat
def _cache_policy(config=None, **config_kwargs) -> str:
config = config or get_config(resolve_plugins=False, **config_kwargs)
return "private" if config.PERMISSIONS == "private" else "public"
return "public" if config.PERMISSIONS == "public" else "private"
def _format_direntry_timestamp(stat_result: os.stat_result) -> str:
@@ -507,6 +529,26 @@ def _index_responses_paths_for_html_images(
best_matches: dict[str, str] = {}
image_matches: set[str] = set()
# Optional responses plugin file contract: index.jsonl records contain method,
# url, status, and a path relative to responses/. No plugin code is imported.
# Legacy captures without an index still use the filename lookup below.
# The index retains complete URLs even when response filenames are shortened.
index = responses_root / "index.jsonl"
if index.is_file():
with index.open(errors="replace") as records:
for line in records:
try:
record = json.loads(line)
encoded = _encoded_responses_image_url(record.get("url", ""), None)
if encoded not in encoded_urls or record.get("method") != "GET" or record.get("status") != 200:
continue
candidate = (responses_root / record.get("path", "")).resolve()
if not candidate.is_relative_to(responses_root.resolve()) or not candidate.is_file():
continue
best_matches[encoded] = posixpath.relpath(candidate, start=snapshot_root / posixpath.dirname(html_rel_path))
image_matches.add(encoded)
except (ValueError, TypeError, AttributeError):
continue
image_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"}
for candidate in responses_root.rglob("*"):
if not candidate.is_file():
@@ -541,10 +583,30 @@ def _rewrite_html_image_sources_to_responses(
if image_url not in encoded_urls_by_src:
encoded_urls_by_src[image_url] = _encoded_responses_image_url(image_url, page_url)
# Optional dom plugin artifact: dom/output.html. Only image src and
# data-canonical-src attributes are read; no scripts or plugin code execute.
# Absence of this artifact simply disables proxy-to-original aliases.
aliases: dict[str, str] = {}
class ImageAliases(HTMLParser):
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
original = attrs.get("data-canonical-src")
proxy = attrs.get("src")
if tag == "img" and original and proxy:
original_key = _encoded_responses_image_url(original, page_url)
proxy_key = _encoded_responses_image_url(proxy, page_url)
if original_key and proxy_key:
aliases[original_key] = proxy_key
dom = snapshot_root / "dom" / "output.html"
if dom.is_file() and dom.stat().st_size <= 10 * 1024 * 1024:
ImageAliases().feed(dom.read_text(errors="replace"))
wanted = {encoded for encoded in encoded_urls_by_src.values() if encoded}
response_paths = _index_responses_paths_for_html_images(
snapshot_root,
html_rel_path,
{encoded_url for encoded_url in encoded_urls_by_src.values() if encoded_url},
wanted | {aliases[key] for key in wanted if key in aliases},
)
rewrites = 0
@@ -552,7 +614,7 @@ def _rewrite_html_image_sources_to_responses(
nonlocal rewrites
image_url = html.unescape(match.group(2))
encoded_url = encoded_urls_by_src.get(image_url)
local_path = response_paths.get(encoded_url or "")
local_path = response_paths.get(encoded_url or "") or response_paths.get(aliases.get(encoded_url or "", ""))
if not local_path:
return match.group(0)
rewrites += 1
@@ -569,12 +631,17 @@ def _rewrite_html_image_sources_for_request(
) -> tuple[str, int]:
if not document_root:
return html_text, 0
return _rewrite_html_image_sources_to_responses(
html_text,
document_root,
html_rel_path,
request.__dict__.get("archivebox_snapshot_url"),
)
try:
return _rewrite_html_image_sources_to_responses(
html_text,
document_root,
html_rel_path,
request.__dict__.get("archivebox_snapshot_url"),
)
except (OSError, ValueError, TypeError, RecursionError):
# Saved plugin artifacts are optional and may be incomplete, malformed,
# or removed concurrently. Image enrichment must not break file replay.
return html_text, 0
def _apply_transformed_html_preview_style(html_text: str) -> str:
@@ -585,16 +652,22 @@ def _apply_transformed_html_preview_style(html_text: str) -> str:
return f"{TRANSFORMED_HTML_PREVIEW_STYLE}\n{html_text}"
def _set_transformed_response_headers(response, fullpath: Path, statobj: os.stat_result, encoding: str | None, config) -> None:
def _set_transformed_response_headers(
response,
fullpath: Path,
statobj: os.stat_result,
encoding: str | None,
cache_policy: str,
) -> None:
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if encoding:
response.headers["Content-Encoding"] = encoding
def _render_markdown_fallback(text: str) -> str:
if _markdown is not None and not HTML_TAG_RE.search(text):
if _markdown is not None:
try:
return _markdown(
text,
@@ -838,19 +911,22 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
cache_policy = getattr(request, "archivebox_cache_policy", None) or _cache_policy(config=config)
fullpath, path = _resolve_archive_path(document_root, path)
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
if request.GET.get("download") == "zip" and show_indexes:
return _build_directory_zip_response(
response = _build_directory_zip_response(
fullpath,
path,
is_archive_replay=is_archive_replay,
use_async_stream=_is_asgi_request(request),
config=config,
)
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
return response
if show_indexes:
response = _render_directory_index(request, path, fullpath)
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
response.headers["Last-Modified"] = http_date(fullpath.stat().st_mtime)
return _apply_archive_replay_headers(
response,
@@ -864,6 +940,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
raise Http404(_("“%(path)s” does not exist") % {"path": fullpath})
statobj = fullpath.stat()
# Captured site icons are intentionally shareable even for private snapshots.
# Only raw icon responses qualify, not generated previews or directory pages.
favicon_cache_control = (
FAVICON_CACHE_CONTROL
if fullpath.stem.lower() == "favicon"
and fullpath.suffix.lower() in {".ico", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"}
and not request.GET.get("preview")
else None
)
document_root = Path(document_root) if document_root else None
rel_path = path
etag = None
@@ -879,7 +964,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if etag in inm_list or etag.strip('"') in [i.strip('"') for i in inm_list]:
not_modified = HttpResponseNotModified()
not_modified.headers["ETag"] = etag
not_modified.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=31536000, immutable"
not_modified.headers["Last-Modified"] = http_date(statobj.st_mtime)
return _apply_archive_replay_headers(
not_modified,
@@ -890,6 +975,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
)
content_type, encoding = mimetypes.guess_type(str(fullpath))
# Captured favicons retain the conventional .ico filename even when a site
# returns SVG. Browsers cannot decode SVG served as image/x-icon.
if fullpath.suffix.lower() == ".ico":
with fullpath.open("rb") as icon_file:
icon_head = icon_file.read(4096)
if re.search(rb"<svg(?:\s|>)", icon_head):
content_type = "image/svg+xml"
preserve_plain_text = fullpath.suffix.lower() in {".log", ".sh", ".jsonl"}
if preserve_plain_text:
content_type = "text/plain"
content_type = content_type or "application/octet-stream"
# Add charset for text-like types (best guess), but don't override the type.
is_text_like = content_type.startswith("text/") or content_type in {
@@ -904,21 +999,21 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
preview_as_text_html = (
bool(request.GET.get("preview"))
and is_text_like
and not content_type.startswith("text/html")
and (bool(request.GET.get("raw")) or not content_type.startswith("text/html"))
and not content_type.startswith("image/svg+xml")
)
preview_as_image_html = (
bool(request.GET.get("preview")) and content_type.startswith("image/") and not content_type.startswith("image/svg+xml")
)
preview_as_archivewebpage_html = bool(request.GET.get("preview")) and archivewebpage_replay.is_replay_target(fullpath.name)
# Respect the If-Modified-Since header for non-markdown responses.
if not content_type.startswith(("text/plain", "text/html")) and not static.was_modified_since(
request.META.get("HTTP_IF_MODIFIED_SINCE"),
statobj.st_mtime,
):
not_modified = HttpResponseNotModified()
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=60, stale-while-revalidate=300"
return _apply_archive_replay_headers(
HttpResponseNotModified(),
not_modified,
fullpath=fullpath,
content_type=content_type,
is_archive_replay=is_archive_replay,
@@ -933,7 +1028,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
decoded = fullpath.read_text(encoding="utf-8", errors="replace")
wrapped = _render_text_preview_document(decoded, fullpath.name)
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
@@ -942,7 +1037,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
config=config,
)
except (OSError, UnicodeDecodeError, ValueError):
preview_as_text_html = False
pass
if preview_as_image_html:
try:
@@ -953,7 +1048,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
raw_image_url = f"{raw_image_url}?{urlencode(list(preview_query.lists()), doseq=True)}"
wrapped = _render_image_preview_document(raw_image_url, fullpath.name)
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
@@ -962,16 +1057,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
config=config,
)
except (OSError, ValueError):
preview_as_image_html = False
pass
if preview_as_archivewebpage_html:
if request.GET.get("preview"):
try:
raw_query = request.GET.copy()
raw_query.pop("preview", None)
raw_output_path = request.path
if raw_query:
raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}"
body, preview_content_type, headers = archivewebpage_replay.render_preview_response(
plugin_replay = render_plugin_full_response(
fullpath.name,
raw_output_path,
wacz_path=fullpath,
@@ -979,28 +1074,28 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
last_modified=http_date(statobj.st_mtime),
etag=etag or "",
cache_control=(
f"{_cache_policy(config=config)}, max-age=31536000, immutable"
if etag
else f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
f"{cache_policy}, max-age=31536000, immutable" if etag else f"{cache_policy}, max-age=60, stale-while-revalidate=300"
),
content_encoding=encoding or "",
)
response = HttpResponse(body, content_type=preview_content_type)
for key, value in headers.items():
response.headers[key] = value
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
content_type=preview_content_type,
is_archive_replay=is_archive_replay,
config=config,
)
if plugin_replay is not None:
body, replay_content_type, headers = plugin_replay
response = HttpResponse(body, content_type=replay_content_type)
for key, value in headers.items():
response.headers[key] = value
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
content_type=replay_content_type,
is_archive_replay=is_archive_replay,
config=config,
)
except (OSError, RuntimeError, ValueError):
preview_as_archivewebpage_html = False
pass
# Heuristic fix: some archived HTML outputs are stored with HTML-escaped markup
# or markdown sources. If so, render sensibly.
if content_type.startswith(("text/plain", "text/html")):
if not request.GET.get("raw") and not preserve_plain_text and content_type.startswith(("text/plain", "text/html", "text/markdown")):
try:
max_unescape_size = 10 * 1024 * 1024 # 10MB cap to avoid heavy memory use
if statobj.st_size <= max_unescape_size:
@@ -1014,12 +1109,12 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if content_type.startswith("text/html") and document_root:
rewritten_html, rewritten_count = _rewrite_html_image_sources_for_request(request, decoded, document_root, rel_path)
markdown_candidate = _extract_markdown_candidate(decoded)
if _looks_like_markdown(markdown_candidate):
if content_type.startswith("text/markdown") or _looks_like_markdown(markdown_candidate):
wrapped = _render_markdown_document(markdown_candidate)
wrapped, _rewrite_count = _rewrite_html_image_sources_for_request(request, wrapped, document_root, rel_path)
wrapped = _apply_transformed_html_preview_style(wrapped)
response = HttpResponse(wrapped, content_type="text/html; charset=utf-8")
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
@@ -1030,7 +1125,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if rewritten_count:
rewritten_html = _apply_transformed_html_preview_style(rewritten_html)
response = HttpResponse(rewritten_html, content_type=content_type)
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
@@ -1041,7 +1136,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
if escaped_count and escaped_count > tag_count * 2:
decoded = _apply_transformed_html_preview_style(decoded)
response = HttpResponse(decoded, content_type=content_type)
_set_transformed_response_headers(response, fullpath, statobj, encoding, config)
_set_transformed_response_headers(response, fullpath, statobj, encoding, cache_policy)
return _apply_archive_replay_headers(
response,
fullpath=fullpath,
@@ -1061,13 +1156,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
if etag:
response.headers["ETag"] = etag
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=31536000, immutable"
response.headers["Cache-Control"] = f"{cache_policy}, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = f"{_cache_policy(config=config)}, max-age=60, stale-while-revalidate=300"
response.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
if is_text_like:
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
if content_type.startswith("image/"):
response.headers["Cache-Control"] = "public, max-age=604800, immutable"
response.headers["Cache-Control"] = f"{cache_policy}, max-age=604800, immutable"
if favicon_cache_control:
response.headers["Cache-Control"] = favicon_cache_control
# handle byte-range requests by serving chunk of file
if stat.S_ISREG(statobj.st_mode):
-83
View File
@@ -2,94 +2,11 @@ from typing import Any, cast
from collections.abc import Callable
import json
import ast
import inspect
import toml
import re
import configparser
from pathlib import Path, PosixPath
from pydantic.json_schema import GenerateJsonSchema
from pydantic_core import to_jsonable_python
JSONValue = str | bool | int | None | list["JSONValue"]
TOML_HEADER = "# Converted from INI to TOML format: https://toml.io/en/\n\n"
def load_ini_value(val: str) -> JSONValue:
"""Convert lax INI values into strict TOML-compliant (JSON) values"""
if val.lower() in ("true", "yes", "1"):
return True
if val.lower() in ("false", "no", "0"):
return False
if val.isdigit():
return int(val)
try:
return ast.literal_eval(val)
except Exception:
pass
try:
return json.loads(val)
except Exception:
pass
return val
def convert(ini_str: str) -> str:
"""Convert a string of INI config into its TOML equivalent (warning: strips comments)"""
config = configparser.ConfigParser()
setattr(config, "optionxform", str) # capitalize key names
config.read_string(ini_str)
# Initialize an empty dictionary to store the TOML representation
toml_dict = {}
# Iterate over each section in the INI configuration
for section in config.sections():
toml_dict[section] = {}
# Iterate over each key-value pair in the section
for key, value in config.items(section):
parsed_value = load_ini_value(value)
# Convert the parsed value to its TOML-compatible JSON representation
toml_dict[section.upper()][key.upper()] = json.dumps(parsed_value)
# Build the TOML string
toml_str = TOML_HEADER
for section, items in toml_dict.items():
toml_str += f"[{section}]\n"
for key, value in items.items():
toml_str += f"{key} = {value}\n"
toml_str += "\n"
return toml_str.strip()
class JSONSchemaWithLambdas(GenerateJsonSchema):
"""
Encode lambda functions in default values properly.
Usage:
>>> json.dumps(value, encoder=JSONSchemaWithLambdas())
"""
def encode_default(self, dft: Any) -> Any:
config = self._config
if isinstance(dft, Callable):
return "{{lambda " + inspect.getsource(dft).split("=lambda ")[-1].strip()[:-1] + "}}"
return to_jsonable_python(
dft,
timedelta_mode=config.ser_json_timedelta,
bytes_mode=config.ser_json_bytes,
serialize_unknown=True,
)
def better_toml_dump_str(val: Any) -> str:
try:
+10 -4
View File
@@ -192,7 +192,10 @@ def validate_url_length(url: str) -> str:
def validate_url(url: str) -> str:
url = validate_url_length((url or "").strip())
url = (url or "").strip()
if "\n" in url or "\r" in url:
raise ValueError("URL must be a single line.")
url = validate_url_length(url)
parsed = urlparse(url)
if parsed.scheme.lower() not in ("http", "https") or not parsed.hostname:
raise ValueError("URL must start with http:// or https:// and include a hostname.")
@@ -385,9 +388,12 @@ def parse_date(date: Any) -> datetime | None:
if date.tzinfo is None:
return date.replace(tzinfo=timezone.utc)
offset = date.utcoffset()
assert offset == datetime.now(timezone.utc).utcoffset(), "Refusing to load a non-UTC date!"
return date
# Legacy SQLite ArchiveResult rows can retain explicit timezone offsets
# through migration. Django loads those as aware datetimes, whereas JSON
# imports reach the string branch below. Normalize both representations
# consistently so opening a migrated snapshot does not raise (#1889).
# Convert the instant; replacing tzinfo here would shift the capture time.
return date.astimezone(timezone.utc)
if isinstance(date, (float, int)):
date = str(date)
-6
View File
@@ -1,6 +0,0 @@
from django.apps import AppConfig
class OpencodeConfig(AppConfig):
name = "archivebox.opencode"
label = "opencode_plugin"
@@ -1,211 +0,0 @@
{% extends "admin/base.html" %}
{% load i18n %}
{% block title %}Agent{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; Agent
</div>
{% endblock %}
{% block extrastyle %}
{{ block.super }}
<style>
body.opencode-agent #content {
padding: 0;
}
body.opencode-agent #content-main {
height: calc(100vh - 150px);
min-height: 560px;
}
.opencode-agent-error {
margin: 24px;
color: #0f172a;
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.opencode-agent-error code {
color: #0369a1;
}
.opencode-agent-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
}
.opencode-agent-shell {
position: relative;
height: 100%;
}
.opencode-agent-welcome {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.48);
color: #111827;
}
.opencode-agent-welcome[hidden] {
display: none;
}
.opencode-agent-welcome-panel {
width: min(720px, 100%);
max-height: min(720px, calc(100vh - 190px));
overflow: auto;
border-radius: 8px;
border: 1px solid #d1d5db;
background: Canvas;
color: CanvasText;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.32);
padding: 24px;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.opencode-agent-welcome-panel h1 {
margin: 0 0 10px;
font-size: 22px;
line-height: 1.25;
}
.opencode-agent-welcome-panel p {
margin: 0 0 14px;
}
.opencode-agent-welcome-panel ul {
margin: 0 0 16px 20px;
padding: 0;
}
.opencode-agent-welcome-panel li {
margin: 0 0 8px;
}
.opencode-agent-welcome-warning {
margin: 16px 0;
padding: 12px 14px;
border: 1px solid #f59e0b;
border-radius: 6px;
background: color-mix(in srgb, #f59e0b 12%, Canvas);
}
.opencode-agent-welcome-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.opencode-agent-welcome button {
cursor: pointer;
border: 1px solid #2563eb;
border-radius: 6px;
background: #2563eb;
color: #fff;
padding: 8px 14px;
font-weight: 600;
}
@media (max-width: 700px) {
.opencode-agent-welcome {
padding: 12px;
}
.opencode-agent-welcome-panel {
max-height: calc(100vh - 170px);
padding: 18px;
}
}
</style>
{% endblock %}
{% block bodyclass %}{{ block.super }} opencode-agent{% endblock %}
{% block content %}
<div id="content-main">
{% if error %}
<div class="opencode-agent-error">
{{ error }}
{% if command %}<br><br><code>{{ command }}</code>{% endif %}
</div>
{% else %}
<script>
(() => {
const workdir = "{{ workdir|escapejs }}";
const recentSessionId = "{{ recent_session_id|escapejs }}";
const merge = (left, right) => {
if (!left || typeof left !== "object" || Array.isArray(left)) return right;
const out = {...left};
for (const [key, value] of Object.entries(right)) {
out[key] = value && typeof value === "object" && !Array.isArray(value) ? merge(out[key], value) : value;
}
return out;
};
const seed = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(merge(JSON.parse(localStorage.getItem(key) || "{}"), value)));
} catch {
localStorage.setItem(key, JSON.stringify(value));
}
};
const setJSON = (key, value) => localStorage.setItem(key, JSON.stringify(value));
const page = {
activeProject: workdir,
activeWorkspace: workdir,
gettingStartedDismissed: true,
workspaceExpanded: {[workdir]: true},
};
if (recentSessionId) {
page.lastProjectSession = {[workdir]: {directory: workdir, id: recentSessionId, at: Date.now()}};
}
seed("opencode.global.dat:layout.page", page);
seed("opencode.global.dat:layout", {
sidebar: {opened: true, width: 280},
fileTree: {opened: true, width: 280, tab: "all"},
});
const serverKey = `${location.origin}/admin/agent/opencode`;
setJSON("opencode.global.dat:server", {
list: [],
projects: {
[serverKey]: [{worktree: workdir, expanded: true}],
},
lastProject: {
[serverKey]: workdir,
},
});
localStorage.setItem("opencode.settings.dat:defaultServerUrl", serverKey);
})();
</script>
<div class="opencode-agent-shell">
<iframe src="{{ proxy_url }}" title="OpenCode Agent" class="opencode-agent-frame"></iframe>
<div class="opencode-agent-welcome" id="opencode-agent-welcome" hidden>
<div class="opencode-agent-welcome-panel" role="dialog" aria-modal="true" aria-labelledby="opencode-agent-welcome-title">
<h1 id="opencode-agent-welcome-title">ArchiveBox AI Agent</h1>
<p>This agent can work directly with your ArchiveBox collection. It can inspect the crawl database, create and monitor crawls, run maintenance operations, answer research questions, query archived data, and help with complex collection workflows.</p>
<p>Example prompts:</p>
<ul>
<li>Archive this list of URLs with depth 0, then report which ones failed and why.</li>
<li>Find snapshots from the last month that failed PDF or screenshot extraction and retry them.</li>
<li>Create a constrained crawl for this site, avoid login/privacy/sitemap URLs, and watch the logs as it runs.</li>
<li>Summarize what my collection contains about this topic and link to the best saved pages.</li>
</ul>
<div class="opencode-agent-welcome-warning">
The agent has unrestricted access to this collection and can make destructive edits. Review requests carefully. Permission settings can be changed from the OpenCode gear icon in the lower left.
</div>
<div class="opencode-agent-welcome-actions">
<button type="button" id="opencode-agent-welcome-dismiss">Start using Agent</button>
</div>
</div>
</div>
</div>
<script>
(() => {
const key = "archivebox.opencode.agentWelcomeDismissed.v1";
const welcome = document.getElementById("opencode-agent-welcome");
const dismiss = document.getElementById("opencode-agent-welcome-dismiss");
if (!welcome || !dismiss) return;
if (localStorage.getItem(key) !== "1") {
welcome.hidden = false;
}
dismiss.addEventListener("click", () => {
localStorage.setItem(key, "1");
welcome.hidden = true;
});
})();
</script>
{% endif %}
</div>
{% endblock %}
+118 -694
View File
@@ -1,716 +1,140 @@
from __future__ import annotations
"""Django adapter for the optional plugin; never import its runtime at startup."""
import atexit
import base64
import os
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlsplit
import logging
from io import BytesIO
import httpx
import requests
from abx_plugins.plugins import opencode as opencode_plugin
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.core.routes_util import build_admin_url, get_api_base_url, get_base_url
from django.http import (
Http404,
HttpRequest,
HttpResponse,
HttpResponseForbidden,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
from asgiref.sync import sync_to_async
from django.contrib.auth.middleware import AuthenticationMiddleware
from django.contrib.auth.views import redirect_to_login
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import PermissionDenied
from django.core.handlers.asgi import ASGIRequest
from django.db import close_old_connections
from django.http import Http404, HttpResponse, HttpResponseForbidden, StreamingHttpResponse
from django.template import engines
from django.urls import Resolver404, resolve
from django.views.decorators.csrf import csrf_exempt
from archivebox.config import CONSTANTS
from archivebox.config.common import get_request_config
from archivebox.core.middleware import ReverseProxyAuthMiddleware
from archivebox.core.routes_util import build_admin_url, get_admin_host, get_api_base_url, get_base_url, host_matches
from archivebox.plugins.discovery import get_plugin_template
_PROCESS: subprocess.Popen | None = None
_PROCESS_LOCK = threading.Lock()
_PROXY_PREFIX = "/admin/agent/opencode"
_PROXY_PREFIX_REGEX = _PROXY_PREFIX.replace("/", r"\/")
_PROXY_PREFIX_NO_SLASH_REGEX = _PROXY_PREFIX.lstrip("/").replace("/", r"\/")
_CONFIG_PATH = Path(opencode_plugin.__file__).with_name("config.json")
_TEXT_CONTENT_TYPES = (
"text/",
"application/javascript",
"application/json",
"application/x-javascript",
)
_HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
}
_ARCHIVEBOX_SKILL = """---
name: archivebox
description: Use ArchiveBox's CLI and local REST API from an ArchiveBox collection.
---
You are running inside an ArchiveBox collection directory.
- ArchiveBox collection directory: {archivebox_data_dir}
- ArchiveBox BASE_URL: {archivebox_base_url}
- ArchiveBox Admin URL: {archivebox_admin_url}
- ArchiveBox REST API URL: {archivebox_api_url}
- Prefer the `archivebox` CLI for authenticated changes, e.g. `archivebox add`, `archivebox schedule`, `archivebox update`, and `archivebox shell`.
- Run ArchiveBox CLI commands from the ArchiveBox collection directory above.
- Get command help with `archivebox list --help`, `archivebox add --help`, `archivebox schedule --help`, etc. Do not use `archivebox help <command>`.
- Use `--depth=0` by default. Only use recursive crawling when the user explicitly asks for it; use `--depth=1` when you need pages one hop out.
- Before any recursive crawl, constrain scope with ArchiveBox config such as `CRAWL_MAX_URLS`, `CRAWL_MAX_SIZE`, `SNAPSHOT_MAX_*`, `URL_ALLOWLIST`, `URL_DENYLIST`, and related limits.
- Respect the configured `archivebox config --get ONLY_NEW` behavior unless the user explicitly says otherwise. Remind users that expected crawl URLs can be skipped when the collection already contains snapshots with the same URL.
- Always audit newly discovered crawl URLs before letting a crawl run broadly. Treat junk URLs such as privacy policies, legal pages, tag archives, sitemap files, feeds, login/logout URLs, and other low-value boilerplate as unwanted unless the user explicitly asked to archive them.
- Always watch crawl output and logs as the crawl progresses, and correct errors early instead of waiting until the crawl finishes.
- If a crawl contains bad URLs, pause it, edit the crawl's `urls` field to remove them, delete any unneeded snapshots already created under that crawl, then resume the crawl.
- Use `archivebox shell -c '...'` or `archivebox shell <<'PY' ... PY` for Django ORM work. Shell Plus prints an import banner first; keep stderr visible while debugging.
- Use full ArchiveBox module paths in shell code: `from archivebox.crawls.models import Crawl, CrawlSchedule` and `from archivebox.core.models import Snapshot, ArchiveResult`.
- If a model/field/relation is unclear, inspect `_meta.fields` before guessing, e.g. `archivebox shell -c "from archivebox.crawls.models import Crawl; print([f.name for f in Crawl._meta.fields])"`.
- Use `archivebox config --get BASE_URL` only to verify the configured base URL; prefer the seeded URLs above for API/admin requests.
- Use `$ARCHIVEBOX_API_URL` for REST API inspection when helpful. Do not assume admin session cookies authenticate API subdomain requests; prefer CLI/shell for authenticated mutations unless the admin provides or asks you to create an API token.
- Discover REST endpoints from `${{ARCHIVEBOX_API_URL}}v1/openapi.json`; crawl endpoints live under `/api/v1/crawls/`, snapshots under `/api/v1/core/`.
- Do not bypass ArchiveBox auth, expose API keys, or modify config unless the admin explicitly asks.
- After creating crawls or snapshots, report the crawl/snapshot IDs and the exact command or API request used.
"""
_LOGGER = logging.getLogger(__name__)
def _stop_owned_process(process: subprocess.Popen | None = None) -> None:
global _PROCESS
owned_process = process or _PROCESS
if owned_process is None:
return
if owned_process.poll() is None:
owned_process.terminate()
try:
owned_process.wait(timeout=5)
except subprocess.TimeoutExpired:
owned_process.kill()
owned_process.wait()
if _PROCESS is owned_process:
_PROCESS = None
def _runtime_settings(request, config):
from abx_plugins.plugins.opencode import runtime
atexit.register(_stop_owned_process)
def _machine_config() -> dict[str, Any]:
resolved = get_config()
return dict(resolved.model_dump(mode="json"))
def _archivebox_data_dir_default() -> Path:
return Path(CONSTANTS.DATA_DIR)
def _archivebox_route_urls(request: HttpRequest, route_config) -> tuple[str, str, str]:
base_url = get_base_url(
request=request,
config=route_config,
).rstrip("/")
admin_url = build_admin_url(
"/admin/",
request=request,
config=route_config,
).rstrip("/")
api_url = f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/"
return base_url, admin_url, api_url
def _config_value(config: dict, key: str, default):
value = config.get(key, default)
if value in (None, ""):
return default
return value
def _opencode_enabled(config: dict) -> bool:
value = config.get("OPENCODE_ENABLED", False)
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _require_enabled(config: dict) -> None:
if not _opencode_enabled(config):
raise Http404
def _require_superuser(request: HttpRequest):
user = getattr(request, "user", None)
if user is None:
return redirect(f"/admin/login/?next={request.get_full_path()}")
if (
bool(getattr(user, "is_authenticated", False))
and bool(getattr(user, "is_active", False))
and bool(getattr(user, "is_superuser", False))
):
return None
if bool(getattr(user, "is_authenticated", False)):
return HttpResponseForbidden(
b"ArchiveBox agent access requires a superuser account.",
)
return redirect(f"/admin/login/?next={request.get_full_path()}")
def _origin_allowed(request: HttpRequest, path: str | None = None) -> bool:
if request.method in {"GET", "HEAD", "OPTIONS", "TRACE"}:
return True
expected_host = request.get_host()
pty_connect = bool(
path and path.startswith("pty/") and path.endswith("/connect-token"),
)
if pty_connect:
return True
origin = _request_header(request, "Origin")
if origin:
return _same_host(origin, expected_host)
referer = _request_header(request, "Referer")
if referer:
return _same_host(referer, expected_host)
fetch_site = _request_header(request, "Sec-Fetch-Site")
if fetch_site:
return fetch_site in {"same-origin", "same-site", "none"}
return False
def _same_host(value: str, expected_host: str) -> bool:
parsed = urlsplit(value)
return parsed.scheme in {"http", "https"} and parsed.netloc == expected_host
def _settings(config: dict) -> dict:
host = str(_config_value(config, "OPENCODE_HOST", "127.0.0.1"))
port = int(_config_value(config, "OPENCODE_PORT", 4096))
default_data_dir = _archivebox_data_dir_default()
workdir = Path(
str(_config_value(config, "OPENCODE_WORKDIR", default_data_dir)),
).expanduser()
opencode_dir = Path(
str(_config_value(config, "OPENCODE_STATE_DIR", workdir / "opencode")),
).expanduser()
binary = str(_config_value(config, "OPENCODE_BINARY", "opencode"))
timeout = int(_config_value(config, "OPENCODE_TIMEOUT", 30))
return {
"host": host,
"port": port,
"origin": f"http://{host}:{port}",
"workdir": workdir,
"opencode_dir": opencode_dir,
"config_home": opencode_dir / "config",
"data_home": opencode_dir / "data",
"state_home": opencode_dir / "state",
"cache_home": opencode_dir / "cache",
"home": opencode_dir / "home",
"binary": binary,
"config": config,
"timeout": timeout,
}
def _resolve_binary(binary: str, config: dict) -> tuple[Any, Any, dict[str, str]]:
try:
from abxpkg import BinProvider
from abx_plugins.plugins.base.utils import load_required_binary_from_config
binary_environ = os.environ.copy()
lib_dir = config.get("ABXPKG_LIB_DIR")
if lib_dir:
binary_environ["ABXPKG_LIB_DIR"] = str(lib_dir)
loaded_dependencies = [
load_required_binary_from_config(
required_binary,
_CONFIG_PATH,
global_config=config,
environ=binary_environ,
install=False,
)
for required_binary in (
str(config.get("NODE_BINARY") or "node"),
str(config.get("GIT_BINARY") or "git"),
binary,
)
]
except Exception as err:
raise RuntimeError(
f"OpenCode dependency is not installed from required_binaries: {err}",
) from err
if any(not loaded.loaded_abspath for loaded in loaded_dependencies):
raise RuntimeError(
"OpenCode dependency is not installed from required_binaries.",
)
providers = [loaded.loaded_binprovider for loaded in loaded_dependencies if loaded.loaded_binprovider is not None]
binary_env = BinProvider.build_exec_env(
providers=providers,
base_env=binary_environ,
)
return (
loaded_dependencies[-1],
loaded_dependencies[1],
binary_env,
)
def _project_route(workdir: Path, session_id: str = "") -> str:
encoded = base64.b64encode(str(workdir.resolve()).encode()).decode()
encoded = encoded.replace("+", "-").replace("/", "_").rstrip("=")
route = f"{_PROXY_PREFIX}/{encoded}/session"
return f"{route}/{session_id}" if session_id else route
def _ensure_project_files(settings: dict) -> None:
workdir = settings["workdir"].resolve()
workdir.mkdir(parents=True, exist_ok=True)
git_marker = workdir / ".git" / "not-a-git"
if git_marker.exists():
# Current OpenCode hangs on the legacy fake marker, so remove only
# that invalid shape before initializing the real worktree.
shutil.rmtree(git_marker.parent)
editable_skill_path = settings["opencode_dir"] / "SKILL.md"
editable_skill_path.parent.mkdir(parents=True, exist_ok=True)
if not editable_skill_path.exists():
editable_skill_path.write_text(
_ARCHIVEBOX_SKILL.format(
archivebox_data_dir=workdir,
archivebox_base_url=settings.get("archivebox_base_url", ""),
archivebox_admin_url=settings.get("archivebox_admin_url", ""),
archivebox_api_url=settings.get("archivebox_api_url", ""),
),
)
opencode_skill_path = settings["config_home"] / "opencode" / "skills" / "archivebox" / "SKILL.md"
opencode_skill_path.parent.mkdir(parents=True, exist_ok=True)
if opencode_skill_path.resolve() != editable_skill_path.resolve():
if opencode_skill_path.exists() or opencode_skill_path.is_symlink():
opencode_skill_path.unlink()
opencode_skill_path.symlink_to(editable_skill_path)
def _ensure_default_session(settings: dict) -> str:
workdir = settings["workdir"].resolve()
params = {"directory": str(workdir)}
timeout = settings["timeout"]
project = requests.post(
f"{settings['origin']}/project/git/init",
params=params,
timeout=timeout,
)
project.raise_for_status()
project_data = project.json()
if Path(str(project_data.get("worktree") or "/")).resolve() != workdir:
raise RuntimeError(
f"OpenCode initialized the wrong project worktree: {project_data.get('worktree')!r}",
)
sessions = requests.get(
f"{settings['origin']}/session",
params={**params, "roots": "true", "limit": 55},
timeout=timeout,
)
sessions.raise_for_status()
session_data = sessions.json()
if not isinstance(session_data, list):
raise RuntimeError("OpenCode returned an invalid project session list.")
for session_data_item in session_data:
if not isinstance(session_data_item, dict):
continue
session_id = str(session_data_item.get("id") or "")
session_directory = session_data_item.get("directory")
if session_id and session_directory and Path(str(session_directory)).resolve() == workdir:
return session_id
session = requests.post(
f"{settings['origin']}/session",
params=params,
json={},
timeout=timeout,
)
session.raise_for_status()
session_data = session.json()
session_id = str(session_data.get("id") or "")
session_directory = session_data.get("directory")
if not session_id or not session_directory or Path(str(session_directory)).resolve() != workdir:
raise RuntimeError(
"OpenCode did not create a session for the requested worktree.",
)
return session_id
def _recent_session_id(settings: dict) -> str:
workdir = str(settings["workdir"].resolve())
try:
response = requests.get(
f"{settings['origin']}/session",
params={"directory": workdir, "roots": "true", "limit": 1},
timeout=settings["timeout"],
)
response.raise_for_status()
sessions = response.json()
if sessions:
return str(sessions[0].get("id") or "")
except requests.RequestException:
pass
return ""
def _health(settings: dict) -> bool:
try:
response = requests.get(
f"{settings['origin']}/global/health",
timeout=2,
)
return response.status_code == 200
except requests.RequestException:
return False
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS
started_process: subprocess.Popen | None = None
workdir = settings["workdir"].resolve()
try:
binary, git_binary, binary_env = _resolve_binary(
settings["binary"],
settings["config"],
)
except RuntimeError as err:
return False, str(err)
env = {
**os.environ,
**binary_env,
"ARCHIVEBOX_BASE_URL": str(settings.get("archivebox_base_url", "")),
"ARCHIVEBOX_ADMIN_URL": str(settings.get("archivebox_admin_url", "")),
"ARCHIVEBOX_API_URL": str(settings.get("archivebox_api_url", "")),
"BROWSER": "false",
"GIT_CEILING_DIRECTORIES": str(workdir),
"HOME": str(settings["home"]),
"OPENCODE_DISABLE_PROJECT_CONFIG": "true",
"XDG_CONFIG_HOME": str(settings["config_home"]),
"XDG_DATA_HOME": str(settings["data_home"]),
"XDG_STATE_HOME": str(settings["state_home"]),
"XDG_CACHE_HOME": str(settings["cache_home"]),
}
with _PROCESS_LOCK:
settings["workdir"].mkdir(parents=True, exist_ok=True)
settings["config_home"].mkdir(parents=True, exist_ok=True)
settings["data_home"].mkdir(parents=True, exist_ok=True)
settings["state_home"].mkdir(parents=True, exist_ok=True)
settings["cache_home"].mkdir(parents=True, exist_ok=True)
settings["home"].mkdir(parents=True, exist_ok=True)
_ensure_project_files(settings)
if not (workdir / ".git").exists():
try:
git_init = git_binary.exec(
cmd=("init", "--quiet"),
cwd=workdir,
env=env,
timeout=settings["timeout"],
)
except (AssertionError, OSError, subprocess.SubprocessError) as err:
return False, f"OpenCode project initialization failed: {err}"
if git_init.returncode != 0:
output = (git_init.stderr or git_init.stdout or "").strip()
return (
False,
f"OpenCode project initialization failed: {output or f'git exited with {git_init.returncode}'}",
)
if _health(settings):
try:
_ensure_default_session(settings)
except (requests.RequestException, RuntimeError, ValueError) as err:
return False, f"OpenCode project initialization failed: {err}"
return True, ""
binary_abspath = binary.loaded_abspath
if binary.loaded_binprovider is not None:
binary_abspath = binary.loaded_binprovider._exec_bin_abspath(
Path(binary.loaded_abspath),
)
cmd = [
str(binary_abspath),
"serve",
"--hostname",
settings["host"],
"--port",
str(settings["port"]),
]
try:
_PROCESS = subprocess.Popen(
cmd,
cwd=workdir,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
started_process = _PROCESS
except FileNotFoundError:
return False, f"OpenCode binary not found: {settings['binary']}"
deadline = time.monotonic() + settings["timeout"]
while time.monotonic() < deadline:
if _health(settings):
try:
_ensure_default_session(settings)
except (requests.RequestException, RuntimeError, ValueError) as err:
_stop_owned_process(started_process)
return False, f"OpenCode project initialization failed: {err}"
return True, ""
if started_process and started_process.poll() is not None:
if _PROCESS is started_process:
_PROCESS = None
return False, "OpenCode exited before the web server became ready."
time.sleep(0.25)
_stop_owned_process(started_process)
return False, "Timed out waiting for OpenCode to start."
def agent_view(request: HttpRequest):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
settings = _settings(config)
settings = runtime._settings(config, CONSTANTS.DATA_DIR)
route_config = request.__dict__.get("archivebox_config")
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
settings["archivebox_base_url"] = base_url
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
from archivebox.core.admin_site import archivebox_admin
recent_session_id = _recent_session_id(settings) if ok else ""
context = {
**archivebox_admin.each_context(request),
"title": "Agent",
"error": "" if ok else error,
"command": f"{settings['binary']} serve --hostname {settings['host']} --port {settings['port']}" if error else "",
# OpenCode 1.17+ keeps durable sessions on the explicit
# /<dirBase64>/session/<sessionId> route. We still seed localStorage
# below because the sidebar state uses it, but the iframe itself must
# open the durable session URL so a fresh browser does not land on the
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
return render(
request,
"opencode/agent.html",
context,
status=200 if ok else 502,
settings.update(
archivebox_base_url=get_base_url(request=request, config=route_config).rstrip("/"),
archivebox_admin_url=build_admin_url("/admin/", request=request, config=route_config).rstrip("/"),
archivebox_api_url=f"{get_api_base_url(request=request, config=route_config).rstrip('/')}/api/",
)
return runtime, settings
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)
def _dispatch(request, path=None):
try:
# Middleware already merged server defaults and live Machine overrides.
# Re-resolving every extractor's config here stalls the UI request burst.
config = get_request_config(request).model_dump(mode="json")
if not config.get("OPENCODE_ENABLED", False):
raise Http404
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path(), login_url="/admin/login/")
if not request.user.is_active or not request.user.is_superuser:
return HttpResponseForbidden("Agent access requires a superuser account.")
runtime, settings = _runtime_settings(request, config)
if path is None:
from archivebox.core.admin_site import archivebox_admin
context = {**archivebox_admin.each_context(request), **runtime.agent_context(settings)}
source = get_plugin_template("opencode", "agent", fallback=False)
if source is None:
raise RuntimeError("Agent template unavailable")
return HttpResponse(engines["django"].from_string(source).render(context, request))
if not runtime._origin_allowed(request.method, request.get_host(), request.headers):
return HttpResponseForbidden("Cross-origin agent requests are blocked.")
status, headers, body = runtime.proxy(
settings,
request.method,
path,
tuple((key, value) for key, values in request.GET.lists() for value in values),
request.headers,
request.body,
)
response_type = HttpResponse if isinstance(body, bytes) else StreamingHttpResponse
response = response_type(body, status=status, headers=headers)
response.xframe_options_exempt = True
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
except Http404:
raise
except Exception:
# Optional-service boundary, including imports and template rendering.
_LOGGER.exception("Optional AI service failed")
return HttpResponse("AI service unavailable. See server logs.", status=503, content_type="text/plain")
def _request_header(request: HttpRequest, name: str) -> str | None:
meta = getattr(request, "META", {})
if name == "Content-Type":
value = meta.get("CONTENT_TYPE")
elif name == "Content-Length":
value = meta.get("CONTENT_LENGTH")
else:
value = meta.get(f"HTTP_{name.upper().replace('-', '_')}")
return str(value) if value else None
def _request_headers(request: HttpRequest, settings: dict) -> dict[str, str]:
forwarded = {}
for key in ("Accept", "Accept-Language", "Content-Type", "Range", "User-Agent"):
value = _request_header(request, key)
if value:
forwarded[key] = value
return forwarded
def _request_params(request: HttpRequest) -> tuple[tuple[str, str], ...]:
if hasattr(request.GET, "lists"):
return tuple((key, str(value)) for key, values in request.GET.lists() for value in values)
return tuple((key, str(value)) for key, value in dict(request.GET).items())
async def _event_chunks(request: HttpRequest, settings: dict, path: str | None):
timeout = httpx.Timeout(settings["timeout"], read=None)
url = _proxy_url(settings, path)
method = request.method or "GET"
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
async with client.stream(
method,
url,
params=_request_params(request),
headers=_request_headers(request, settings),
) as upstream:
async for chunk in upstream.aiter_raw(chunk_size=512):
yield chunk
def _rewrite_text(body: bytes, settings: dict) -> bytes:
text = body.decode("utf-8", errors="replace")
text = text.replace(settings["origin"], _PROXY_PREFIX)
text = text.replace("location.origin", f'location.origin+"{_PROXY_PREFIX}"')
text = text.replace(
"k(k5,{get component(){return t.router??Az},",
f'k(k5,{{base:"{_PROXY_PREFIX}",get component(){{return t.router??Az}},',
)
text = text.replace('"/assets/', f'"{_PROXY_PREFIX}/assets/')
text = text.replace("'/assets/", f"'{_PROXY_PREFIX}/assets/")
proxy_path = rf'(\1.replace(/^{_PROXY_PREFIX_REGEX}(?=\/|$)/,"")||"/")'
text = re.sub(r"\b(window\.location\.pathname)\b", proxy_path, text)
text = re.sub(r"(?<![.\w])(location\.pathname)\b", proxy_path, text)
text = text.replace(
'window.history.replaceState(nz(o),"",r):window.history.pushState(o,"",r)',
(
f'window.history.replaceState(nz(o),"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r):'
f'window.history.pushState(o,"",r.startsWith("{_PROXY_PREFIX}")?r:r.startsWith("/")?"{_PROXY_PREFIX}"+r:r)'
),
)
text = text.replace(
'const BL="modulepreload",UL=function(t){return"/"+t}',
f'const BL="modulepreload",UL=function(t){{return"{_PROXY_PREFIX}/"+t}}',
)
text = re.sub(
rf"""(?P<prefix>\b(?:href|src|action)=["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
text = re.sub(
rf"""(?P<prefix>\b(?:fetch|EventSource)\(["'])/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
text = re.sub(
rf"""(?P<prefix>\burl\(["']?)/(?!{_PROXY_PREFIX_NO_SLASH_REGEX}(?:/|$))""",
rf"\g<prefix>{_PROXY_PREFIX}/",
text,
)
return text.encode("utf-8")
def _response_headers(upstream: requests.Response, settings: dict) -> dict[str, str]:
headers = {}
for key, value in upstream.headers.items():
lower = key.lower()
if lower in _HOP_BY_HOP_HEADERS or lower in {
"content-length",
"content-encoding",
"x-frame-options",
}:
continue
if lower == "location":
if value.startswith(settings["origin"]):
value = value.replace(settings["origin"], _PROXY_PREFIX, 1)
elif value.startswith("/"):
value = f"{_PROXY_PREFIX}{value}"
headers[key] = value
return headers
def agent_view(request):
return _dispatch(request)
@csrf_exempt
def opencode_proxy_view(request: HttpRequest, path: str | None = None):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
if not _origin_allowed(request, path):
return HttpResponseForbidden(
b"Cross-origin OpenCode agent requests are blocked.",
)
def opencode_proxy_view(request, path=None):
return _dispatch(request, path=path or "")
settings = _settings(config)
route_config = request.__dict__.get("archivebox_config")
base_url, admin_url, api_url = _archivebox_route_urls(request, route_config)
settings["archivebox_base_url"] = base_url
settings["archivebox_admin_url"] = admin_url
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
if not ok:
return HttpResponse(
error.encode(),
status=502,
content_type="text/plain; charset=utf-8",
)
if request.method == "GET" and (path or "").endswith("/event"):
response = StreamingHttpResponse(
_event_chunks(request, settings, path),
content_type="text/event-stream",
)
response.headers["Cache-Control"] = "no-store"
response.headers["X-Accel-Buffering"] = "no"
return response
def _websocket_context(scope):
close_old_connections()
try:
method = request.method or "GET"
upstream = requests.request(
method,
_proxy_url(settings, path),
params=_request_params(request),
data=request.body if method not in {"GET", "HEAD"} else None,
headers=_request_headers(request, settings),
stream=True,
timeout=(settings["timeout"], None),
allow_redirects=False,
)
except requests.RequestException as err:
return HttpResponse(
str(err).encode(),
status=502,
content_type="text/plain; charset=utf-8",
match = resolve(scope["path"])
if match.url_name != "opencode-proxy":
raise PermissionDenied
request = ASGIRequest(
{**scope, "type": "http", "method": "GET", "scheme": "https" if scope.get("scheme") == "wss" else "http"},
BytesIO(),
)
route_config = get_request_config(request, resolve_plugins=False)
request.archivebox_config = route_config
if not route_config.CONTROL_PLANE_ENABLED:
raise PermissionDenied
if (
route_config.USES_SUBDOMAIN_ROUTING
and route_config.BASE_URL
and not host_matches(request.get_host(), get_admin_host(config=route_config, request=request))
):
raise PermissionDenied
SessionMiddleware(_dispatch).process_request(request)
AuthenticationMiddleware(_dispatch).process_request(request)
ReverseProxyAuthMiddleware(_dispatch).process_request(request)
config = route_config.model_dump(mode="json")
if not config.get("OPENCODE_ENABLED") or not request.user.is_active or not request.user.is_superuser:
raise PermissionDenied
runtime, settings = _runtime_settings(request, config)
if not request.headers.get("Origin") or not runtime._origin_allowed("POST", request.get_host(), request.headers):
raise PermissionDenied
return runtime, settings, match.kwargs.get("path", "")
finally:
close_old_connections()
content_type = upstream.headers.get("Content-Type", "")
is_event_stream = content_type.startswith("text/event-stream")
is_text = not is_event_stream and any(content_type.startswith(prefix) for prefix in _TEXT_CONTENT_TYPES)
headers = _response_headers(upstream, settings)
if is_text:
body = _rewrite_text(upstream.content, settings)
response = HttpResponse(
body,
status=upstream.status_code,
content_type=content_type or "text/plain; charset=utf-8",
)
elif is_event_stream:
response = StreamingHttpResponse(
upstream.iter_lines(chunk_size=1),
status=upstream.status_code,
content_type=content_type or "text/event-stream",
)
else:
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=64 * 1024),
status=upstream.status_code,
content_type=content_type or "application/octet-stream",
)
for key, value in headers.items():
response.headers[key] = value
response.headers["Cache-Control"] = "no-store"
return response
async def websocket_view(scope, receive, send):
if (await receive())["type"] != "websocket.connect":
return
try:
runtime, settings, path = await sync_to_async(_websocket_context)(scope)
await runtime.websocket_proxy(settings, path, scope.get("query_string", b""), scope.get("subprotocols", []), receive, send)
except (PermissionDenied, Resolver404, Http404):
await send({"type": "websocket.close", "code": 1008})
except Exception:
_LOGGER.exception("Optional AI WebSocket failed")
await send({"type": "websocket.close", "code": 1011})
+3 -4
View File
@@ -27,7 +27,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
"Persona",
{
"fields": ("name", "created_by", "permissions"),
"classes": ("card", "persona-card-primary"),
"classes": ("card", "wide", "persona-card-primary"),
},
),
(
@@ -42,7 +42,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
"import_extract_cookies",
"import_capture_storage",
),
"classes": ("card", "wide"),
"classes": ("card", "wide", "persona-card-import"),
},
),
(
@@ -63,7 +63,6 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
"classes": ("card", "persona-card-timestamps"),
},
),
add_fieldsets[1],
add_fieldsets[2],
(
"Artifacts",
@@ -136,7 +135,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
return super().get_form(request, obj=obj, change=change, **kwargs)
def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
context["detected_profile_count"] = len(discover_local_browser_profiles())
context["detected_profile_count"] = len(discover_local_browser_profiles()) if add else 0
return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj)
def save_model(self, request, obj, form, change):
+13 -23
View File
@@ -9,11 +9,11 @@
* COOKIES_OUTPUT_FILE Optional output path for Netscape cookies.txt
* AUTH_STORAGE_OUTPUT_FILE Optional output path for auth.json
* CHROME_BINARY Absolute browser path resolved by abxpkg
* CHROME_SANDBOX Set to false when the Linux sandbox is unavailable
* NODE_MODULES_DIR node_modules path resolved by abxpkg
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const pluginsDir = process.env.ARCHIVEBOX_ABX_PLUGINS_DIR || process.env.ABX_PLUGINS_DIR;
@@ -26,7 +26,7 @@ const baseUtils = require(path.join(pluginsDir, 'base', 'utils.js'));
baseUtils.ensureNodeModuleResolution(module);
const chromeUtils = require(path.join(pluginsDir, 'chrome', 'chrome_utils.js'));
const puppeteer = require('puppeteer-core');
const puppeteer = require('puppeteer');
function cookieToNetscape(cookie) {
let domain = cookie.domain;
@@ -115,38 +115,28 @@ async function openBrowser() {
throw new Error(`User data directory does not exist: ${userDataDir}`);
}
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abx-browser-state-'));
const binary = process.env.CHROME_BINARY;
if (!binary || !path.isAbsolute(binary) || !fs.existsSync(binary)) {
throw new Error('CHROME_BINARY must be an absolute executable path resolved by abxpkg');
}
const launched = await chromeUtils.launchChromium({
binary,
outputDir,
// The ordinary archiving launcher uses a mock keychain. An import must
// instead let the original signed browser decrypt its own profile cookies.
const sandboxDisabled = ['false', '0', 'no', 'off'].includes(
String(process.env.CHROME_SANDBOX || '').trim().toLowerCase(),
);
const browser = await puppeteer.launch({
executablePath: binary,
userDataDir,
headless: true,
killZombies: false,
ignoreDefaultArgs: ['--use-mock-keychain', '--password-store=basic'],
args: ['--no-first-run', '--disable-sync', '--disable-background-networking',
'--profile-directory=Default', ...(process.platform === 'linux' && (process.getuid?.() === 0 || sandboxDisabled) ? ['--no-sandbox'] : [])],
});
if (!launched.success) {
throw new Error(launched.error || 'Chrome launch failed');
}
const browser = await chromeUtils.connectToBrowserEndpoint(puppeteer, launched.cdpUrl, { defaultViewport: null });
return {
browser,
async cleanup() {
try {
await browser.disconnect();
} catch (error) {}
try {
await chromeUtils.killChrome(launched.pid, outputDir);
} catch (error) {}
try {
fs.rmSync(outputDir, { recursive: true, force: true });
} catch (error) {}
await browser.close();
},
sourceDescription: userDataDir,
};
+1 -1
View File
@@ -55,7 +55,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
label="Autodiscovered profiles",
widget=forms.RadioSelect,
choices=(),
help_text="Detected from local Chrome, Chromium, Brave, and Edge profile roots.",
help_text="Detected from local Chrome, Chrome Beta, Chromium, Brave, and Edge profile roots.",
)
import_source = forms.CharField(
required=False,
+309 -91
View File
@@ -11,10 +11,14 @@ import json
import os
import platform
import shutil
import sqlite3
import time
from http.cookiejar import MozillaCookieJar
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import urlparse
@@ -28,6 +32,7 @@ if TYPE_CHECKING:
BROWSER_LABELS = {
"chrome": "Google Chrome",
"chrome-beta": "Google Chrome Beta",
"chromium": "Chromium",
"brave": "Brave",
"edge": "Microsoft Edge",
@@ -42,25 +47,27 @@ BROWSER_PROFILE_DIR_NAMES = (
)
VOLATILE_PROFILE_COPY_PATTERNS = (
# Chromium/macOS atomic-write staging files (e.g.
# .com.brave.Browser.TransportSecurity.Ze7UBU) disappear after rename.
".*.??????",
"Cache",
"Code Cache",
"GPUCache",
"ShaderCache",
"Service Worker",
"GCM Store",
"chrome-extension_*",
"DNR Extension Rules",
"Extension Rules",
"Extension Scripts",
"Extension State",
"Local Extension Settings",
"*.log",
"Crashpad",
"BrowserMetrics",
"BrowserMetrics-spare.pma",
"RunningChromeVersion",
"DevToolsActivePort",
"SingletonLock",
"SingletonSocket",
"SingletonCookie",
"Sessions",
"Sessions_Encrypted",
"Current Session",
"Current Tabs",
"Last Session",
"Last Tabs",
)
PERSONA_PROFILE_DIR_CANDIDATES = (
@@ -201,6 +208,7 @@ class PersonaImportResult:
source: PersonaImportSource
profile_copied: bool = False
cookies_imported: bool = False
cookie_count: int = 0
storage_captured: bool = False
user_agent_imported: bool = False
warnings: list[str] = field(default_factory=list)
@@ -210,28 +218,29 @@ class PersonaImportResult:
return self.profile_copied or self.cookies_imported or self.storage_captured or self.user_agent_imported
def get_chrome_user_data_dir() -> Path | None:
def get_chrome_user_data_dir(*, beta: bool = False) -> Path | None:
"""Get the default Chrome user data directory for the current platform."""
system = platform.system()
home = Path.home()
app_name = "Chrome Beta" if beta else "Chrome"
if system == "Darwin":
candidates = [
home / "Library" / "Application Support" / "Google" / "Chrome",
home / "Library" / "Application Support" / "Chromium",
home / "Library" / "Application Support" / "Google" / app_name,
]
elif system == "Linux":
candidates = [
home / ".config" / "google-chrome",
home / ".config" / "chromium",
home / ".config" / "chrome",
home / "snap" / "chromium" / "common" / "chromium",
]
candidates = (
[home / ".config" / "google-chrome-beta"]
if beta
else [
home / ".config" / "google-chrome",
home / ".config" / "chrome",
]
)
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = [
local_app_data / "Google" / "Chrome" / "User Data",
local_app_data / "Chromium" / "User Data",
local_app_data / "Google" / app_name / "User Data",
]
else:
candidates = []
@@ -243,6 +252,21 @@ def get_chrome_user_data_dir() -> Path | None:
return None
def get_chromium_user_data_dir() -> Path | None:
"""Select Chromium's own profile even when Google Chrome is installed."""
home = Path.home()
if platform.system() == "Darwin":
candidates = [home / "Library/Application Support/Chromium"]
elif platform.system() == "Windows":
candidates = [Path(os.environ.get("LOCALAPPDATA", home / "AppData/Local")) / "Chromium/User Data"]
else:
candidates = [
Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")) / "chromium",
home / "snap/chromium/common/chromium",
]
return next((path for path in candidates if path.is_dir() and _list_profile_names(path)), None)
def get_brave_user_data_dir() -> Path | None:
"""Get the default Brave user data directory for the current platform."""
system = platform.system()
@@ -303,7 +327,8 @@ def get_edge_user_data_dir() -> Path | None:
BROWSER_PROFILE_FINDERS = {
"chrome": get_chrome_user_data_dir,
"chromium": get_chrome_user_data_dir,
"chrome-beta": partial(get_chrome_user_data_dir, beta=True),
"chromium": get_chromium_user_data_dir,
"brave": get_brave_user_data_dir,
"edge": get_edge_user_data_dir,
}
@@ -337,6 +362,12 @@ def validate_persona_name(name: str) -> tuple[bool, str]:
def discover_local_browser_profiles() -> list[PersonaImportSource]:
# The macOS companion exports with the host Keychain before creating personas.
# Raw mounts alone cannot decrypt macOS cookies in the Linux container.
if host_profiles := os.environ.get("ARCHIVEBOX_HOST_BROWSER_PROFILES"):
host_templates = discover_persona_template_profiles(Path(host_profiles))
host_names = {source.source_name for source in host_templates}
return host_templates + [source for source in discover_persona_template_profiles() if source.source_name not in host_names]
discovered: list[PersonaImportSource] = []
for browser, finder in BROWSER_PROFILE_FINDERS.items():
@@ -413,13 +444,21 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
def resolve_browser_import_source(browser: str, profile_dir: str | None = None) -> PersonaImportSource:
browser = browser.lower().strip()
if host_profiles := os.environ.get("ARCHIVEBOX_HOST_BROWSER_PROFILES"):
label = BROWSER_LABELS.get(browser, browser)
exported = Path(host_profiles) / f"{label} - {profile_dir or 'Default'}"
if exported.is_dir():
return resolve_custom_import_source(str(exported))
if browser not in BROWSER_PROFILE_FINDERS:
supported = ", ".join(BROWSER_PROFILE_FINDERS)
raise ValueError(f"Unknown browser: {browser}. Supported browsers: {supported}")
user_data_dir = BROWSER_PROFILE_FINDERS[browser]()
if not user_data_dir:
raise ValueError(f"Could not find {browser} profile directory")
raise ValueError(
f"Could not find {browser} profile directory. Use --source /path/to/browser-data. "
"For Docker on macOS/Windows, import on the host into the shared data directory first.",
)
chosen_profile = profile_dir or pick_default_profile_dir(user_data_dir)
if not chosen_profile:
@@ -444,6 +483,8 @@ def resolve_browser_profile_source(
resolved_root = resolved_root.resolve()
if not resolved_root.exists():
raise ValueError(f"Profile root does not exist: {resolved_root}")
if Path(profile_dir).name != profile_dir or profile_dir in {".", ".."}:
raise ValueError("Profile must be a directory name within the source browser root.")
if not profile_dir.strip():
raise ValueError("Profile directory name cannot be empty.")
@@ -476,6 +517,17 @@ def resolve_custom_import_source(raw_value: str, profile_dir: str | None = None)
raise ValueError(f"Custom browser path does not exist: {source_path}")
explicit_profile = profile_dir.strip() if profile_dir else ""
for profile_root in PERSONA_PROFILE_DIR_CANDIDATES:
root = source_path / profile_root
if root.is_dir():
selected = explicit_profile or pick_default_profile_dir(root)
if selected:
return resolve_browser_profile_source(
browser="persona",
source_name=source_path.name,
user_data_dir=root.resolve(),
profile_dir=selected,
)
if _looks_like_profile_dir(source_path):
if explicit_profile and explicit_profile != source_path.name:
raise ValueError("Profile name does not match the provided profile directory path.")
@@ -508,6 +560,48 @@ def pick_default_profile_dir(user_data_dir: Path) -> str | None:
return profiles[0]
def resolve_source_browser_binary(source: PersonaImportSource) -> str:
"""Use the originating browser so OS-protected cookies use the correct keychain."""
if source.browser_binary:
return str(Path(source.browser_binary).expanduser().resolve())
names = {
"chrome": ("Google Chrome", "google-chrome"),
"chrome-beta": ("Google Chrome Beta", "google-chrome-beta"),
"chromium": ("Chromium", "chromium"),
"brave": ("Brave Browser", "brave-browser"),
"edge": ("Microsoft Edge", "microsoft-edge"),
}
app, executable = names.get(source.browser, ("", ""))
candidates = []
if platform.system() == "Darwin" and app:
candidates = [
Path("/Applications") / f"{app}.app/Contents/MacOS/{app}",
Path.home() / f"Applications/{app}.app/Contents/MacOS/{app}",
]
elif platform.system() == "Windows":
relative = {
"chrome": "Google/Chrome/Application/chrome.exe",
"chrome-beta": "Google/Chrome Beta/Application/chrome.exe",
"edge": "Microsoft/Edge/Application/msedge.exe",
"brave": "BraveSoftware/Brave-Browser/Application/brave.exe",
"chromium": "Chromium/Application/chrome.exe",
}.get(source.browser)
if relative:
candidates = [
Path(os.environ[root]) / relative for root in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)") if os.environ.get(root)
]
for candidate in candidates:
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
if executable and (found := shutil.which(executable)):
return found
raise ValueError(
f"Source {source.browser} browser executable not found. Use --browser-binary with the originating Chromium browser. "
"For Docker, run persona create --import on the browser's host into the shared data directory first: "
"the container cannot decrypt cookies using the host's macOS Keychain or Windows credentials.",
)
def import_persona_from_source(
persona: Persona,
source: PersonaImportSource,
@@ -516,67 +610,182 @@ def import_persona_from_source(
import_cookies: bool = True,
capture_storage: bool = False,
) -> PersonaImportResult:
# Stage all work before replacing an existing, working identity.
persona.ensure_dirs()
result = PersonaImportResult(source=source)
expected_cookies = 0
browser_binary = None
native_cookie_browsers = {"chrome", "chromium", "brave", "edge", "vivaldi", "opera", "opera_gx"}
# browser-cookie3 needs a desktop D-Bus session on Linux. In a headless
# environment, let the originating browser decode its own profile instead.
has_native_cookie_decoder = platform.system() != "Linux" or bool(os.environ.get("DBUS_SESSION_BUS_ADDRESS"))
native_cookie_import = source.kind == "browser-profile" and source.browser in native_cookie_browsers and has_native_cookie_decoder
portable_import = source.kind == "browser-profile" and source.browser == "persona"
if source.kind == "browser-profile" and not portable_import and (import_cookies or capture_storage):
if not native_cookie_import:
browser_binary = resolve_source_browser_binary(source)
for cookie_db in (source.profile_path / "Network" / "Cookies", source.profile_path / "Cookies"):
if cookie_db.is_file():
with sqlite3.connect(f"{cookie_db.as_uri()}?mode=ro", uri=True) as conn:
expected_cookies = conn.execute(
"SELECT COUNT(*) FROM cookies WHERE expires_utc = 0 OR expires_utc > ?",
(int((time.time() + 11644473600) * 1_000_000),),
).fetchone()[0]
break
with tempfile.TemporaryDirectory(prefix=".import-", dir=persona.path.parent) as tmp:
stage = Path(tmp)
staged_profile = stage / "chrome_profile"
if source.kind == "browser-profile":
assert source.user_data_dir and source.profile_path
if copy_profile or import_cookies or capture_storage:
staged_profile.mkdir()
# Only the selected profile belongs to this persona. Normalize its
# name so downstream Chromium launches always select it.
copy_browser_user_data_dir(source.profile_path, staged_profile / "Default")
local_state = source.user_data_dir / "Local State"
if local_state.exists():
state = json.loads(local_state.read_text())
profile_state = state.setdefault("profile", {})
profile_state["last_used"] = "Default"
profile_state["last_active_profiles"] = ["Default"]
info = profile_state.get("info_cache", {}).get(source.profile_dir)
profile_state["info_cache"] = {"Default": info} if info else {}
(staged_profile / "Local State").write_text(json.dumps(state))
persona.cleanup_chrome_profile(staged_profile)
result.profile_copied = copy_profile
elif copy_profile:
result.warnings.append(
"CDP imports capture cookies and open-tab storage, not browser settings. Use --source for a full profile import.",
)
persona_chrome_dir = Path(persona.CHROME_USER_DATA_DIR)
cookies_file = persona.path / "cookies.txt"
auth_file = persona.path / "auth.json"
launch_user_data_dir: Path | None = None
if source.kind == "browser-profile":
if copy_profile and source.user_data_dir:
resolved_source_root = source.user_data_dir.resolve()
resolved_persona_root = persona_chrome_dir.resolve()
if resolved_source_root == resolved_persona_root:
result.warnings.append(
"Skipped profile copy because the selected source is already this persona's chrome_profile directory.",
)
if import_cookies or capture_storage:
if portable_import:
export_dir = source.user_data_dir.parent
if not all((export_dir / name).is_file() for name in ("auth.json", "cookies.txt")):
raise ValueError("This persona has no portable cookie export. Re-import it on the original browser host first.")
auth_payload = json.loads((export_dir / "auth.json").read_text())
if not isinstance(auth_payload, dict) or not isinstance(auth_payload.get("cookies"), list):
raise ValueError("Invalid persona auth.json: expected a cookies list.")
for name in ("auth.json", "cookies.txt"):
shutil.copy2(export_dir / name, stage / name)
success, message = True, ""
elif native_cookie_import:
auth_payload = export_profile_cookies(source, staged_profile, stage)
success, message = True, ""
else:
copy_browser_user_data_dir(resolved_source_root, resolved_persona_root)
persona.cleanup_chrome_profile(resolved_persona_root)
result.profile_copied = True
launch_user_data_dir = resolved_persona_root
else:
launch_user_data_dir = source.user_data_dir
elif copy_profile:
result.warnings.append(
"Profile copying is only available for local Chromium profile paths. CDP imports can only pull cookies and open-tab storage.",
)
if not import_cookies and not capture_storage:
return result
if source.kind == "cdp":
export_success, auth_payload, export_message = export_browser_state(
cdp_url=source.cdp_url,
cookies_output_file=cookies_file if import_cookies else None,
auth_output_file=auth_file if capture_storage else None,
)
else:
export_success, auth_payload, export_message = export_browser_state(
user_data_dir=launch_user_data_dir,
profile_dir=source.profile_dir,
chrome_binary=source.browser_binary,
cookies_output_file=cookies_file if import_cookies else None,
auth_output_file=auth_file if capture_storage else None,
)
if not export_success:
result.warnings.append(export_message or "Browser import failed.")
return result
if import_cookies and cookies_file.exists():
result.cookies_imported = True
if capture_storage and auth_file.exists():
result.storage_captured = True
if _apply_imported_user_agent(persona, auth_payload):
result.user_agent_imported = True
# Custom browsers and headless Linux use the originating browser.
# Desktop imports use their OS decoder without launching or probing.
launch_profile = stage / "export_profile"
if source.kind == "browser-profile":
copy_browser_user_data_dir(staged_profile, launch_profile)
success, auth_payload, message = export_browser_state(
user_data_dir=launch_profile if source.kind == "browser-profile" else None,
cdp_url=source.cdp_url,
profile_dir="Default" if source.kind == "browser-profile" else None,
chrome_binary=browser_binary,
cookies_output_file=stage / "cookies.txt" if import_cookies else None,
auth_output_file=stage / "auth.json",
)
if not success:
raise ValueError(message or "Browser state export failed; persona was not replaced.")
if expected_cookies and not (auth_payload or {}).get("cookies"):
raise ValueError(
"The source has cookies but the browser exported none. Run the import on the source host "
"with the original browser and unlock its OS keychain; the existing persona was not replaced.",
)
if import_cookies:
result.cookies_imported = True
result.cookie_count = len((auth_payload or {}).get("cookies", []))
if capture_storage:
result.storage_captured = True
# Keep full CDP cookie attributes (sameSite, httpOnly, partition keys)
# as well as cookies.txt for non-browser extractors.
result.user_agent_imported = _apply_imported_user_agent(persona, auth_payload)
if result.profile_copied:
target = Path(persona.CHROME_USER_DATA_DIR)
if target.exists():
shutil.rmtree(target)
staged_profile.rename(target)
for filename in ("cookies.txt", "auth.json"):
exported = stage / filename
if exported.exists():
exported.chmod(0o600)
exported.replace(persona.path / filename)
return result
def export_profile_cookies(source: PersonaImportSource, staged_profile: Path, output: Path) -> dict:
"""Decode with the host keychain, never by starting the user's browser."""
import browser_cookie3
payload = {"TYPE": "auth", "cookies": [], "localStorage": {}, "sessionStorage": {}}
jar = MozillaCookieJar(str(output / "cookies.txt"))
assert source.profile_path
for relative in (Path("Network/Cookies"), Path("Cookies")):
original = source.profile_path / relative
if not original.is_file():
continue
database = staged_profile / "Default" / relative
for suffix in ("", "-wal", "-shm"):
database.with_name(database.name + suffix).unlink(missing_ok=True)
# SQLite backup includes committed WAL data even while the browser is open.
with sqlite3.connect(f"{original.as_uri()}?mode=ro", uri=True) as reader:
with sqlite3.connect(database) as writer:
reader.backup(writer)
try:
cookies = getattr(browser_cookie3, source.browser)(
cookie_file=str(database),
key_file=str(staged_profile / "Local State"),
)
except Exception as err:
raise ValueError(
"Could not decrypt browser cookies. Run the import as your desktop user on the browser host "
"with its OS keychain unlocked. Mounting macOS/Windows browser files into Linux does not provide "
"the decryption keys. The existing persona was not replaced.",
) from err
with sqlite3.connect(database) as connection:
connection.row_factory = sqlite3.Row
columns = {row[1] for row in connection.execute("PRAGMA table_info(cookies)")}
metadata_columns = [
name
for name in ("host_key", "path", "name", "samesite", "top_frame_site_key", "has_cross_site_ancestor")
if name in columns
]
rows = {
(row["host_key"], row["path"], row["name"]): dict(row)
for row in connection.execute("SELECT " + ", ".join(metadata_columns) + " FROM cookies")
}
for cookie in cookies:
if cookie.is_expired():
continue
jar.set_cookie(cookie)
item = {
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": bool(cookie.secure),
"httpOnly": cookie.has_nonstandard_attr("HTTPOnly"),
}
if cookie.expires:
item["expires"] = cookie.expires
row = rows.get((cookie.domain, cookie.path, cookie.name), {})
same_site = {0: "None", 1: "Lax", 2: "Strict"}.get(row.get("samesite"))
if same_site:
item["sameSite"] = same_site
if row.get("top_frame_site_key"):
item["partitionKey"] = {
"topLevelSite": row["top_frame_site_key"],
"hasCrossSiteAncestor": bool(row.get("has_cross_site_ancestor", False)),
}
payload["cookies"].append(item)
break
jar.save(ignore_discard=True, ignore_expires=False)
(output / "auth.json").write_text(json.dumps(payload) + "\n")
return payload
def copy_browser_user_data_dir(source_dir: Path, destination_dir: Path) -> None:
destination_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(destination_dir, ignore_errors=True)
@@ -611,21 +820,27 @@ def export_browser_state(
chrome_config = chrome_plugin_dir / "chrome" / "config.json"
env = os.environ.copy()
if chrome_binary:
env["CHROME_BINARY"] = str(chrome_binary)
dependency_env = subprocess.run(
[
str(Path(sys.executable).with_name("abxpkg")),
"env",
"--install",
"--json",
f"--lib={get_config().ABXPKG_LIB_DIR}",
f"--deps-from={chrome_config}:required_binaries",
],
capture_output=True,
text=True,
env=env,
)
dependency_config = json.loads(chrome_config.read_text())
dependency_config["required_binaries"] = [dep for dep in dependency_config["required_binaries"] if dep["name"] != "{CHROME_BINARY}"]
dependency_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
with dependency_file:
json.dump(dependency_config, dependency_file)
try:
dependency_env = subprocess.run(
[
str(Path(sys.executable).with_name("abxpkg")),
"env",
"--install",
"--json",
f"--lib={get_config().ABXPKG_LIB_DIR}",
f"--deps-from={dependency_file.name}:required_binaries",
],
capture_output=True,
text=True,
env=env,
)
finally:
Path(dependency_file.name).unlink()
if dependency_env.returncode != 0:
return False, None, dependency_env.stderr.strip() or "abxpkg could not resolve browser export dependencies."
try:
@@ -639,6 +854,9 @@ def export_browser_state(
if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK):
return False, None, f"abxpkg did not resolve Node.js into {node_projection}."
env.update({str(key): str(value) for key, value in resolved_env.items()})
if chrome_binary:
env["CHROME_BINARY"] = chrome_binary
env["NODE_MODULES_DIR"] = str(abxpkg_lib_dir / "pnpm" / "packages" / "chrome" / "node_modules")
env["NODE_BINARY"] = str(node_projection)
env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(chrome_plugin_dir)
@@ -865,7 +1083,7 @@ def _apply_imported_user_agent(persona: Persona, auth_payload: dict | None) -> b
if not auth_payload:
return False
user_agent = str(auth_payload.get("user_agent") or "").strip()
user_agent = str(auth_payload.get("user_agent") or "").strip().replace("HeadlessChrome/", "Chrome/")
if not user_agent:
return False
+29 -3
View File
@@ -45,17 +45,23 @@ VOLATILE_PROFILE_DIR_NAMES = {
"Code Cache",
"GPUCache",
"ShaderCache",
"Service Worker",
"GCM Store",
"Crashpad",
"BrowserMetrics",
"Sessions",
"Sessions_Encrypted",
}
VOLATILE_PROFILE_FILE_NAMES = {
"BrowserMetrics-spare.pma",
"RunningChromeVersion",
"DevToolsActivePort",
"SingletonCookie",
"SingletonLock",
"SingletonSocket",
"Current Session",
"Current Tabs",
"Last Session",
"Last Tabs",
}
@@ -211,7 +217,7 @@ class Persona(ModelWithConfig):
cleaned = True
for path in profile_paths():
if not path.match("*.log"):
if path.name not in {"chrome.log", "chrome_debug.log"}:
continue
try:
path.unlink()
@@ -297,6 +303,10 @@ class Persona(ModelWithConfig):
else:
runtime_profile_dir.mkdir(parents=True, exist_ok=True)
for filename in ("cookies.txt", "auth.json"):
source = self.path / filename
if source.is_file():
shutil.copy2(source, runtime_root / filename)
runtime_downloads_dir.mkdir(parents=True, exist_ok=True)
self.cleanup_chrome_profile(runtime_profile_dir)
@@ -313,6 +323,11 @@ class Persona(ModelWithConfig):
# derivation centralized in the Chrome plugin helpers.
"PERSONAS_DIR": str(runtime_root.parent),
"ACTIVE_PERSONA": self.name,
**{
key: str(runtime_root / filename)
for key, filename in (("COOKIES_FILE", "cookies.txt"), ("AUTH_STORAGE_FILE", "auth.json"))
if (runtime_root / filename).is_file()
},
}
def prepare_runtime_for_snapshot(self, snapshot, chrome_binary: str = "") -> dict[str, str]:
@@ -329,6 +344,12 @@ class Persona(ModelWithConfig):
else:
runtime_profile_dir.mkdir(parents=True, exist_ok=True)
for filename in ("cookies.txt", "auth.json"):
source = self.runtime_root_for_crawl(snapshot.crawl) / filename
if not source.is_file():
source = self.path / filename
if source.is_file():
shutil.copy2(source, runtime_root / filename)
runtime_downloads_dir.mkdir(parents=True, exist_ok=True)
self.cleanup_chrome_profile(runtime_profile_dir)
@@ -343,6 +364,11 @@ class Persona(ModelWithConfig):
# Chrome hooks and ArchiveBox-driven hooks resolve paths the same way.
"PERSONAS_DIR": str(runtime_root.parent),
"ACTIVE_PERSONA": self.name,
**{
key: str(runtime_root / filename)
for key, filename in (("COOKIES_FILE", "cookies.txt"), ("AUTH_STORAGE_FILE", "auth.json"))
if (runtime_root / filename).is_file()
},
}
def cleanup_runtime_for_crawl(self, crawl) -> None:
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const pluginsDir = process.env.ARCHIVEBOX_ABX_PLUGINS_DIR;
const {ensureNodeModuleResolution} = require(path.join(pluginsDir, 'base', 'utils.js'));
ensureNodeModuleResolution(module);
const installShutdownHandler = require(path.join(pluginsDir, 'base', 'daemon_lifecycle.js')).captureShutdownSignals();
const {
acquireSessionLock,
ensureChromeSession,
getChromeSessionOptionsFromConfig,
getChromeLaunchPrerequisites,
connectToBrowserEndpoint,
closeBrowserInChromeSession,
} = require(path.join(pluginsDir, 'chrome', 'chrome_utils.js'));
async function main() {
const config = JSON.parse(fs.readFileSync(0, 'utf8'));
const outputDir = path.join(path.dirname(config.CHROME_USER_DATA_DIR), '.browser');
fs.mkdirSync(outputDir, {recursive: true});
const release = await acquireSessionLock(path.join(outputDir, 'launch.lock'));
let session;
const {puppeteer, binary} = getChromeLaunchPrerequisites();
try {
session = await ensureChromeSession({
...getChromeSessionOptionsFromConfig(config),
outputDir, puppeteer, binary,
});
} finally {
release();
}
const browser = await connectToBrowserEndpoint(puppeteer, session.cdpUrl, {defaultViewport: null});
installShutdownHandler(async () => {
if (!session.reusedExisting) {
await closeBrowserInChromeSession({
outputDir, puppeteer, cdpUrl: session.cdpUrl, pid: session.pid, processIsLocal: true,
});
}
await browser.disconnect();
process.exit(0);
});
const pages = await browser.pages();
const page = pages[0] || await browser.newPage();
const windowSession = await page.createCDPSession();
const {windowId} = await windowSession.send('Browser.getWindowForTarget');
await windowSession.send('Browser.setWindowBounds', {windowId, bounds: {windowState: 'maximized'}});
await windowSession.detach();
await page.bringToFront();
console.log(`Browser ready for persona ${config.ACTIVE_PERSONA || 'Default'}`);
if (session.reusedExisting) {
await browser.disconnect();
return;
}
await new Promise(resolve => browser.once('disconnected', resolve));
}
main().catch(error => {
console.error(error.message);
process.exit(1);
});
+166 -170
View File
@@ -1,12 +1,14 @@
__package__ = "archivebox.plugins"
import json
from collections.abc import Iterable
from functools import lru_cache
from pathlib import Path
from importlib import import_module
from pathlib import Path, PurePosixPath
from types import ModuleType
from typing import Any, Protocol, TypedDict
from abx_plugins import get_plugins_dir
from abx_dl.catalog import PluginCatalog, PluginConfigResolver
from django.utils.safestring import mark_safe
from archivebox.config.constants import CONSTANTS
@@ -29,18 +31,18 @@ USER_PLUGINS_DIR = CONSTANTS.USER_PLUGINS_DIR
def iter_plugin_dirs() -> list[Path]:
"""Iterate over all built-in and user plugin directories."""
plugin_dirs: list[Path] = []
"""Return the exact plugin directories exposed by the shared catalog."""
return [plugin.path for plugin in get_plugin_catalog().values()]
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
if not base_dir.exists():
continue
for plugin_dir in base_dir.iterdir():
if plugin_dir.is_dir() and not plugin_dir.name.startswith("_"):
plugin_dirs.append(plugin_dir)
@lru_cache(maxsize=1)
def get_plugin_catalog() -> PluginCatalog:
return PluginCatalog.discover(extra_plugin_dirs=[USER_PLUGINS_DIR], runtime="archivebox")
return plugin_dirs
@lru_cache(maxsize=1)
def get_plugin_config_resolver() -> PluginConfigResolver:
return PluginConfigResolver(get_plugin_catalog())
@lru_cache(maxsize=1)
@@ -52,25 +54,7 @@ def get_plugins() -> list[str]:
or a standardized templates/icon.html asset. This includes non-extractor
plugins such as binary providers and shared base plugins.
"""
plugins = []
for plugin_dir in iter_plugin_dirs():
has_hooks = any(plugin_dir.glob("on_*__*.*"))
has_config = (plugin_dir / "config.json").exists()
has_icon = (plugin_dir / "templates" / "icon.html").exists()
if has_hooks or has_config or has_icon:
plugins.append(plugin_dir.name)
return sorted(set(plugins))
def get_plugin_models():
from abx_dl.models import discover_plugins
plugins = {}
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
plugins.update(discover_plugins(plugins_dir=base_dir, runtime="archivebox"))
return plugins
return sorted(get_plugin_catalog())
def get_plugin_name(plugin: str) -> str:
@@ -84,8 +68,141 @@ def get_plugin_name(plugin: str) -> str:
"""
parts = plugin.split("_", 1)
if len(parts) == 2 and parts[0].isdigit():
return parts[1]
return plugin
plugin = parts[1]
return get_archive_result_aliases().get(plugin, plugin)
@lru_cache(maxsize=1)
def get_archive_result_aliases() -> dict[str, str]:
aliases: dict[str, str] = {}
for plugin in get_plugin_catalog().values():
for alias in plugin.manifest.get("archive_result_aliases") or []:
alias = str(alias or "").strip()
if alias:
aliases[alias] = plugin.name
return aliases
def get_archive_result_names(plugin_name: str) -> tuple[str, ...]:
"""Return the canonical ArchiveResult name and its declared historic aliases."""
canonical = get_plugin_name(plugin_name)
aliases = get_archive_result_aliases()
return tuple(dict.fromkeys((canonical, plugin_name, *(alias for alias, target in aliases.items() if target == canonical))))
def plugin_card_is_interactive(plugin_name: str) -> bool:
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
return bool(plugin and plugin.manifest.get("card_interactive"))
def get_plugin_output_extension_preference(plugin_name: str) -> tuple[tuple[str, ...], ...] | None:
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
raw_groups = plugin.manifest.get("output_extension_preference") if plugin else None
if not isinstance(raw_groups, list):
return None
groups = []
for raw_group in raw_groups:
if not isinstance(raw_group, list):
continue
group = tuple(str(extension).lower() for extension in raw_group if str(extension).startswith("."))
if group:
groups.append(group)
return tuple(groups) or None
def get_plugin_default_output_path(plugin_name: str) -> str | None:
prefix, separator, base_name = plugin_name.partition("_")
archive_result_name = base_name if separator and prefix.isdigit() else plugin_name
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
raw_path = str(plugin.manifest.get("default_output_path") or "").strip() if plugin else ""
path = PurePosixPath(raw_path)
if not raw_path or raw_path.startswith("/") or ".." in path.parts or len(path.parts) != 1:
return None
return f"{archive_result_name}/{raw_path}"
@lru_cache(maxsize=1)
def get_snapshot_thumbnail_card_order() -> dict[str, int]:
"""Return plugin-owned ArchiveResult names eligible for snapshot thumbnails."""
ordering: dict[str, int] = {}
for plugin in get_plugin_catalog().values():
declarations = plugin.manifest.get("snapshot_thumbnail_cards") or []
if not isinstance(declarations, list):
continue
for declaration in declarations:
if not isinstance(declaration, dict):
continue
result_name = str(declaration.get("archive_result_name") or "").strip()
order = declaration.get("order")
if not result_name or isinstance(order, bool) or not isinstance(order, int):
continue
ordering[result_name] = order
return ordering
@lru_cache(maxsize=None)
def get_snapshot_role_names(role: str) -> tuple[str, ...]:
"""Return ArchiveResult names for a plugin-owned snapshot presentation role."""
manifest_key = f"snapshot_{role}"
names = []
for plugin in get_plugin_catalog().values():
if plugin.manifest.get(manifest_key):
names.extend(get_archive_result_names(plugin.name))
return tuple(dict.fromkeys(names))
def get_snapshot_role_result(results, role: str):
"""Select the first successful result declared for a presentation role."""
results_by_name = {result.plugin: result for result in results if result.status == "succeeded"}
return next((results_by_name[name] for name in get_snapshot_role_names(role) if name in results_by_name), None)
@lru_cache(maxsize=1)
def get_plugin_presentation_modules() -> tuple[ModuleType, ...]:
"""Load optional plugin-owned presentation hooks declared in manifests."""
return tuple(module for plugin in get_plugin_catalog().values() if (module := get_plugin_presentation_module(plugin.name)) is not None)
@lru_cache(maxsize=None)
def get_plugin_presentation_module(plugin_name: str) -> ModuleType | None:
"""Load one plugin's optional presentation hooks module."""
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
module_name = str(plugin.manifest.get("presentation_module") or "").strip() if plugin else ""
return import_module(module_name) if module_name else None
def plugin_has_custom_full_response(plugin_name: str) -> bool:
"""Return whether a plugin renders its full template through a response hook."""
module = get_plugin_presentation_module(plugin_name)
return bool(module and getattr(module, "render_full_response", None))
def get_extra_snapshot_output_cards(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Collect additional cards from plugin-owned presentation hooks."""
cards = []
for module in get_plugin_presentation_modules():
hook = getattr(module, "extra_snapshot_output_cards", None)
if hook is not None:
cards.extend(hook(outputs) or ())
return cards
def serve_plugin_replay_asset(path: str, config, response_class):
"""Give plugin presentation hooks the first chance to serve replay assets."""
for module in get_plugin_presentation_modules():
hook = getattr(module, "serve_replay_asset_response", None)
if hook is not None and (response := hook(path, config, response_class)) is not None:
return response
return None
def render_plugin_full_response(path: str, output_url: str, **kwargs):
"""Let plugin presentation hooks render a saved output's full template."""
for module in get_plugin_presentation_modules():
render = getattr(module, "render_full_response", None)
if render is not None and (response := render(path, output_url, **kwargs)) is not None:
return response
return None
def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any) -> list[str]:
@@ -99,91 +216,17 @@ def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any
config = get_config(**config_kwargs)
enabled = []
disabled = []
for plugin in get_plugins():
plugin_config = get_plugin_special_config(plugin, config)
if plugin_config["enabled"]:
enabled.append(plugin)
else:
disabled.append(plugin)
from abx_dl.models import filter_plugins
return list(filter_plugins(get_plugin_models(), enabled, include_providers=True, disabled_names=disabled))
return get_plugin_config_resolver().enabled_plugin_names_from_flat(dict(config.items()))
def discover_plugins_that_provide_interface(
module_name: str,
required_attrs: list[str],
plugin_prefix: str | None = None,
) -> dict[str, Any]:
"""
Discover plugins that provide a specific Python module with required interface.
This enables dynamic plugin discovery for features like search backends,
storage backends, etc. without hardcoding imports.
"""
import importlib.util
backends = {}
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
if not base_dir.exists():
continue
for plugin_dir in base_dir.iterdir():
if not plugin_dir.is_dir():
continue
plugin_name = plugin_dir.name
if plugin_prefix and not plugin_name.startswith(plugin_prefix):
continue
module_path = plugin_dir / f"{module_name}.py"
if not module_path.exists():
continue
try:
spec = importlib.util.spec_from_file_location(
f"archivebox.dynamic_plugins.{plugin_name}.{module_name}",
module_path,
)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not all(attr in vars(module) for attr in required_attrs):
continue
if plugin_prefix:
backend_name = plugin_name[len(plugin_prefix) :]
else:
backend_name = plugin_name
backends[backend_name] = module
except Exception:
continue
return backends
def get_search_backends() -> dict[str, Any]:
"""
Discover all available search backend plugins.
Search backends must provide a search.py module with:
- search(query: str) -> List[str] (returns snapshot IDs)
- flush(snapshot_ids: Iterable[str]) -> None
"""
return discover_plugins_that_provide_interface(
module_name="search",
required_attrs=["search", "flush"],
plugin_prefix="search_backend_",
)
def get_search_backends():
"""Return plugins that declare both standalone search commands."""
catalog = get_plugin_catalog()
return {
plugin.name.removeprefix("search_backend_"): plugin
for plugin in catalog.values()
if catalog.command(plugin.name, "search") is not None and catalog.command(plugin.name, "flush") is not None
}
@lru_cache(maxsize=1)
@@ -196,33 +239,7 @@ def discover_plugin_configs() -> dict[str, dict[str, Any]]:
schemas are plugin package metadata, not live user config; runtime values
still come from env/db config at each callsite.
"""
configs = {}
for plugin_dir in iter_plugin_dirs():
config_path = plugin_dir / "config.json"
if not config_path.exists():
continue
try:
with open(config_path) as f:
schema = json.load(f)
if not isinstance(schema, dict):
continue
if schema.get("type") != "object":
continue
if "properties" not in schema:
continue
configs[plugin_dir.name] = schema
except (json.JSONDecodeError, OSError) as e:
import sys
print(f"Warning: Failed to load config.json from {plugin_dir.name}: {e}", file=sys.stderr)
continue
return configs
return get_plugin_config_resolver().schemas
def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited: set[str] | None = None) -> PluginSpecialConfig:
@@ -234,26 +251,7 @@ def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited:
- {PLUGIN}_TIMEOUT: Plugin-specific timeout (fallback to TIMEOUT, default 300)
- {PLUGIN}_BINARY: Primary binary path (default to plugin_name)
"""
plugin_upper = plugin_name.upper()
enabled_key = f"{plugin_upper}_ENABLED"
enabled = config.get(enabled_key)
if enabled is None:
enabled = True
elif isinstance(enabled, str):
enabled = enabled.lower() not in ("false", "0", "no", "")
timeout_key = f"{plugin_upper}_TIMEOUT"
timeout = config.get(timeout_key) or config.get("TIMEOUT", 300)
binary_key = f"{plugin_upper}_BINARY"
binary = config.get(binary_key, plugin_name)
return {
"enabled": bool(enabled),
"timeout": int(timeout),
"binary": str(binary),
}
return get_plugin_config_resolver().runtime_settings(plugin_name, dict(config.items()))
DEFAULT_TEMPLATES = {
@@ -288,18 +286,16 @@ def get_plugin_template(plugin: str, template_name: str, fallback: bool = True)
Args:
plugin: Plugin name (e.g., 'screenshot', '15_singlefile')
template_name: One of 'icon', 'card', 'full'
template_name: A template slot such as 'icon', 'card', or 'full'.
fallback: If True, return default template if plugin template not found
"""
base_name = get_plugin_name(plugin)
if base_name in ("yt-dlp", "youtube-dl"):
base_name = "ytdlp"
for plugin_dir in iter_plugin_dirs():
if plugin_dir.name == base_name or plugin_dir.name.endswith(f"_{base_name}"):
template_path = plugin_dir / "templates" / f"{template_name}.html"
if template_path.exists():
return template_path.read_text()
catalog = get_plugin_catalog()
if base_name in catalog:
template_path = catalog.template_path(base_name, template_name)
if template_path is not None:
return template_path.read_text()
if fallback:
return DEFAULT_TEMPLATES.get(template_name, "")
+21 -138
View File
@@ -11,133 +11,19 @@ from django.utils.html import format_html
from archivebox.config import CONSTANTS_CONFIG
from archivebox.config.common import ArchiveBoxConfig, get_config
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_catalog, get_plugin_icon, get_plugins
PLUGIN_CONFIG_FIELD_PREFIX = "plugin_config__"
PLUGIN_GROUP_DEFINITIONS = (
(
"main_plugins",
"Main",
"",
"",
"",
(
"dom",
"screenshot",
"pdf",
"singlefile",
"wget",
"archivedotorg",
"chrome_mhtml",
"archivewebpage",
),
),
(
"page_setup_plugins",
"Page Setup",
"",
"",
"",
(
"chrome",
"infiniscroll",
"modalcloser",
"ublock",
"istilldontcareaboutcookies",
"twocaptcha",
"claudechrome",
),
),
(
"media_plugins",
"Media",
"",
"",
"",
(
"staticfile",
"responses",
"chrome_screencast",
"ytdlp",
"gallerydl",
"git",
),
),
(
"text_plugins",
"Text",
"",
"",
"",
(
"readability",
"htmltotext",
"defuddle",
"forumdl",
"mercury",
"trafilatura",
"liteparse",
"opendataloader",
"papersdl",
),
),
(
"metadata_plugins",
"Metadata",
"",
"",
"",
(
"title",
"favicon",
"headers",
"redirects",
"accessibility",
"consolelog",
"sslcerts",
"dns",
"seo",
"hashes",
),
),
(
"postprocessing_plugins",
"Postprocessing",
"",
"",
"",
(
"parse_dom_outlinks",
"parse_html_urls",
"parse_jsonl_urls",
"parse_netscape_urls",
"parse_rss_urls",
"parse_txt_urls",
"claudecode",
"claudecodecleanup",
"claudecodeextract",
),
),
PLUGIN_GROUPS = (
("main", "main_plugins", "Main"),
("page_setup", "page_setup_plugins", "Page Setup"),
("media", "media_plugins", "Media"),
("text", "text_plugins", "Text"),
("metadata", "metadata_plugins", "Metadata"),
("postprocessing", "postprocessing_plugins", "Postprocessing"),
("other", "other_plugins", "Other"),
)
HIDDEN_PLUGIN_CONFIG_UI_PLUGINS = {
"apt",
"base",
"bash",
"brew",
"cargo",
"chromewebstore",
"env",
"media",
"npm",
"opencode",
"pip",
"puppeteer",
"search_backend_ripgrep",
"search_backend_sonic",
"search_backend_sqlite",
"ssl",
}
TIMEOUT_INPUT_PATTERN = r"(0|[1-9][0-9]*|[0-9]+(?:\.[0-9]+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours))"
@@ -255,26 +141,21 @@ class PluginConfigFormMixin:
allow_crawl_execution_config_fields = True
def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None:
all_plugins = get_plugins()
catalog = get_plugin_catalog()
all_plugins = set(catalog)
plugin_configs = discover_plugin_configs()
runtime_config = runtime_config or get_config()
self.plugin_config_binary_urls = get_plugin_config_binary_urls(runtime_config)
grouped_plugins = set().union(*(group[-1] for group in PLUGIN_GROUP_DEFINITIONS))
other_plugins = tuple(sorted(set(all_plugins) - grouped_plugins - HIDDEN_PLUGIN_CONFIG_UI_PLUGINS))
grouped_plugins = catalog.groups()
for field_name, *_rest, plugin_names in PLUGIN_GROUP_DEFINITIONS:
group_specs = []
for category, field_name, title in PLUGIN_GROUPS:
plugin_names = tuple(plugin.name for plugin in grouped_plugins.get(category, []))
group_specs.append((field_name, title, "", "", "", plugin_names))
if field_name in self.fields:
get_choice_field(self, field_name).choices = [
(p, get_plugin_choice_label(p, plugin_configs)) for p in plugin_names if p in all_plugins
]
if "other_plugins" in self.fields:
get_choice_field(self, "other_plugins").choices = [(p, get_plugin_choice_label(p, plugin_configs)) for p in other_plugins]
group_specs = (
*PLUGIN_GROUP_DEFINITIONS,
("other_plugins", "Other", "", "", "", other_plugins),
)
binary_url_lookup = _build_required_binary_url_lookup(plugin_configs, runtime_config)
self.plugin_groups = [
{
@@ -332,7 +213,7 @@ class PluginConfigFormMixin:
"enabled_config_key": enabled_config_key,
"description": str(schema.get("description") or "").strip(),
"source_url": f"https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/{plugin_name}",
"docs_url": f"https://archivebox.github.io/abx-plugins/#{plugin_name}",
"docs_url": f"https://plugins.archivebox.io/#{plugin_name}",
"required_plugins": [str(item) for item in schema.get("required_plugins") or []],
"required_binary_links": _build_required_binary_links(
schema.get("required_binaries") or [],
@@ -395,7 +276,7 @@ class PluginConfigFormMixin:
elif is_sensitive:
input_type = "password"
else:
input_value = "" if is_sensitive else str(current_value)
input_value = "" if current_value is None else str(current_value)
return {
"key": config_key,
@@ -409,7 +290,7 @@ class PluginConfigFormMixin:
"default": _jsonish(default_display),
"current": "configured"
if is_sensitive and current_value
else (str(current_value) if "string" in schema_types else _jsonish(current_value)),
else (("" if current_value is None else str(current_value)) if "string" in schema_types else _jsonish(current_value)),
"current_url": self.plugin_config_binary_urls.get(config_key, "") if str(config_key).endswith("_BINARY") else "",
"is_sensitive": is_sensitive,
"minimum": prop_schema.get("minimum"),
@@ -461,6 +342,8 @@ class PluginConfigFormMixin:
continue
base_value = effective_config.get(config_key, prop_schema.get("default", ""))
if base_value is None and coerced_value == "":
continue
if _same_config_value(coerced_value, base_value):
continue
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff. Show more