Compare commits

...
893 Commits
Author SHA1 Message Date
Nick Sweeting 7387d4b93c fix pihole compose setup 2023-11-08 00:14:07 -08:00
Nick Sweeting fd9803f3b7 chown PLAYWRIGHT_BROWSERS_PATH in entrypoint to fix archivebox setup 2023-11-07 01:49:48 -08:00
Nick Sweeting c808e53b44 chown PLAYWRIGHT_BROWSERS_PATH after installing so archivebox setup can run 2023-11-07 01:42:16 -08:00
Nick Sweeting 56f27fdb13 remove dev packages from lockfiles 2023-11-03 21:54:17 -07:00
Nick Sweeting 07b9710970 update pip dist script 2023-11-03 21:35:08 -07:00
Nick Sweeting 1323b812ce fix brew release process 2023-11-03 21:30:28 -07:00
Nick Sweeting ebb716514d Update README.md security notice about extractors 2023-11-03 21:17:37 -07:00
Nick Sweeting 5c4a50557b bump brew dist version 2023-11-03 20:31:09 -07:00
Nick Sweeting 7377d9ebc3 bump version to 0.7.1 and fix version autodetection code 2023-11-03 20:07:39 -07:00
Nick Sweeting 907d170614 add pdm lockfile changes 2023-11-03 19:14:42 -07:00
Nick Sweeting 5ce77d3d0b Update pip.yml 2023-11-03 19:12:19 -07:00
Nick Sweeting c6e5a565c0 switch back to locked cache instead of private but put targetarch in cache id to separate them 2023-10-31 19:43:41 -07:00
Nick Sweeting 57f033be60 add targetos to build args 2023-10-31 19:43:01 -07:00
Nick Sweeting db733151f7 bump user agent browser version 2023-10-31 19:16:15 -07:00
Nick Sweeting 7283e9b91d fix docker build tags 2023-10-31 19:11:14 -07:00
Nick Sweeting 00d671240e use private caches for each architecture build so as not to pollute caches with bad binaries 2023-10-31 16:06:19 -07:00
Nick Sweeting 41bd7c6480 fix cache locking errors during parallel builds 2023-10-31 14:59:34 -07:00
Nick Sweeting 2c692191ef fixes for ci 2023-10-31 06:48:55 -07:00
Nick Sweeting bc555cca96 working arm v7 build 2023-10-31 06:28:11 -07:00
Nick Sweeting 12c966477f bump docker build action ci versions 2023-10-31 05:37:26 -07:00
Nick Sweeting 43a020c534 fewer packages 2023-10-31 05:33:30 -07:00
Nick Sweeting 211ca363b5 better dockerfiles caching 2023-10-31 05:31:19 -07:00
Nick Sweeting 562fd5a3bb revert back to python-3.11 base image and add cache dirs for massive build speed increase 2023-10-31 03:58:24 -07:00
Nick Sweeting ce4df6d965 use global pip cache 2023-10-31 03:19:35 -07:00
Nick Sweeting da80a41afb use bookworm-backports for everything 2023-10-31 03:11:53 -07:00
Nick Sweeting 47d9161168 fix Dockerfile 2023-10-31 03:06:02 -07:00
Nick Sweeting aabc89d6a7 add requirements.txt 2023-10-31 01:06:57 -07:00
Nick Sweeting f0d51c377b attempt fix for python build 2023-10-31 01:05:39 -07:00
Nick Sweeting 214016d8a1 fix broken arg 2023-10-31 00:31:14 -07:00
Nick Sweeting b87af19235 use pdm sync instead of pdm install in docker 2023-10-31 00:30:27 -07:00
Nick Sweeting 479a6f44ea dont install site packages as editable 2023-10-31 00:27:31 -07:00
Nick Sweeting 166573d417 autodetect qemu and warn about broken chrome in emulated containers 2023-10-31 00:23:19 -07:00
Nick Sweeting 32e363848e install all optionall dependencies in docker 2023-10-31 00:00:09 -07:00
Nick Sweeting 1bfbf5e9c3 add pip packages build step 2023-10-30 23:58:13 -07:00
Nick Sweeting ce76c324a5 autodetect uid and gid from data dir contents 2023-10-30 23:50:27 -07:00
Nick Sweeting 019c012951 fix entrypoint chowning 2023-10-30 23:42:12 -07:00
Nick Sweeting a677ef7113 fix conditional on architecture 2023-10-30 23:34:26 -07:00
Nick Sweeting 2ab4be71c1 Add instructions to run specific branch in docker compose 2023-10-30 23:33:29 -07:00
Nick Sweeting d46c9b9bc8 bump pip dist folder 2023-10-30 23:28:49 -07:00
Nick Sweeting 6b35c30e17 fix armv7 build for Docker and bare metal 2023-10-30 23:25:51 -07:00
Nick Sweeting 62e077a5bc Merge pull request #1241 from overhacked/fts5-search 2023-10-30 18:49:52 -07:00
Nick Sweeting 8467d89400 Merge pull request #1255 from c01o/fix_python_test 2023-10-30 18:48:28 -07:00
Nick Sweeting 3b005b5faf Add PDM 2023-10-30 18:48:10 -07:00
Nick Sweeting 851f59b4ba Merge pull request #1254 from c01o/dev 2023-10-30 18:44:55 -07:00
Nick Sweeting 9b0b7c6f64 remove setup.py and youtube-dl dependency 2023-10-30 18:40:55 -07:00
Nick Sweeting db360dea1f Fix writing to stderr from docker entrypoint 2023-10-30 15:44:33 -07:00
Nick Sweeting e7795550fc add help message to permissions error output 2023-10-30 15:39:12 -07:00
Nick Sweeting e2bcb19603 Merge pull request #1259 from mAAdhaTTah/fix-readwise-token 2023-10-30 11:59:08 -07:00
mAAdhaTTah 18e89bee3d Fix readwise token
Accidentally shipped this hard-coded. Token is long-size revoked,
but obviously hard to use this way!
2023-10-29 17:27:04 -04:00
Nick Sweeting 720061185c Merge pull request #1195 from overhacked/method_allow_deny 2023-10-27 23:05:30 -07:00
c01o bb1646ddc1 Fix python-tests dependency
Starting from b6113cc, it requires `build` package to be built.
2023-10-27 07:21:55 +09:00
c01o 619c5ae6a2 Upgrade test python version
`pyproject.toml` says the minimum requirement is python 3.9, no need to test on 3.7.
Actually `setuptools 68.1.0+` requires `python 3.8+`.
2023-10-27 07:18:46 +09:00
c01o 8c25a81009 Remove playwright from armv7 2023-10-27 05:59:25 +09:00
Nick Sweeting 7a80b61bea Merge pull request #1252 from c01o/patch-1 2023-10-25 03:28:42 -07:00
c01o 08cffb8742 Fix docker build
Lack of `fi` resulted in a syntax error.
Also, change `;` to `||` to ensure it builds successfully on architectures other than `linux/arm/v7`.


```
[2023-10-24T10:46:49.941Z] ------
 > [dev_container_auto_added_stage_label 11/18] RUN mkdir -p "/home/archivebox/.config/chromium/Crash Reports/pending/"     && chown -R archivebox "/home/archivebox/.config"     ; if [[ "linux/amd64" == "linux/arm/v7" ]]; then $exit 0; else exit 1:
0.401 /bin/bash: -c: line 2: syntax error: unexpected end of file
------
WARNING: buildx: git was not found in the system. Current commit information was not captured by the build
[2023-10-24T10:46:49.942Z] Dockerfile-with-features:135
--------------------
 134 |         && ln -s "$CHROME_BINARY" /usr/bin/chromium-browser
 135 | >>> RUN mkdir -p "/home/${ARCHIVEBOX_USER}/.config/chromium/Crash Reports/pending/" \
 136 | >>>     && chown -R $ARCHIVEBOX_USER "/home/${ARCHIVEBOX_USER}/.config" \
 137 | >>>     ; if [[ "$TARGETPLATFORM" == "linux/arm/v7" ]]; then $exit 0; else exit 1
 138 |         # ignore failure for architectures where no playwright release is available yet
--------------------
ERROR: failed to solve: process "/bin/bash -c mkdir -p \"/home/${ARCHIVEBOX_USER}/.config/chromium/Crash Reports/pending/\"     && chown -R $ARCHIVEBOX_USER \"/home/${ARCHIVEBOX_USER}/.config\"     ; if [[ \"$TARGETPLATFORM\" == \"linux/arm/v7\" ]]; then $exit 0; else exit 1" did not complete successfully: exit code: 2
```
2023-10-24 19:58:23 +09:00
Nick Sweeting 2681f752f1 ignore playwright chrome install on arm v7 2023-10-21 14:17:50 -07:00
Nick Sweeting 2928c19119 fix multiarch chromium install 2023-10-20 05:45:08 -07:00
Nick Sweeting 988b6585c9 better multiarch builds 2023-10-20 05:30:45 -07:00
Nick Sweeting 25d2c17bb1 add dev build helper script 2023-10-20 05:10:18 -07:00
Nick Sweeting 015737b1e4 fix buildx helper script 2023-10-20 05:10:03 -07:00
Nick Sweeting bf7a6e4c09 add missing cpython packages needed for arm v8 docker build 2023-10-20 04:33:26 -07:00
Nick Sweeting 63ad43f46c Merge branch 'dev' into method_allow_deny 2023-10-20 04:25:44 -07:00
Nick Sweeting 53355be46a Merge branch 'dev' into fts5-search 2023-10-20 04:23:50 -07:00
Nick Sweeting a58535baff Merge pull request #1249 from ArchiveBox/pdm 2023-10-20 04:23:25 -07:00
Nick Sweeting 82d8662c74 add more readability error output 2023-10-20 04:14:28 -07:00
Nick Sweeting f3e81c1487 bump npm lockfile 2023-10-20 04:09:03 -07:00
Nick Sweeting 76f9b91ed3 dockerfile fixes 2023-10-20 04:08:38 -07:00
Nick Sweeting 6736e63ef0 new Dockerfile layout with better layering 2023-10-20 02:47:34 -07:00
Nick Sweeting 86c662421b show FS_USER in version output debug string 2023-10-20 02:47:23 -07:00
Nick Sweeting 7910a5b34b fix pretty_path printing logic 2023-10-20 02:47:03 -07:00
Nick Sweeting d4ca2d1154 update mercury bin path to postlight parser 2023-10-20 02:46:41 -07:00
Nick Sweeting 75eeb12ebe dont install youtubedl anymore 2023-10-20 02:46:09 -07:00
Nick Sweeting 16796a63fd share PUID and PGID with child procs from entrypoint 2023-10-20 02:45:54 -07:00
Nick Sweeting e0e34e6377 update package.json and npm lockfile 2023-10-20 02:45:37 -07:00
Nick Sweeting 3b3bdab97d update ignore files 2023-10-20 02:45:20 -07:00
Nick Sweeting 63c276a93d redirect add page back to snapshots list automatically 2023-10-19 18:24:19 -07:00
Nick Sweeting 22bcffe5ee remove accidental duplicate template code 2023-10-19 18:24:05 -07:00
Nick Sweeting 85f8583d62 catch ldap configuration and packaging errors and make them non fatal 2023-10-19 18:05:59 -07:00
Nick Sweeting 53cff45ec0 switch from pipenv and setup.py to PDM for python packaging 2023-10-19 17:49:06 -07:00
Nick Sweeting 8caffc4b4e ignore .pdm-python file 2023-10-19 17:09:48 -07:00
Nick Sweeting b6113cc00c use pypa build instead of setup.py build and move ldap to extras 2023-10-19 16:44:48 -07:00
Nick Sweeting 4b26ab9d1f only load image into docker when building, and push on deploy 2023-10-19 16:29:51 -07:00
Nick Sweeting 9fa210515e Bump required python version to 3.9 in stdeb.cfg 2023-10-19 16:22:06 -07:00
Nick Sweeting 748363e685 Bump required Python version to 3.9 2023-10-19 16:21:32 -07:00
Nick Sweeting 6619e8005e Update pip.yml github action python version 2023-10-19 16:20:43 -07:00
Nick Sweeting a61f544547 Update SECURITY.md 2023-10-19 16:18:32 -07:00
Nick Sweeting edde40898b fix markdown underlining everything as link 2023-10-19 12:58:38 -07:00
Nick Sweeting a8ce927937 fix markdown formatting 2023-10-19 12:57:05 -07:00
Nick Sweeting 0e3475a0eb Create SECURITY.md policy 2023-10-18 18:40:58 -07:00
Nick Sweeting 4f655fc4a1 Replace chown of entire data directory when ownership mismatch detected 2023-10-18 18:24:43 -07:00
Nick Sweeting e9c85aa812 Update README.md 2023-10-18 17:53:49 -07:00
Nick Sweeting 6f4a7e4e68 Add ADMIN_USERNAME and ADMIN_PASSWORD to example options 2023-10-18 11:53:13 -07:00
Nick Sweeting 2a1104c76a Merge pull request #1245 from benmuth/add-user-env-vars 2023-10-18 11:50:58 -07:00
Nick Sweeting d286dca925 better to log before doing a thing than after 2023-10-18 11:47:55 -07:00
Ben Muthalaly 9e6a87114b Fix formatting, logging, logic issues 2023-10-18 12:07:36 -05:00
Ben Muthalaly 521ea70e0c Add check for existing user, change varable names 2023-10-18 03:07:54 -05:00
Ross Williams 9b85f35b63 sqlite search: check SQLite version when indexing
If creating the FTS5 tables fails due to a known version
incompatiblity, report the required version to the user.
2023-10-16 14:50:48 -04:00
Ross Williams 1e604a1352 sqlite search: clean up errors and type-checking
Clean up error handling, and report a better error message
on search and flush if FTS5 tables haven't yet been created.

Add some mypy comments to clean up type-checking errors.
2023-10-16 14:31:52 -04:00
Ross Williams adb9f0ecc9 sqlite search: Rename connection to get_connection
`connection` could cause confusion with `django.db.connection` and
`get_connection` is a better callable name.
2023-10-16 13:23:26 -04:00
Ross Williams e0f8eeeaa7 Improve search.backends.sqlite retry logic
Retry with table creation should fail if it is attempted for a second
time.
2023-10-16 13:23:26 -04:00
Ross Williams 8fe5faf4d0 Introduce SQLite FTS5-powered search backend
Use SQLite's FTS5 extension to power full-text search without any
additional dependencies. FTS5 was introduced in SQLite 3.9.0,
[released][1] in 2015 so should be available on most SQLite
installations at this point in time.

[1]: https://www.sqlite.org/changes.html#version_3_9_0
2023-10-16 13:23:26 -04:00
Ross Williams c53ec45a29 WIP: add sqlite search backend boilerplate 2023-10-16 13:23:26 -04:00
Ben Muthalaly 44a94157be Remove logging of configured username and password 2023-10-15 23:36:47 -05:00
Ben Muthalaly 11d473e536 Add config options to add admin user on first run 2023-10-14 00:38:04 -05:00
Nick Sweeting d7b883b049 fix broken link 2023-10-12 00:22:47 -07:00
Nick Sweeting dcef217e5e Merge pull request #1242 from benmuth/fix-titles-with-empty-tag 2023-10-09 21:39:26 -07:00
Ben Muthalaly 77917e9b55 Fix HTML title parsing bugs.
This slightly modifies the HTML_TITLE_REGEX to fix two parsing errors.
The first occurred when title tags were empty (e.g. "<title></title>")
which was parsed as "</title". The second occurred when titles were a
single character (e.g. "<title>A</title>") which was not matched by the
regex, and so would fall back to link.base_url.

Now when tags are empty, it falls back to link.base_url, and single
character titles are parsed correctly.

The way the regex works now is still a bit wonky for some edge cases.
I couldn't find any cases of incorrect behavior, but it still might be
worth reworking more completely for robustness.
2023-10-09 02:00:01 -05:00
Nick Sweeting 4950cee3b6 Merge pull request #1229 from jamesob/dev 2023-09-18 12:19:00 -07:00
James O'Beirne b28b3b7e67 README: update outdated links
Most frustratingly, the outdated docker-compose link prompts users to
download an older version of the docker-compose.yml file, which packages
a broken YouTube retrieval method.
2023-09-18 10:37:35 -04:00
Nick Sweeting 5c1a14e4f2 ignore errors while getting system user name 2023-09-14 03:39:44 -07:00
Nick Sweeting ffe2968e4f improve some comments 2023-09-14 02:41:27 -07:00
Nick Sweeting f809efce4d Merge pull request #996 from barthalion/dev 2023-09-03 21:40:49 -07:00
Nick Sweeting aaca74f6a8 only start parsing json after the first open brace 2023-09-03 21:40:12 -07:00
Nick Sweeting cd9f228b2f Merge pull request #1214 from DanielBatteryStapler/DanielBatteryStapler-patch-1 2023-09-03 21:25:12 -07:00
Nick Sweeting 16d278fbdb Merge pull request #1168 from mAAdhaTTah/add-readwise-reader 2023-09-03 21:24:49 -07:00
Nick Sweeting 110a22ee32 Merge branch 'dev' into DanielBatteryStapler-patch-1 2023-08-31 15:20:46 -07:00
Nick Sweeting 73a5f74d38 update default YOUTUBEDL_ARGS to fix subs and filesize 2023-08-31 15:17:45 -07:00
Nick Sweeting 86366d5640 Update logging_util.py to fix generator subscripting error 2023-08-31 15:12:43 -07:00
Nick Sweeting a837f870af Merge pull request #1221 from spresse1/update-singlefile 2023-08-29 17:04:04 -07:00
spresse1 c8597a7fa1 Update singlefile to the latest version and switch it to single-file-cli. Unfortunately, this requires a rewrite of NPM dependency files. 2023-08-29 20:28:48 +02:00
Nick Sweeting 62fb56354b Merge pull request #1219 from spresse1/chrome-cleanup 2023-08-28 20:02:22 -07:00
spresse1 603ce7ec10 After a timeout, chrome will leave behind a SingletonLock, which prevents future instances of chrome from starting. When an extractor fails due to a timeout, remove this file. 2023-08-28 17:27:03 +02:00
Nick Sweeting 0b6064b7dd Update docker_entrypoint.sh to use /bin/bash 2023-08-22 16:35:43 -07:00
root 23f086aa40 add LDAP support 2023-08-17 19:51:02 -05:00
Nick Sweeting 00ecf57b0f Merge pull request #1211 from DanielBatteryStapler/DanielBatteryStapler-patch-1 2023-08-15 21:34:53 -07:00
DanielBatteryStapler 94dacc49c7 Fix archive_org icon "exists" 2023-08-15 23:49:54 -04:00
Nick Sweeting 68e936e7c2 Merge pull request #1186 from ArchiveBox/dependabot/npm_and_yarn/word-wrap-1.2.4 2023-08-13 16:43:09 -07:00
Nick Sweeting a7d7644dca Merge pull request #1205 from overhacked/fix_url_regex_hyphen 2023-08-09 14:20:07 -07:00
Ross Williams c039ef05b3 Fix hyphen placement in util.URL_REGEX
Incorrect hyphen placement in `URL_REGEX` was allowing it to match more
characters than intended. In a regex character class, a literal hyphen
can only appear as the first character in the class, or it will be
interpreted as the delimiter of a range of characters.

The issue fixed here caused the range of characters from `[$-_]`
be treated as valid URL characters, instead of the intended set of three
characters `[-_$]`. The incorrect range interpretation inadvertantly
included most ASCII punctuation, most importantly the angle brackets,
square brackets, and single quote that the expression uses
to mark the end of a match.

This causes the expression to match a URL that has a "hostname" portion
beginning with one of the intended "stop parsing" characters. For
example:

```
https://<b>www</b>.example.com/  # MATCHES but should not
https://[for example]            # MATCHES but should not
scheme='https://'                # MATCHES, including final quote, but should not
```

Some test cases have been added to the `URL_REGEX` assert in
archivebox.parsers to cover this possibility.
2023-08-08 15:24:16 -04:00
Ross Williams 2076474252 Drop use of TypeAlias to maintain Python 3.9 compat
TypeAlias annotation was introduced in Python 3.10, and is not strictly
necessary. Drop use of it to maintain Python 3.9 compatibility.
2023-08-02 10:56:48 -04:00
Ross Williams b44f7e68b1 Add URL-specific method allow/deny lists
Allows enabling only allow-listed extractors or disabling specific
deny-listed extractors for a regular expression matched against an added
site's URL.
2023-08-02 09:36:40 -04:00
Ross Williams 46e80dd509 Rename URL_(WHITE|BLACK)LIST to URL_(ALLOW|DENY)LIST
Retain aliases for old configuration files
2023-08-02 09:31:48 -04:00
Nick Sweeting b773041952 Merge pull request #1199 from overhacked/chrome_version_detection_fix 2023-08-01 10:14:18 -07:00
Nick Sweeting 5b7ecfc872 Merge pull request #1197 from overhacked/bin_version_env_fix 2023-08-01 10:12:59 -07:00
Ross Williams d0e65eba7f More reliably detect Google Chrome version number
Previous method was splitting on the first whitespace, and missing the
version number when it appeared as `"Google Chrome 115.0.234.2342"`
instead of, i.e. `"Chromium 115.0.234.8283"`.

This commit changes the version detection to regex search for
whitespace, then one or more digits followed by a period, then at least
one more digit. Only the first sequence of digits is captured. Unless
Chrome radically changes their version numbering, this should capture
the first group of digits after the reported browser name, which would
be the major version.
2023-07-31 15:34:58 -04:00
Ross Williams 9d9872d325 bin_version means to modify, not replace environ
the `bin_version` function means to modify the environment,
not replace it entirely. Fixes bugs that occur when it wipes out the
PATH environment variable, such as when running in a virtual
environment.
2023-07-31 11:36:34 -04:00
Nick Sweeting 3e5e9c7a41 Merge pull request #1194 from wogong/patch-1 2023-07-29 09:29:34 -07:00
Zhen 3e9e221232 Fix Instapaper export link in README.md
Original link to Instapaper export `https://www.instapaper.com/user/export` is broken: `405: Method Not Allowed`.
2023-07-28 07:58:58 +08:00
dependabot[bot] 0bf739b736 Bump word-wrap from 1.2.3 to 1.2.4
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-19 02:03:00 +00:00
Nick Sweeting 40ddd33602 Update README.md 2023-07-07 09:37:42 -07:00
mAAdhaTTah 181501fd36 Add Readwise Reader API parser
Implemented similar to the Pocket API.
2023-07-02 11:20:58 -04:00
Nick Sweeting 0d26538a4b Update README.md example commands to use new docker compose format 2023-06-13 17:46:32 -07:00
Nick Sweeting 37d238cd31 Update README.md 2023-06-13 17:43:40 -07:00
Nick Sweeting 2580f76a2e Update README.md 2023-06-13 17:41:08 -07:00
Nick Sweeting 571131d5f3 Update README.md to simplify intro instructions 2023-06-13 17:35:00 -07:00
Nick Sweeting 733dbfa1f3 Update scheduler to persist single shared contab via volume instead of requiring separate container for each job 2023-06-13 17:13:55 -07:00
Nick Sweeting 58d784cdd8 limit nginx config to only serve archive directory instead of main data folder root 2023-06-13 16:43:37 -07:00
Nick Sweeting 0e0b06bef1 Merge pull request #1159 from ArchiveBox/pirate-patch-1 2023-06-13 05:49:58 -07:00
Nick Sweeting 406e2b681d Update docker-compose.yml scheduled task image and container name 2023-06-13 05:49:22 -07:00
Nick Sweeting 347b8d977d Merge pull request #1154 from sissbruecker/fix/oneshot_exit_code 2023-05-30 17:34:25 -07:00
Sascha Ißbrücker 7bf4f40da0 just use out_dir 2023-05-29 10:03:49 +02:00
Sascha Ißbrücker 40c122515a fix: make oneshot command return successful exist code 2023-05-29 10:01:27 +02:00
Nick Sweeting 32eef51081 Merge pull request #1153 from ArchiveBox/pirate-patch-1
Add links to new social media profiles under Further Reading
2023-05-27 06:08:25 -07:00
Nick Sweeting d6780d1ab7 Add links to new social media profiles under Further Reading 2023-05-27 06:08:04 -07:00
Nick Sweeting dbe1cd8dc0 Merge pull request #1152 from jess-sol/patch-1 2023-05-26 21:34:15 -07:00
J aa5533b80f Update export_browser_history.sh
Add tags to export of Firefox bookmarks. Ignore invalid bookmarks such as 'javascript:' or 'place:'
2023-05-25 09:08:13 -06:00
Nick Sweeting 081ba7e81b Merge pull request #1147 from mrled/set-app-permissions-dockerfile 2023-05-06 00:20:50 -07:00
Micah R Ledbetter 3edb319ba9 Set /app permissions in the Dockerfile
Add a note in readme to be careful of permissions with --reload
2023-05-05 23:47:02 -05:00
Nick Sweeting 5bbc5228cc Merge pull request #1146 from mrled/add-favicon-provider 2023-05-05 19:00:15 -07:00
Micah R Ledbetter 1e50ca243e Add FAVICON_PROVIDER option for custom favicon service 2023-05-05 20:42:36 -05:00
Nick Sweeting a1e2fce405 Update debian CI builds to use ubuntu 22.04 and actions/checkout@v3 2023-04-18 22:17:58 -07:00
Nick Sweeting 32fac5999e Merge pull request #1131 from dcalano/dev 2023-03-30 13:14:17 -06:00
David Calano f48e48e6da Fix for Issue #1008
- Added missing decode() when setting pkg_path variable
2023-03-29 01:48:12 -04:00
Nick Sweeting f86891bc33 Merge pull request #1128 from tejr/dev 2023-03-26 18:46:06 -06:00
Tom Ryder 53af810ff8 Add missing closing quote to style attribute 2023-03-27 10:54:04 +13:00
Nick Sweeting 829e377890 Merge pull request #1123 from fa0311/dev 2023-03-18 08:07:23 -06:00
ふぁ 44a5a5ed7e add explicitly specify --headless=new
Signed-off-by: ふぁ <yuki@yuki0311.com>
2023-03-17 19:30:14 +09:00
Nick Sweeting f5e631dbfa Merge pull request #1120 from fa0311/dev 2023-03-15 17:05:46 -06:00
Nick Sweeting 9f42a3bf29 fix whitespace 2023-03-15 16:01:02 -07:00
ふぁ d77c770c47 add CHROME_TIMEOUT args
Signed-off-by: ふぁ <yuki@yuki0311.com>
2023-03-14 20:29:41 +09:00
Nick Sweeting cc53eceda2 recreate dockerx builder if missing platforms 2023-03-13 06:55:28 -06:00
Nick Sweeting e5eee4e0b6 add cross-platform docker build support 2023-03-13 11:57:20 +00:00
Nick Sweeting 606fa397a4 disable passing timeout arg to chrome because v111 is crashing when passed 2023-03-13 10:50:18 +00:00
Nick Sweeting 1f1c70a8b1 remove --single-process from chrome args and add some rendering optimization args 2023-03-13 10:49:57 +00:00
Nick Sweeting 9599845b56 ensure DOM HTML dump is non-zero length file when retrying 2023-03-13 10:49:26 +00:00
Nick Sweeting f94e8cd9b2 bump docker base image version and node version 2023-03-13 10:49:02 +00:00
Nick Sweeting d72b1495d0 Merge pull request #1115 from fa0311/dev 2023-03-08 23:30:51 -08:00
Nick Sweeting a580e4627d add instructions on how to install to comment 2023-03-08 23:30:30 -08:00
ふぁ 67e9c77ab7 remove dash
Signed-off-by: ふぁ <yuki@yuki0311.com>
2023-03-09 14:56:53 +09:00
ふぁ 0e6fdb854f add systemd config
Signed-off-by: ふぁ <yuki@yuki0311.com>
2023-03-08 13:17:47 +09:00
Nick Sweeting c2bc6c6781 add ssl to setup script curl usage 2023-02-28 10:26:04 -08:00
Nick Sweeting 8208a1ca89 Merge pull request #1104 from ArchiveBox/revert-1103-codesee-arch-diagram-workflow-1676842676316 2023-02-19 14:48:34 -08:00
Nick Sweeting 30cb3a38bc Revert "Install the CodeSee workflow." 2023-02-19 14:48:23 -08:00
Nick Sweeting ae26c71954 Update README.md 2023-02-19 14:02:18 -08:00
Nick Sweeting 135ad7b75d Merge pull request #1103 from ArchiveBox/codesee-arch-diagram-workflow-1676842676316 2023-02-19 13:38:38 -08:00
codesee-maps[bot] 9b38de49ec Install the CodeSee workflow. Learn more at https://docs.codesee.io 2023-02-19 21:37:56 +00:00
Nick Sweeting 9952724688 fix pypi test link 2023-01-31 00:23:34 -08:00
Nick Sweeting 992a02f87b fix spelling typos 2023-01-30 23:25:59 -08:00
Nick Sweeting 9c763cbda9 Update README.md 2023-01-30 23:11:09 -08:00
Nick Sweeting 7fb65a985b simplify readme shields 2023-01-30 23:08:44 -08:00
Nick Sweeting b0a6b081a0 another newline sigh 2023-01-30 17:19:09 -08:00
Nick Sweeting b8bd9bf598 add newline after npm note 2023-01-30 17:18:43 -08:00
Nick Sweeting 61ad22516b move npm/node note location 2023-01-30 17:17:28 -08:00
Nick Sweeting 8e1cfe0cf8 add NodeJS and npm install note for debian/ubuntu method 2023-01-30 17:15:04 -08:00
Nick Sweeting 5408028265 remove broken lgtm shields 2023-01-11 02:40:01 -08:00
Nick Sweeting 7cb5d8aebe Merge pull request #1081 from ArchiveBox/dependabot/npm_and_yarn/moment-2.29.4 2023-01-09 18:23:03 -08:00
Nick Sweeting 0487cb9733 Merge pull request #866 from ajgon/feat/reverse-proxy-auth 2023-01-09 18:22:46 -08:00
Nick Sweetinganddugite-code dca69933eb Update archivebox/config.py
Co-authored-by: dugite-code <dugite-code@users.noreply.github.com>
2023-01-09 18:22:01 -08:00
Nick Sweeting 2538b170c7 Merge branch 'dev' into feat/reverse-proxy-auth 2023-01-09 18:20:45 -08:00
Nick Sweeting 0cbeeb4346 Merge pull request #1021 from renaisun/dev 2023-01-09 18:17:39 -08:00
dependabot[bot] b26ecc2b7f Bump moment from 2.29.3 to 2.29.4
Bumps [moment](https://github.com/moment/moment) from 2.29.3 to 2.29.4.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.3...2.29.4)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-10 01:58:35 +00:00
Nick Sweeting 8a96563169 Merge pull request #1026 from turian/feature/kludge-984-UTF8-bug 2023-01-09 17:57:49 -08:00
Nick Sweeting d4c18e4142 Merge pull request #1068 from mwestza/freebsd13 2022-12-30 08:13:48 -08:00
Matthew West 86208c0267 get setup.sh to run on FreeBSD again (13.x) 2022-12-25 17:36:01 +00:00
Joseph Turian 07de4a79a1 Merge branch 'dev' into feature/kludge-984-UTF8-bug 2022-12-20 11:39:01 +01:00
Nick Sweeting e114b1f6dc Merge pull request #1027 from turian/feature/migrations-0021_auto_20220914_0934.py 2022-11-27 19:28:55 -08:00
Nick Sweeting f17f4c6bb6 Merge pull request #1023 from turian/feature/better-dockerfil 2022-11-27 19:28:32 -08:00
Nick Sweeting f86c9fc130 Merge pull request #1048 from EsEnZeT/dev 2022-11-22 16:17:29 -08:00
SnZ 2db830c6a8 Method typo?
Fixes '[Errno 2] No such file or directory' error during add
2022-11-20 01:51:16 +01:00
Nick Sweeting 9b65639a59 Merge pull request #1020 from turian/feature/kludge-984-UTF8-bug 2022-11-02 02:12:47 -07:00
Joseph Turian a26a91d09f Merge branch 'feature/migrations-0021_auto_20220914_0934.py' into feature/kludge-984-UTF8-bug 2022-09-14 09:44:55 +00:00
Joseph Turian 22d8e57637 Add missing migration 0021 2022-09-14 09:36:17 +00:00
Joseph Turian 30947aeb07 yt-dlp flag cleanup 2022-09-14 06:29:57 +02:00
Joseph Turian f729bbe122 yt-dlp fixes 2022-09-14 06:27:58 +02:00
Joseph Turian caa8b782fb Remove tab 2022-09-12 21:52:01 +00:00
Joseph Turian 4ce392846c Fix actions 2022-09-12 21:51:15 +00:00
Joseph Turian 081a12b079 Add ts 2022-09-12 21:32:47 +00:00
Joseph Turian daef48e59b flake8 2022-09-12 21:31:33 +00:00
Joseph Turian 983f485cc0 flake8 2022-09-12 21:29:43 +00:00
Joseph Turian b864c38d9e Don't be strict on unicode errors 2022-09-12 20:40:45 +00:00
Joseph Turian dba423a568 A few more youtube-dl tweaks 2022-09-12 20:36:23 +00:00
Joseph Turian f5f7aff3b4 Added yt-dlp everywhere 2022-09-12 20:34:02 +00:00
renaisun 0ea955b3ed add a missing comma 2022-09-12 09:08:28 +08:00
notevenaperson 40659b5e9d singlefile.py: Code to ensure options are deduplicated 2022-09-12 09:08:28 +08:00
Joseph Turian 35a052ae8b Another comment 2022-09-11 22:13:22 +02:00
Joseph Turian cc56785695 Added standalone dockerfile instructions 2022-09-11 22:11:13 +02:00
Joseph Turian e41f313fa3 Change actions 2022-09-11 12:31:11 +02:00
Joseph Turian 2b58cce43f Attempted to warn on #984 and #1014 2022-09-11 12:19:16 +02:00
Bartłomiej Piotrowski eb97fd427b Skip first line of the "JSON" file
ArchiveBox moves the file to parse to the sources directory and adds the
original filename at the top, making the file invalid.
2022-07-05 10:56:40 +02:00
Nick Sweeting 03eb7e5875 Update config.py 2022-06-09 01:04:55 -07:00
renaisun 8899fe0b92 Add SINGLEFILE_ARGS to control single-file arguments 2022-06-09 14:35:48 +08:00
Nick Sweeting d586a8babc show mount paths with at symbol in version output 2022-06-08 20:22:58 -07:00
Nick Sweeting 319ea481b8 Update config.py 2022-06-08 20:17:38 -07:00
Nick Sweeting 01555dfe34 Update main.py 2022-06-08 20:17:31 -07:00
Nick Sweeting 2bbc742017 typo fix 2022-06-08 20:16:08 -07:00
Nick Sweeting e2fa68dba6 resolve config paths before using 2022-06-08 20:15:22 -07:00
Nick Sweeting ccce4a6a2f use new is_mount and COMMIT_HASH config options 2022-06-08 20:13:22 -07:00
Nick Sweeting 9f90a2d60d disable unused sqlite3 stuff 2022-06-08 20:12:55 -07:00
Nick Sweeting c78a2edc42 add is_mount and COMMIT_HASH to config.py 2022-06-08 20:04:01 -07:00
Nick Sweeting 375ba9d135 Update settings.py 2022-06-08 20:00:29 -07:00
Nick Sweeting ae5c8f2bf8 fix newline included in commit hash 2022-06-08 19:57:38 -07:00
Nick Sweeting cb3ebbe69a fix git commit hash location 2022-06-08 19:52:48 -07:00
Nick Sweeting 413aa2ef04 fix commit hash detection 2022-06-08 19:51:46 -07:00
Nick Sweeting 33ec2117e9 Update main.py 2022-06-08 19:50:45 -07:00
Nick Sweeting dd29e1bf78 clean up first line of CLI version output for easier downstream parsing 2022-06-08 19:46:09 -07:00
Nick Sweeting 0c6d4c82c3 Update config.py 2022-06-08 19:11:02 -07:00
Nick Sweeting f9c5808940 Update config.py 2022-06-08 19:09:11 -07:00
Nick Sweeting 5509b5cd8b Update main.py 2022-06-08 19:08:33 -07:00
Nick Sweeting 19b88d30b2 fix missing brace 2022-06-08 19:06:03 -07:00
Nick Sweeting 31d5fbbf17 Update config.py 2022-06-08 19:04:06 -07:00
Nick Sweeting 6b019da3e9 Update config.py 2022-06-08 19:01:55 -07:00
Nick Sweeting c752c7053d Update main.py 2022-06-08 18:59:08 -07:00
Nick Sweeting f9c82841ad fix sqlite option detection 2022-06-08 18:58:15 -07:00
Nick Sweeting 1fd5830f58 enforce UTC timezone on server 2022-06-08 18:41:22 -07:00
Nick Sweeting 3e3c011f86 enforce UTC timezone on server 2022-06-08 18:40:48 -07:00
Nick Sweeting e06717419c fix sqlite3 version detection 2022-06-08 18:35:31 -07:00
Nick Sweeting d0f129295f move sqlite3 checks up a level 2022-06-08 18:29:53 -07:00
Nick Sweeting 0c7d7deb32 add missing brace 2022-06-08 18:26:42 -07:00
Nick Sweeting ca16c88a3d show PUID, PGID, ENFORCE_ATOMIC_WRITES, and OUTPUT_PERMISSIONS in version output header 2022-06-08 18:24:58 -07:00
Nick Sweeting 89175ccb22 check SQLite3 version and enabled extensions on startup 2022-06-08 18:24:17 -07:00
Nick Sweeting c245d36e44 add PUID and PGID as config options in archivebox 2022-06-08 17:42:52 -07:00
Nick Sweeting 880ab07f10 Update README.md 2022-06-08 16:59:42 -07:00
Nick Sweeting c7370aaebc Merge pull request #974 from m3nu/misc/add-pikapods-hosting 2022-05-11 13:50:22 -07:00
Manu 5a7828fb08 Add PikaPods as hosting option 2022-05-10 15:28:10 +04:00
Nick Sweeting eb77908273 add missing migration 2022-05-09 23:59:42 -07:00
Nick Sweeting c5fc3e1e65 --ammend 2022-05-09 23:59:27 -07:00
Nick Sweeting 0b4df768ba hack to check for generator type cause too lazy to import 2022-05-09 23:50:56 -07:00
Nick Sweeting 9d6a59485c add missing migration 2022-05-09 23:45:01 -07:00
Nick Sweeting 74b3c59314 fix helper install script handling of python3 version 2022-05-09 21:58:30 -07:00
Nick Sweeting 5e4ddbbf25 fix mercury bin parsing back 2022-05-09 21:58:17 -07:00
Nick Sweeting e96c1bcf13 bump mercury parser to git head version 2022-05-09 21:48:41 -07:00
Nick Sweeting d581a5081f correctly handle bytes strings in hints 2022-05-09 21:29:37 -07:00
Nick Sweeting a6767671fb append content of referenced files to imports 2022-05-09 21:21:39 -07:00
Nick Sweeting f6d6a06c78 always show all totals in log output 2022-05-09 21:21:26 -07:00
Nick Sweeting d05510f844 fix version string parsing on macOS in some cases where LANG C is not supported 2022-05-09 21:21:08 -07:00
Nick Sweeting 4b8b17e788 add update flag support to archivebox schedule 2022-05-09 20:18:43 -07:00
Nick Sweeting 8cfe6f4afb cleanup update flag handling and show better logging to clarify when its working 2022-05-09 20:15:55 -07:00
Nick Sweeting 38e54b93fe allow parsing to continue even when fetching URL contents fails 2022-05-09 19:56:24 -07:00
Nick Sweeting ecbcb6a1b3 fix bracing in template tag for PREVIEW_ORIGINALS 2022-05-09 19:56:08 -07:00
Nick Sweeting 8ebf3e2f93 add config option PREVIEW_ORIGINALS to hide original iframes in snapshot detail pages 2022-05-09 19:31:41 -07:00
Nick Sweeting e83132c24e bump js lockfile 2022-05-09 19:24:05 -07:00
Nick Sweeting 5ddffae83e bump sonic version to support ARM 2022-05-09 19:14:26 -07:00
Nick Sweeting acd53c854d handle new wallabag export format with newlines mid-tag attributes 2022-05-09 19:07:48 -07:00
Nick Sweeting 808ae1a351 Merge pull request #969 from ArchiveBox/dependabot/npm_and_yarn/node-fetch-2.6.7
Bump node-fetch from 2.6.1 to 2.6.7
2022-05-09 18:38:23 -07:00
Nick Sweeting 950b5cbbb6 Merge pull request #924 from prnake/dev
improve title extractor
2022-05-09 18:38:12 -07:00
Nick Sweeting 6e66863871 add max 5s writing delay for concurrent writers and flush WAL slower 2022-05-09 18:36:40 -07:00
Nick Sweeting c5ccb05d4a update Dockerfile buildx instructions to exclude intel/386 2022-04-21 07:35:34 -07:00
Nick Sweeting ed9ef588d9 add instructions for multi-arch build at top of Dockerfile 2022-04-21 07:29:27 -07:00
dependabot[bot] 66f79fc041 Bump node-fetch from 2.6.1 to 2.6.7
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-21 14:12:19 +00:00
Nick Sweeting 2b35d14c0f update npm package versions 2022-04-21 07:11:35 -07:00
Nick Sweeting 57df65f28f use yt-dlp for media archiving instead of youtube-dl 2022-04-21 07:11:35 -07:00
Nick Sweeting eb81d41f84 bump Dockerfile base image version and install yt-dlp 2022-04-21 07:11:35 -07:00
Nick Sweeting 1d2fd83b1b Merge pull request #967 from rossvor/dev 2022-04-20 17:22:51 -07:00
Ross c63822a5e5 Fix missing input redirection in a hint text 2022-04-19 22:25:49 +01:00
Nick Sweeting 9e74676af3 Update README.md 2022-04-12 20:24:44 -04:00
Nick Sweeting 7d4033e73b Add links to DB troubleshooting and upgrading instructions 2022-04-12 18:27:22 -04:00
Nick Sweeting 183253e983 Update README.md 2022-04-12 18:24:24 -04:00
Nick Sweeting f1095f547c Merge pull request #961 from kusold/patch-1 2022-04-06 13:39:26 -07:00
Mike Kusold 4febbacef6 Fix entrypoint
docker-compose requires `$$` for env vars that are supposed to be executed inside the container.
2022-04-05 20:58:40 -06:00
Mike Kusold dfef21a814 Update pywb entrypoint
wb-manager first needs you to init the collection. If you attempt to init a collection that already exists, it exits with a `2`.

Also, `wayback --proxy` requires an argument, so I remove the proxy argument.
2022-04-05 20:42:29 -06:00
Igor Rzegocki d4f534e612 add LOGOUT_REDIRECT_URL 2022-03-31 21:40:14 +02:00
Nick Sweeting d268513877 cleanup example configs 2022-03-22 20:55:17 -04:00
Nick Sweeting 0ec5ba0595 fix pihole admin port format in docker-compose 2022-03-16 14:15:37 -07:00
Nick Sweeting a15350cc77 Update docker-compose.yml 2022-03-16 14:14:35 -07:00
Nick Sweeting 5cd2b328c0 Add example pihole docker-compose config for ad/tracker blocking during archiving 2022-03-16 14:12:18 -07:00
Nick Sweeting 0612ff751a Merge pull request #923 from CrazyPython/patch-1 2022-03-05 03:21:09 -05:00
Nick Sweeting feafe9a2a2 Merge pull request #936 from pellaeon/pr-binversion 2022-03-05 03:20:04 -05:00
Nick Sweeting 51ad780047 Merge pull request #932 from hyfen/patch-1 2022-03-05 03:17:49 -05:00
Nick Sweeting dc8bd1d78f Merge pull request #942 from xfq/archive_dot_org 2022-03-05 03:14:32 -05:00
Fuqiao Xue 29ee73660b Fix broken URI fragment 2022-03-03 21:04:19 +08:00
Pellaeon Lin 5e9d05483e Fix bin_version: set LANG=C when calling executables to avoid parsing
localized output.
2022-02-24 17:01:00 +08:00
Andrew Louis 532aeca8a0 Fix typo in README.md 2022-02-18 13:03:11 -05:00
prnake 011bd104cb remove unused import 2022-02-09 10:48:51 +08:00
papersnake de8e22efb7 improve title extractor 2022-02-08 23:17:52 +08:00
James 6dae0cc252 Fix arch installation command
The linked package is on AUR, typically installed with yay. "pacman install" is not a valid command.
2022-02-06 15:39:44 -05:00
Nick Sweeting bf432d4931 Merge pull request #918 from akhilleusuggo/patch-1
typo
2022-01-30 18:55:11 -05:00
akhilleusuggo 9231e7f091 typo
ERROR: The Compose file './docker-compose.yml' is invalid because:
services.pywb.volumes contains an invalid type, it should be an array
2022-01-27 19:24:59 +01:00
Nick Sweeting 663918a372 Merge pull request #911 from hannah98/dev 2022-01-10 20:54:44 -05:00
Nick Sweeting 666ab20df5 Update archivebox/config.py 2022-01-10 20:42:09 -05:00
hannah98 fc3d2bb4dc rename TAG_SEPARATORS to TAG_SEPARATOR_PATTERN 2022-01-06 14:14:41 +00:00
hannah98 049f88def9 Added TAG_SEPARATORS option to supply a regex of characters to use when splitting tags 2021-12-30 20:19:48 +00:00
Nick Sweeting 745f6573c1 bump node dependencies 2021-12-23 16:03:51 -05:00
Nick Sweeting 7a7217abb4 Bump mercury-parser version 2021-12-23 15:59:50 -05:00
Nick Sweeting 0aa75c861b also install npm packages when testing brew package 2021-12-23 15:56:28 -05:00
Nick Sweeting 9850333b88 Update _config.yml 2021-12-23 15:43:13 -05:00
Nick Sweeting f144f7b5c6 bump brew disk version 2021-12-23 15:39:49 -05:00
Nick Sweeting 2c7be14bb7 Merge pull request #909 from ArchiveBox/dependabot/npm_and_yarn/jszip-3.7.1 2021-12-23 12:55:25 -05:00
Nick Sweeting d6a856264f Merge pull request #910 from ArchiveBox/dependabot/npm_and_yarn/ansi-regex-5.0.1 2021-12-23 12:55:15 -05:00
dependabot[bot] 2c3a25765f Bump ansi-regex from 5.0.0 to 5.0.1
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-23 17:49:43 +00:00
dependabot[bot] 1f7e15e3c0 Bump jszip from 3.6.0 to 3.7.1
Bumps [jszip](https://github.com/Stuk/jszip) from 3.6.0 to 3.7.1.
- [Release notes](https://github.com/Stuk/jszip/releases)
- [Changelog](https://github.com/Stuk/jszip/blob/master/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.6.0...v3.7.1)

---
updated-dependencies:
- dependency-name: jszip
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-12-23 17:49:42 +00:00
Nick Sweeting 559c395c1e run archivebox version after homebrew bottle build 2021-12-23 12:49:03 -05:00
Nick Sweeting d7f01922f3 fix direct assignment of tags to many-to-many set 2021-12-23 12:29:17 -05:00
Nick Sweeting b1b7ee2b85 Update sql.py 2021-12-23 12:17:55 -05:00
Nick Sweeting f363738aae Merge pull request #908 from hannah98/dev 2021-12-21 00:24:48 -05:00
hannah98 4b8962b60b Fix #725 - correctly parse tags on json import 2021-12-20 08:58:58 -06:00
Nick Sweeting 989937796a Merge pull request #906 from tuupola/bug-901 2021-12-19 23:46:27 -05:00
Mika Tuupola f14a861605 Change logfile open to write mode only 2021-12-19 23:17:33 +02:00
Nick Sweeting ea0d9c1d99 Update README.md 2021-12-17 17:55:40 -05:00
Nick Sweeting e3d4d0aa91 Merge pull request #904 from TheCakeIsNaOH/hints-string 2021-12-17 16:53:36 -05:00
TheCakeIsNaOH decab91ea2 (#847) Decode error output hints to string if needed 2021-12-16 16:46:12 -06:00
Nick Sweeting b279c30b8d Update docker-compose.yml 2021-12-16 05:18:33 -05:00
Nick Sweeting 63fc835a70 link to archivebox-exporter browser extension 2021-12-06 16:23:31 -05:00
Nick Sweeting b40a1e19d1 comment out healthcheck 2021-12-02 21:03:19 -05:00
Nick Sweeting 84b927e3e5 change header title 2021-11-16 21:13:19 -05:00
Nick Sweeting 44f5338470 fix typo in pocket_api articl variable name 2021-11-12 19:23:47 -05:00
Nick Sweeting 8878dcc5e8 Merge pull request #843 from bltavares/patch-1 2021-11-12 15:57:19 -08:00
Nick Sweeting d5240f1a1d Merge pull request #885 from adamwolf/safari-admin-actions 2021-11-12 08:56:31 -08:00
Nick Sweeting f809e3be46 Merge pull request #886 from adamwolf/safari-add 2021-10-30 18:07:58 -07:00
Adam Wolf 18e1fb0d96 Fixes Add button behavior on Safari
Previously, when you clicked the Add button, the page wouldn't change.

It looked like it wasn't doing anything, as noted by @rcarmo
(https://github.com/ArchiveBox/ArchiveBox/issues/658#issuecomment-948300055)

I didn't track it down the exact reason why. It may be that Safari
didn't like the two opening <h3>s, but I was able to find a bunch of
people complaining about Safari being very finicky with innerHTML
and actually repainting the page, enough that I decided to try just
extending the block hide/show behavior already done with the
delay-warning, and it works for me now in both Chrome and Safari.

For #658.
2021-10-28 22:31:54 -05:00
Adam Wolf 83731f5a68 Tweak JS so Safari can choose admin actions
I noticed that Safari was submitting both the empty option and the
selected options back to the server.

Digging into it, I was able to get Safari to deselect the ---------
option by using '[selected]' as the selector.

For #658
2021-10-28 22:22:46 -05:00
Nick Sweeting c20c3be02e Merge pull request #884 from adamwolf/dev-docs 2021-10-28 09:58:35 -07:00
Adam Wolf 3b6ef764c5 Update README.md
Bind the data directory when initializing a dev environment using
Docker.
2021-10-28 10:01:34 -05:00
Nick Sweeting b54076054c Update README.md 2021-10-26 02:02:23 -04:00
Nick Sweeting aaa459082d Update README.md 2021-10-26 01:58:16 -04:00
Nick Sweeting 569a4582dd Merge pull request #873 from ajgon/feature/healthcheck 2021-10-21 15:08:57 -07:00
Igor Rzegocki 05de1c9fe6 healthcheck endpoint 2021-10-03 19:12:03 +02:00
Igor Rzegocki 95cf85f8cf Support for Reverse Proxy authentication backends (like authelia) 2021-09-30 17:40:13 +02:00
Bruno Tavares bb2a2e758a Avoid KeyError on Pocket API parser
When trying to import my pocket library I got a lot of ` KeyError`  on Python. Pocket API has a few idiosyncrasies, such as sometimes returning the keys on json, sometimes not.

` ` ` sh
archivebox add --parser pocket_api pocket://my_username
` ` ` 

Gave me this errors
` ` ` 
  File "/app/archivebox/parsers/pocket_api.py", line 54, in link_from_article
    title = article['resolved_title'] or article['given_title'] or url
KeyError: 'resolved_title'
` ` ` 

This commit are the patches I've changed to successfully import my library
2021-09-07 21:53:36 -03:00
Nick Sweeting 63693bdc77 add nix install instructions, thanks @siraben! 2021-08-11 11:12:58 -04:00
Nick Sweeting 2e5937d9e1 Merge pull request #822 from overhacked/bug_pinboard_rss
Fix Pinboard RSS parsing valid links as `None`
2021-08-04 12:23:40 -04:00
Ross Williams f6cf35a45d Fix Pinboard RSS parsing valid links as None
`item.find(p)` returns either an `ElementTree.Element` or `None`.  The
[lambda on line 24][lambda] coerces the return value to a bool, which is
`False` if the `<link>` element has no children (see
[`ElementTree.py` line 207][etbooldef]), so the lambda returns `None`.

Further, returning a `Link` with `url=None` violates
[an assertion in `index/schema.py`][assertion], which crashes
the `archivebox add` command.

[lambda]: https://github.com/ArchiveBox/ArchiveBox/blob/3d54b1321bf8c56627aaa50efcc809cd99caee52/archivebox/parsers/pinboard_rss.py#L24
[etbooldef]: https://github.com/python/cpython/blob/3d8993a744813c5144851da5347d7b4b1885f234/Lib/xml/etree/ElementTree.py#L207
[assertion]: https://github.com/ArchiveBox/ArchiveBox/blob/3d54b1321bf8c56627aaa50efcc809cd99caee52/archivebox/index/schema.py#L165
2021-08-04 10:13:37 -04:00
Nick Sweeting 3d54b1321b Merge pull request #804 from munzevibirinsan/patch-1 2021-07-21 08:28:08 -04:00
munzevibirinsan ee86eb534d Update setup.sh 2021-07-21 13:24:31 +03:00
Nick Sweeting 2427e6d3dc Merge pull request #799 from Inndy/dev
Discard Referer header from iframe and link to original URL
2021-07-19 10:16:52 -04:00
Inndy 0e81a0722e Discard Referer header from iframe and link to original URL 2021-07-19 21:48:01 +08:00
Nick Sweeting ca819b8015 Update README.md 2021-07-07 06:55:07 -04:00
Nick Sweeting 9100ad63a7 Merge pull request #784 from ArchiveBox/dependabot/npm_and_yarn/ws-5.2.3
Bump ws from 5.2.2 to 5.2.3
2021-07-07 00:44:33 -04:00
dependabot[bot] 1084f6aacc Bump ws from 5.2.2 to 5.2.3
Bumps [ws](https://github.com/websockets/ws) from 5.2.2 to 5.2.3.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/5.2.2...5.2.3)

---
updated-dependencies:
- dependency-name: ws
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-07-07 03:42:46 +00:00
Nick Sweeting 767e32c08c bump version number 2021-07-06 23:42:05 -04:00
Nick Sweeting 5a2c78e14b add proper support for URL_WHITELIST instead of using negation regexes 2021-07-06 23:42:00 -04:00
Nick Sweeting e4974d3536 support negation patterns by checking both re.search and re.match 2021-07-06 23:17:05 -04:00
Nick Sweeting 65d452fe7b add documentation for contributing a new extractor 2021-07-01 22:48:39 -04:00
Nick Sweeting f6aad7f9a3 Merge pull request #777 from tjhorner/exempt-add-from-csrf
Exempt /add route from CSRF
2021-07-01 21:40:10 -04:00
TJ Horner cdcfb7fa44 Exempt /add route from CSRF 2021-07-01 20:55:51 -04:00
Nick Sweeting 66f17cb46b Merge pull request #766 from Namdrib/dev
Docs: Improve spelling on readme
2021-06-10 21:01:44 -04:00
Namdrib 6cffe7fa0f Docs: Improve spelling on readme 2021-06-07 21:36:28 +09:30
Nick Sweeting e0a2b2e252 ominous warnings 2021-06-01 03:03:42 -04:00
Nick Sweeting aa53fe653c fix use of uneeded perms arg 2021-06-01 02:58:36 -04:00
Nick Sweeting c2d1a57581 fix umask dir permissions 2021-06-01 00:50:18 -04:00
Nick Sweeting 4715ace7dd ignore BaseException lgtm errors 2021-05-31 20:59:05 -04:00
Nick Sweeting 9f1470cf03 fix output permissions tests 2021-05-31 20:57:46 -04:00
Nick Sweeting 8230f88d80 change default OUTPUT_PERMISSIONS to disallow execution except on dirs 2021-05-31 19:31:51 -04:00
Nick Sweeting 1112526543 add option ENFORCE_ATOMIC_WRITES to allow disabling forced FSYNC writes on network drives 2021-05-31 19:31:51 -04:00
Nick Sweeting e529f550d8 Update README.md 2021-05-13 05:34:03 -04:00
Nick Sweeting ecc178b87f Update README.md 2021-05-13 05:31:40 -04:00
Nick Sweeting 9dbc9d64fb Update README.md 2021-05-13 05:27:48 -04:00
Nick Sweeting 11a7c4ef11 Update README.md 2021-05-13 05:24:36 -04:00
Nick Sweeting a7fb8556ff Update README.md 2021-05-13 05:21:49 -04:00
Nick Sweeting decd18eea0 Update README.md 2021-05-13 05:14:39 -04:00
Nick Sweeting 254ff41b1a Update README.md 2021-05-13 05:11:54 -04:00
Nick Sweeting 49faec8f6d add no-zygote and single-process args to try and prevent orphan chrome processes after exit 2021-05-13 05:04:23 -04:00
Nick Sweeting 930fb394c3 Update README.md 2021-05-13 04:19:22 -04:00
Nick Sweeting 64a9200ac9 Update README.md 2021-05-13 00:55:35 -04:00
Nick Sweeting d8c1be3f06 Update README.md 2021-05-13 00:43:29 -04:00
Nick Sweeting b512b372ba Update README.md 2021-05-13 00:39:27 -04:00
Nick Sweeting 4187afdebc Update README.md 2021-05-13 00:37:18 -04:00
Nick Sweeting c63fe0250e Update README.md 2021-05-13 00:30:12 -04:00
Nick Sweeting eb4d3bca9d Update readability.py 2021-05-13 00:13:32 -04:00
Nick Sweeting 8504d47984 Update README.md 2021-05-12 23:37:19 -04:00
Nick Sweeting 76f29d0325 fix merge 2021-05-12 23:35:57 -04:00
Nick Sweeting 54a11d7927 add python dependencies 2021-05-12 23:35:25 -04:00
Nick Sweeting a8c1ffc5ba Update README.md 2021-05-07 09:43:50 -04:00
Nick Sweeting d43ce963de Update README.md 2021-05-07 09:43:30 -04:00
Nick Sweeting cac13964cb Update README.md 2021-05-07 09:30:44 -04:00
Nick Sweeting 987915e5ea Update README.md 2021-05-07 09:27:42 -04:00
Nick Sweeting 257529625d Update README.md 2021-05-07 09:26:22 -04:00
Nick Sweeting 8e6a65639e Update README.md 2021-05-07 09:18:42 -04:00
Nick Sweeting 4d2936fd4c Update README.md 2021-05-07 09:14:35 -04:00
Nick Sweeting 5d37063313 Update README.md 2021-05-07 09:11:24 -04:00
Nick Sweeting 595839d887 Update README.md 2021-05-07 09:01:57 -04:00
Nick Sweeting f52b9340d0 Update README.md 2021-05-07 08:57:21 -04:00
Nick Sweeting 7da8142ee9 Update README.md 2021-05-07 08:53:03 -04:00
Nick Sweeting e9b26cc6b7 Update README.md 2021-05-07 08:50:44 -04:00
Nick Sweeting 26bc786329 Update README.md 2021-05-07 08:50:00 -04:00
Nick Sweeting 39d2695ca0 Update README.md 2021-05-07 08:47:51 -04:00
Nick Sweeting 76783da161 Update README.md 2021-05-07 08:45:21 -04:00
Nick Sweeting 13ef6a994c Update README.md 2021-05-07 08:43:09 -04:00
Nick Sweeting b92192ed17 Update README.md 2021-05-07 08:40:17 -04:00
Nick Sweeting 9bc9c2495b Update README.md 2021-05-07 08:32:38 -04:00
Nick Sweeting 301c2d67fa Update README.md 2021-05-07 08:26:42 -04:00
Nick Sweeting ff32ffe6eb Update README.md 2021-05-07 08:21:27 -04:00
Nick Sweeting a1c70dda54 Update README.md 2021-05-07 08:15:06 -04:00
Nick Sweeting b443f58d87 Update README.md 2021-05-07 08:12:44 -04:00
Nick Sweeting d2a7d8284c Update README.md 2021-05-07 08:02:45 -04:00
Nick Sweeting a1fae0a5c1 Update README.md 2021-05-07 07:52:04 -04:00
Nick Sweeting df5fcf8f43 Update README.md 2021-05-07 07:50:02 -04:00
Nick Sweeting 4770ec82d6 Update README.md 2021-05-07 07:44:57 -04:00
Nick Sweeting efebd8e02b Merge pull request #739 from FiddlyRumpus/patch-1
point to master image, not latest
2021-05-07 05:29:19 -04:00
Evan Wade bac5a10eae point to master image, not latest
Following the docker-compose quick-start guide, used "latest" tagged docker image in compose file. Returned an exec error on my arm64 raspi 3b+. Seems like this needs to be "master" tag to really support multi-arch... Worked when I changed it to "master" on my machine.
2021-05-07 02:03:54 -07:00
Nick Sweeting ae56636bed Update README.md 2021-04-27 15:10:23 -04:00
Nick Sweeting b9a1c14e29 Update README.md 2021-04-27 15:04:41 -04:00
Nick Sweeting cf817990a3 Update README.md 2021-04-27 15:02:31 -04:00
Nick Sweeting 3d56e44c24 Update README.md 2021-04-27 14:59:31 -04:00
Nick Sweeting edcb7b39a4 Update README.md 2021-04-27 14:58:16 -04:00
Nick Sweeting 0d97069fc7 Update README.md 2021-04-27 14:54:57 -04:00
Nick Sweeting 71ef88b4d9 Update README.md 2021-04-27 14:51:45 -04:00
Nick Sweeting 7113e56859 Update README.md 2021-04-27 14:51:00 -04:00
Nick Sweeting 4386552810 Update docker-compose.yml 2021-04-24 07:27:52 -04:00
Nick Sweeting 5940dd65fb add pip install post-step 2021-04-24 07:17:53 -04:00
Nick Sweeting 0e3ef41d4b add version check 2021-04-24 07:08:56 -04:00
Nick Sweeting c3d009e4ec fix python file execution checking 2021-04-24 04:43:02 -04:00
Nick Sweeting 3d40693f1c change version checking order 2021-04-24 04:31:05 -04:00
Nick Sweeting 3e7d8e5264 install requirements from setup.py 2021-04-24 04:21:43 -04:00
Nick Sweeting bfdd8a9dc1 only install django requirement 2021-04-24 04:14:09 -04:00
Nick Sweeting e974b82db2 fix wrapping error 2021-04-24 04:07:18 -04:00
Nick Sweeting 96514e67c4 force isntall newer django version on ubuntu build ci 2021-04-24 04:05:59 -04:00
Nick Sweeting 79e19ecd47 shield pwd import for windows 2021-04-24 03:51:38 -04:00
Nick Sweeting 3db77fd1a2 fix lint errors 2021-04-24 03:49:01 -04:00
Nick Sweeting 3d4e23e627 Update setup.sh 2021-04-24 03:43:15 -04:00
Nick Sweeting 02874f6ae9 Update setup.sh 2021-04-24 03:42:06 -04:00
Nick Sweeting 3992e0fee3 auto prepend python binary to args when running system command with python file as first argument 2021-04-24 03:29:22 -04:00
Nick Sweeting fa84f749ff run archivebox version using python binary 2021-04-24 03:29:22 -04:00
Nick Sweeting 226e26852c dont try to autodetect whether node, chrome, etc are needed during setup 2021-04-24 03:29:22 -04:00
Nick Sweeting 831d82a7a1 alias python3 to python3.7 on bsd 2021-04-24 03:26:19 -04:00
Nick Sweeting 2f9312f69b Update setup.sh 2021-04-24 03:21:22 -04:00
Nick Sweeting f129b9f443 allow executing main 2021-04-24 03:11:06 -04:00
Nick Sweeting 8bbad5c5d0 Update setup.sh 2021-04-24 03:09:18 -04:00
Nick Sweeting c3f05de602 Update setup.sh 2021-04-24 02:56:48 -04:00
Nick Sweeting 8ba567ef09 Update setup.sh 2021-04-24 02:54:18 -04:00
Nick Sweeting 4b4b85f09b require python3-pip in apt dependencies 2021-04-24 02:52:09 -04:00
Nick Sweeting 208f866fc4 fix user detection on freebsd always returning root 2021-04-24 02:52:09 -04:00
Nick Sweeting 4356085a43 switch instructions to curl sh instead of curl bash 2021-04-24 02:41:29 -04:00
Nick Sweeting 0b26e91d9d Update setup.sh 2021-04-24 02:40:45 -04:00
Nick Sweeting c850a1a920 Update setup.sh 2021-04-24 02:30:31 -04:00
Nick Sweeting a5410484d3 Update setup.sh 2021-04-24 02:27:30 -04:00
Nick Sweeting df1a4e6e47 Update setup.sh 2021-04-24 02:26:25 -04:00
Nick Sweeting dd4f84e8d0 Update setup.sh 2021-04-24 02:11:04 -04:00
Nick Sweeting 2dcb5d1bc5 check to make sure python is installed first 2021-04-24 02:09:53 -04:00
Nick Sweeting f4a373748c Update README.md 2021-04-24 01:48:28 -04:00
Nick Sweeting 85374e02a3 Update README.md 2021-04-24 01:46:24 -04:00
Nick Sweeting 19ab7310fc Update README.md 2021-04-24 01:45:36 -04:00
Nick Sweeting c2fbb18f2e Update README.md 2021-04-24 01:40:01 -04:00
Nick Sweeting b30a0d0aea Update README.md 2021-04-24 01:38:39 -04:00
Nick Sweeting c1dbf03d1d Update README.md 2021-04-24 01:34:09 -04:00
Nick Sweeting 84202a5849 Update README.md 2021-04-24 01:32:53 -04:00
Nick Sweeting 1ebcb8cc88 Update README.md 2021-04-24 01:32:02 -04:00
Nick Sweeting 3cfbaf39e0 Update README.md 2021-04-24 01:30:59 -04:00
Nick Sweeting a437c750dd Update README.md 2021-04-24 01:28:02 -04:00
Nick Sweeting 11850bc700 Update README.md 2021-04-24 01:24:37 -04:00
Nick Sweeting 9e9a7793c2 Update README.md 2021-04-24 01:20:58 -04:00
Nick Sweeting 6543757ddf Update README.md 2021-04-24 01:16:00 -04:00
Nick Sweeting 34605eac92 add createsupersuer example in final help text 2021-04-24 01:13:59 -04:00
Nick Sweeting 0644856aae Update setup.sh 2021-04-24 01:04:58 -04:00
Nick Sweeting 2e0781375f Update setup.sh 2021-04-24 01:02:12 -04:00
Nick Sweeting 4ae965cb07 Update setup.sh 2021-04-24 00:55:51 -04:00
Nick Sweeting d5af216b4e Update setup.sh 2021-04-24 00:50:05 -04:00
Nick Sweeting fced474c38 Update README.md 2021-04-24 00:41:10 -04:00
Nick Sweeting 2bdeda2658 Update README.md 2021-04-24 00:40:34 -04:00
Nick Sweeting 5fd138eb69 add get.archivebox.io helper script 2021-04-24 00:38:19 -04:00
Nick Sweeting 3ff20c5724 Update README.md 2021-04-24 00:29:41 -04:00
Nick Sweeting b7507de983 Update setup.sh 2021-04-24 00:23:14 -04:00
Nick Sweeting 430f28ca93 fix lint action 2021-04-24 00:09:52 -04:00
Nick Sweeting 06f6084e3b ignore stdin when passed instead of throwing an error 2021-04-24 00:09:52 -04:00
Nick Sweeting 431020e6e4 Update setup.sh 2021-04-24 00:08:17 -04:00
Nick Sweeting 9f4e13d8b1 Update setup.sh 2021-04-24 00:06:07 -04:00
Nick Sweeting 0624ac5a74 try docker in setup script before anything else 2021-04-23 23:56:55 -04:00
Nick Sweeting 77e5560762 Update setup.sh 2021-04-23 23:50:11 -04:00
Nick Sweeting 4a26d20849 Update setup.sh 2021-04-23 23:49:44 -04:00
Nick Sweeting b7bf9644de Update setup.sh 2021-04-23 23:43:41 -04:00
Nick Sweeting 98b11eb6d4 add usage to browser history export script and fix output dirs 2021-04-23 23:18:15 -04:00
Nick Sweeting 457556a2a8 add deprecation notice to old archive helper bin 2021-04-23 23:12:14 -04:00
Nick Sweeting 9fc6399034 Update README.md 2021-04-23 23:09:40 -04:00
Nick Sweeting 1aed8a9870 update setup.py 2021-04-23 23:07:55 -04:00
Nick Sweeting eb80dc26a1 cleanup config files 2021-04-23 22:58:44 -04:00
Nick Sweeting 1cdd2bf496 add link to docker config details 2021-04-23 22:48:30 -04:00
Nick Sweeting 2d7c81f6dc add backwards-compatible anchor 2021-04-23 22:37:33 -04:00
Nick Sweeting 6a63bbd9a4 more consice sections 2021-04-23 22:33:45 -04:00
Nick Sweeting bdd8c4693a rearrange sections 2021-04-23 22:26:42 -04:00
Nick Sweeting d96745b592 shorten next steps 2021-04-23 22:20:36 -04:00
Nick Sweeting 3bb0867669 add next steps section to quickstart 2021-04-23 22:17:21 -04:00
Nick Sweeting 9d66594942 simplify CLI usage section in quickstart 2021-04-23 22:08:32 -04:00
Nick Sweeting 44e034041b simplify usagegap 2021-04-23 22:08:23 -04:00
Nick Sweeting dfd61fd021 tweak recommended alternative projects 2021-04-23 22:02:01 -04:00
Nick Sweeting ab680479c4 add re-snapshot ui button to readme 2021-04-23 20:58:06 -04:00
Nick Sweeting 07f4a63635 shorten dependencies section 2021-04-23 20:49:06 -04:00
Nick Sweeting caa1207f88 shorten most common options section even more 2021-04-23 20:44:12 -04:00
Nick Sweeting 5bdcd992bb shorten most common options list 2021-04-23 20:34:31 -04:00
Nick Sweeting 8e5983d434 add configuration section to readme 2021-04-23 20:20:52 -04:00
Nick Sweeting 55e93bcdc0 fix pricing and color 2021-04-23 19:48:01 -04:00
Nick Sweeting ac2a5f82d8 show cli usage example in server block 2021-04-23 19:44:53 -04:00
Nick Sweeting bbb6aaa5cb fix footer links formatting 2021-04-23 19:36:25 -04:00
Nick Sweeting 9edf8a0d52 fix cap 2021-04-23 19:29:13 -04:00
Nick Sweeting d9b1b4eaeb separate ubuntu setup blocks 2021-04-23 19:21:52 -04:00
Nick Sweeting 3a4ae18ebf rearrange paid options 2021-04-23 19:19:19 -04:00
Nick Sweeting 74f7e9ae1d fix closing tags 2021-04-23 19:11:25 -04:00
Nick Sweeting 02b85b63f2 fix missing closing tag 2021-04-23 19:04:49 -04:00
Nick Sweeting dbf9af0933 clearer server setup wording 2021-04-23 18:56:12 -04:00
Nick Sweeting 0e1f61eebc note server is completely optional 2021-04-23 18:49:24 -04:00
Nick Sweeting 0d84f921b7 use emoji arrow 2021-04-23 18:44:18 -04:00
Nick Sweeting d58e9ef2a0 cleaner clinks 2021-04-23 18:42:02 -04:00
Nick Sweeting ef5ab2d53f better list formatting 2021-04-23 18:37:57 -04:00
Nick Sweeting 58a6b48a67 try list format 2021-04-23 18:27:30 -04:00
Nick Sweeting 8839877e8b simpler server instructions 2021-04-23 18:18:24 -04:00
Nick Sweeting 04b2413f78 spacing 2021-04-23 18:10:49 -04:00
Nick Sweeting 2e2a07b649 tweak styling 2021-04-23 17:55:20 -04:00
Nick Sweeting b2d693451a simplify quickstart instructions 2021-04-23 17:50:14 -04:00
Nick Sweeting bc7e172f04 add docker-compose install instructions 2021-04-23 17:33:16 -04:00
Nick Sweeting fe90b88ed6 fix monadical link 2021-04-23 17:28:09 -04:00
Nick Sweeting 82cfef1985 css fixes 2021-04-23 17:25:25 -04:00
Nick Sweeting 76da9895a6 layout changes 2021-04-23 17:23:25 -04:00
Nick Sweeting a2d5feedd1 layout fixes 2021-04-23 17:20:58 -04:00
Nick Sweeting 5bccd04549 custom solution 2021-04-23 17:20:03 -04:00
Nick Sweeting 1ca3f9b48f add more info links 2021-04-23 17:10:56 -04:00
Nick Sweeting 72595e49e2 move note to top 2021-04-23 17:04:08 -04:00
Nick Sweeting b8ee7bb8a0 update referral links 2021-04-23 16:59:29 -04:00
Nick Sweeting 64d9cd568d change referral links 2021-04-23 16:54:49 -04:00
Nick Sweeting 0a09bdb2ca create archivebox dir in code dir manually 2021-04-23 16:12:25 -04:00
Nick Sweeting 145dda3e0f add some more env vars to deploy template 2021-04-23 16:04:18 -04:00
Nick Sweeting bbc9c49694 fix closing tags 2021-04-23 15:54:24 -04:00
Nick Sweeting 985740b0fb attempt to specify source dir for docker build 2021-04-23 15:53:50 -04:00
Nick Sweeting 4f91af0ce9 TODO create admin user from env vars 2021-04-23 15:50:34 -04:00
Nick Sweeting ac0f93ee10 section for paid providers in quickstart 2021-04-23 15:50:34 -04:00
Nick Sweeting 9999721659 Update deploy.template.yaml 2021-04-23 15:26:29 -04:00
Nick Sweeting 4dbad07a0b why isnt it working 2021-04-23 15:17:46 -04:00
Nick Sweeting f0bb48a9d2 fixes 2021-04-23 15:11:12 -04:00
Nick Sweeting c88e042445 deploy button fixes 2021-04-23 15:08:13 -04:00
Nick Sweeting f99fd6f511 add referral code 2021-04-23 15:03:09 -04:00
Nick Sweeting 764ed083b0 add one click deploy button 2021-04-23 15:02:26 -04:00
Nick Sweeting f473114e34 add digitalocean app deploy template 2021-04-23 15:02:26 -04:00
Nick Sweeting 9563e74f7c enforce minimum django version 2021-04-23 15:02:26 -04:00
Nick Sweeting 5a66e8a483 Merge pull request #708 from dryrain39/dryrain39-dev
fix bookmarklet script
2021-04-16 12:53:14 -04:00
Kunwoo Ahn b6ee7d291e fix bookmarklet script
Fix '&' bug in parameter
2021-04-16 21:24:51 +09:00
Nick Sweeting 687c34af3e ignore build dist dirs in pytest discovery 2021-04-14 04:49:16 -04:00
Nick Sweeting 6c06da5581 Merge branch 'dev' 2021-04-14 04:32:27 -04:00
root f7b732f889 bump deb_dist submodule 2021-04-14 04:30:23 -04:00
root f8d2e186e1 add v0.6.2 debian release 2021-04-14 04:26:35 -04:00
Nick Sweeting 5ce042fae0 Merge branch 'dev' 2021-04-14 04:16:09 -04:00
Nick Sweeting 413c89d5cf fix bottle issue 2021-04-14 04:15:44 -04:00
Nick Sweeting 3da6fb8ca7 add latest homebrew release 2021-04-14 04:13:14 -04:00
Nick Sweeting 23812f44af include v0.5.6 in pip_dist 2021-04-14 03:38:28 -04:00
Nick Sweeting 8c1da0e378 update pip_dist folder 2021-04-14 03:36:37 -04:00
Nick Sweeting c3ee1b836a Merge branch 'dev' 2021-04-14 03:33:27 -04:00
Nick Sweeting b45b3fa21c bump version number 2021-04-14 03:33:20 -04:00
Nick Sweeting a09211993c whoops 2021-04-14 03:33:20 -04:00
Nick Sweeting aaea5f3081 bump version number 2021-04-14 03:33:04 -04:00
Nick Sweeting 6811cb35e5 Update README.md 2021-04-14 02:37:46 -04:00
Nick Sweeting 0d670a09d5 Update README.md 2021-04-14 02:36:35 -04:00
Nick Sweeting 63552be0e3 whoops 2021-04-13 04:03:47 -04:00
Nick Sweeting 3af6f7c734 reorder 2021-04-13 03:59:44 -04:00
Nick Sweeting 7d1f8cb496 no longer recommend snap chromium, just use playwright 2021-04-13 03:59:17 -04:00
Nick Sweeting 2c6f0a96bf fix extra arg 2021-04-13 02:21:51 -04:00
Nick Sweeting 54cab113f3 allow static_index.json in dir root from old static exports 2021-04-12 17:06:32 -04:00
Nick Sweeting 50b341baab bail out if old index.json is found during init but doesnt contain links 2021-04-12 16:51:45 -04:00
Nick Sweeting 217952c8d0 sigh linters 2021-04-11 12:51:50 -04:00
Nick Sweeting bfefe042bf fix noqa 2021-04-11 03:57:04 -04:00
Nick Sweeting ff0b04890f Merge branch 'master' into dev 2021-04-11 00:49:43 -04:00
Nick Sweeting 4524129965 dont prevent search when backend throws an exception or times out 2021-04-11 00:49:35 -04:00
Nick Sweeting 335e4658c8 keep grid header on one line 2021-04-11 00:49:33 -04:00
Nick Sweeting 3fd711cd74 Update README.md 2021-04-10 17:27:09 -04:00
Nick Sweeting 52429b8d15 dont prevent search when backend throws an exception or times out 2021-04-10 08:18:13 -04:00
Nick Sweeting 9f05cf8283 virtual-time-budget doesnt work with some chrome stuff 2021-04-10 08:04:59 -04:00
Nick Sweeting 62078a77f8 show run duration after each archived link in cli output 2021-04-10 07:52:01 -04:00
Nick Sweeting 6053f4e415 move sonic file up a level 2021-04-10 07:42:16 -04:00
Nick Sweeting 6a6ef63775 enforce utf8 on more file reads 2021-04-10 07:22:32 -04:00
Nick Sweeting 193df5c8d3 add video subtitles and description to full-text index 2021-04-10 07:22:20 -04:00
Nick Sweeting 1e3c1e3c83 also write autogenerated subtitles 2021-04-10 06:56:19 -04:00
Nick Sweeting e7473c4e3e add some more padding to public index columns 2021-04-10 06:38:33 -04:00
Nick Sweeting 6949803395 enforce new models to use uuid keys 2021-04-10 06:32:45 -04:00
Nick Sweeting 1b67dd5c45 fix issues with django 3.2 2021-04-10 06:22:07 -04:00
Nick Sweeting 0c321a06d0 hide scrollbars in screenshots 2021-04-10 05:45:19 -04:00
Nick Sweeting c5a9b77f4f update user agents to mimic latest chrome 2021-04-10 05:45:02 -04:00
Nick Sweeting bf0da664be fix favicons on public index 2021-04-10 05:23:02 -04:00
Nick Sweeting 3093057675 bail out on sonic indexing after 5 errors 2021-04-10 05:18:03 -04:00
Nick Sweeting 89158d5dc7 allow full-text search from public index 2021-04-10 05:14:02 -04:00
Nick Sweeting 8a045706ce Update README.md 2021-04-10 04:56:43 -04:00
Nick Sweeting d1051e40bd remove tzlocal 2021-04-10 04:42:39 -04:00
Nick Sweeting a9986f1f05 add timezone support, tons of CSS and layout improvements, more detailed snapshot admin form info, ability to sort by recently updated, better grid view styling, better table layouts, better dark mode support 2021-04-10 04:21:36 -04:00
Nick Sweeting cf7d7e4990 add new timezone autosetting and cache header setting middlewares 2021-04-10 04:21:36 -04:00
Nick Sweeting 1977ae8962 add more helper props to snapshot for getting latest headers, favicon, etc 2021-04-10 04:21:36 -04:00
Nick Sweeting 8d68f1744e tweak columns in private snapshots list and shorten action names 2021-04-10 04:21:36 -04:00
Nick Sweeting 722f530c80 support adding and removing multiple tags at once using autocomplete multiselect field 2021-04-10 04:21:36 -04:00
Nick Sweeting a92e96500a Update README.md 2021-04-09 12:33:57 -04:00
Nick Sweeting 06c58ac42a fix unreachable config backup clear 2021-04-09 12:15:47 -04:00
Nick Sweeting 32764347ce add new SEARCH_BACKEND_TIMEOUT config option defaulted to 90sec 2021-04-09 00:27:12 -04:00
Nick Sweeting 34371d027a Update README.md 2021-04-08 10:51:16 -04:00
Nick Sweeting 0005f97510 Update README.md 2021-04-08 10:50:59 -04:00
Nick Sweeting 4464bbcf2c Update README.md 2021-04-08 10:49:00 -04:00
Nick Sweeting b57feb29ba Update README.md 2021-04-08 10:47:06 -04:00
Nick Sweeting 840a6bf84b Update README.md 2021-04-08 10:39:39 -04:00
Nick Sweeting 6a4f4d16f0 Update README.md 2021-04-08 10:37:57 -04:00
Nick Sweeting fb4caf6372 Update README.md 2021-04-08 10:36:07 -04:00
Nick Sweeting dd17ad6176 Update README.md 2021-04-08 10:26:23 -04:00
Nick Sweeting 940bd5072c Update README.md 2021-04-08 10:05:15 -04:00
Nick Sweeting 42c8e6c42a Update README.md 2021-04-08 10:01:49 -04:00
Nick Sweeting ea9d6820fb Update README.md 2021-04-08 10:00:19 -04:00
Nick Sweeting 8884dbcf34 Update README.md 2021-04-08 09:56:43 -04:00
Nick Sweeting 841288e8f1 Update README.md 2021-04-08 09:52:10 -04:00
Nick Sweeting af0c7aa5fb Update README.md 2021-04-08 09:48:11 -04:00
Nick Sweeting 2094c7113f Update README.md 2021-04-08 09:38:02 -04:00
Nick Sweeting 84981dc4fa Update README.md 2021-04-08 09:37:07 -04:00
Nick Sweeting f1d77f9739 Update README.md 2021-04-08 09:32:25 -04:00
Nick Sweeting d20402b1f2 Update README.md 2021-04-08 09:31:08 -04:00
Nick Sweeting f8227ce84f Update README.md 2021-04-08 09:28:41 -04:00
Nick Sweeting 8191595cb4 Update README.md 2021-04-08 09:27:08 -04:00
Nick Sweeting 68dac5b9f2 Update README.md 2021-04-08 09:26:29 -04:00
Nick Sweeting 1d1b65c43c Update README.md 2021-04-08 09:24:24 -04:00
Nick Sweeting 2ebe650fe8 Update README.md 2021-04-08 09:21:54 -04:00
Nick Sweeting 0f14b5a27d Update README.md 2021-04-08 09:19:55 -04:00
Nick Sweeting 31dd0949cc Update README.md 2021-04-08 09:12:24 -04:00
Nick Sweeting 56518f30bf Update README.md 2021-04-08 09:10:18 -04:00
Nick Sweeting f790670393 Update README.md 2021-04-08 09:08:45 -04:00
Nick Sweeting 36f56f6275 Update README.md 2021-04-08 09:06:42 -04:00
Nick Sweeting 40cff700a6 Update README.md 2021-04-08 09:04:44 -04:00
Nick Sweeting 7016c68768 Update README.md 2021-04-08 09:02:18 -04:00
Nick Sweeting 9a24320427 Update README.md 2021-04-08 08:59:12 -04:00
Nick Sweeting 796c46ff6f Update README.md 2021-04-08 08:46:24 -04:00
Nick Sweeting e38cc60bfe Update README.md 2021-04-08 08:29:48 -04:00
Nick Sweeting 5c0790bdd0 Update README.md 2021-04-08 08:27:36 -04:00
Nick Sweeting 8ae0450ad3 Update README.md 2021-04-08 08:20:38 -04:00
Nick Sweeting a81393b995 Update README.md 2021-04-08 08:03:26 -04:00
Nick Sweeting 1224cd197e Update README.md 2021-04-08 07:53:20 -04:00
Nick Sweeting d37aad4045 Update README.md 2021-04-08 07:43:09 -04:00
Nick Sweeting 4737988e5b Update README.md 2021-04-08 07:31:47 -04:00
Nick Sweeting 66187f2603 Update README.md 2021-04-08 07:21:04 -04:00
Nick Sweeting 17485c922f Update README.md 2021-04-08 06:57:58 -04:00
Nick Sweeting b851c22343 Update README.md 2021-04-08 06:47:45 -04:00
Nick Sweeting 74c855c0be Update README.md 2021-04-08 06:39:23 -04:00
Nick Sweeting 2e57df917e handle BaseExceptions properly 2021-04-08 06:08:17 -04:00
Nick Sweeting 87970a254f Update README.md 2021-04-08 06:03:46 -04:00
Nick Sweeting d4687a72cc Update README.md 2021-04-08 06:01:04 -04:00
Nick Sweeting 2e12738c1f Update README.md 2021-04-08 05:56:42 -04:00
Nick Sweeting d7fb041978 Update README.md 2021-04-08 05:49:36 -04:00
Nick Sweeting 9d0e7c4cf7 Update README.md 2021-04-08 05:47:55 -04:00
Nick Sweeting c4a719eca3 Update README.md 2021-04-08 05:46:27 -04:00
Nick Sweeting e47f149816 Update README.md 2021-04-08 05:33:00 -04:00
Nick Sweeting 8e428a942d fix url 2021-04-08 03:33:49 -04:00
Nick Sweeting e4c95a5369 update demo urls to new demo.archivebox.io domain 2021-04-08 03:33:01 -04:00
Nick Sweeting f37c77c859 Update docker-compose.yml 2021-04-07 22:50:18 -04:00
Nick Sweeting 2c07d8f1f3 fix lgtm alerts 2021-04-06 13:47:26 -04:00
Nick Sweeting 21bb84f627 fix lint errors 2021-04-06 02:50:51 -04:00
Nick Sweeting e92db03f5f fallback to using bin_path to find archivebox binary if sys.argv is empty 2021-04-06 02:45:57 -04:00
Nick Sweeting c98053a8ca allow filtering by archiveresult status 2021-04-06 02:07:22 -04:00
Nick Sweeting f67a5a215a fix readability indexing process and implement a max total character length on indexed content 2021-04-06 02:01:38 -04:00
Nick Sweeting b3a89172ab add original src url to dosctring 2021-04-06 01:38:24 -04:00
Nick Sweeting e7c7a8f71c force kill forked child processes after each extractor command 2021-04-06 01:35:21 -04:00
Nick Sweeting af6561b527 remove CDN jquery and bootstrap by inlining them instead 2021-04-06 01:13:31 -04:00
Nick Sweeting e4823cc5d7 add back CUSTOM_TEMPLATES_DIR config option 2021-04-06 00:57:20 -04:00
Nick Sweeting 4b098bea15 fix navbar showing user info even when not logged in 2021-04-06 00:44:01 -04:00
Nick Sweeting 68a506e1a7 fix Snapshot admin actions buttons submitting on Safari 2021-04-06 00:43:49 -04:00
Nick Sweeting 5a9f27204a dont use chrome when its not available on windows systems 2021-04-05 23:33:08 -04:00
Nick Sweeting 83468fd304 add sonic backfill instructions to docker-compose 2021-04-05 23:24:12 -04:00
Nick Sweeting 1485e4eb90 use older docker compose version for more features 2021-04-05 23:22:05 -04:00
Nick Sweeting cadac4838e fix searching by url contents 2021-04-05 23:21:07 -04:00
Nick Sweeting 2fc9f1cfa3 dont depend on README in Dockerfile install step 2021-04-05 23:17:07 -04:00
Nick Sweeting 6e1f1a2466 enforce wrapping on code blocks 2021-04-05 21:48:17 -04:00
Nick Sweeting 555fee6486 bump docs version 2021-04-05 21:33:18 -04:00
Nick Sweeting 3691c4184a update README to use new setup flag for easier init 2021-04-05 21:15:44 -04:00
Nick Sweeting 8b4f84959a new setup flag on init command to autosetup on init 2021-04-05 21:15:32 -04:00
Nick Sweeting 5c181532b5 only install dependencies in setup if they are missing 2021-04-05 21:15:10 -04:00
Nick Sweeting 21820acecd import User model into core models for convenience 2021-04-05 21:14:22 -04:00
Nick Sweeting bbebc33b7a better autodetection of node modules dependencies 2021-04-05 21:14:06 -04:00
Nick Sweeting 211bf86f4a fix bug checking value in subcommand_args when None 2021-04-05 21:13:49 -04:00
Nick Sweeting 79fb60a47a dont allow caching when installing packages 2021-04-05 20:08:48 -04:00
Nick Sweeting 05c17af4dc fix lint errors 2021-04-05 20:00:16 -04:00
Nick Sweeting ef76f7c490 require running setup in existing data dir 2021-04-05 19:59:33 -04:00
Nick Sweeting 8c1d12020f fix lint errors 2021-04-05 19:53:42 -04:00
Nick Sweeting 3449bac5bc add new setup command to autoinstall dependencies 2021-04-05 19:51:57 -04:00
Nick Sweeting 24f7dd9c05 dont raise systemexit on succesful config 2021-04-05 19:51:40 -04:00
Nick Sweeting 31ba235a82 re-add lockfile 2021-04-05 18:13:48 -04:00
Nick Sweeting 0b72d26bce attempt to fix bad bin linking on linux 2021-04-05 17:41:04 -04:00
Nick Sweeting 2ce6610d70 change latest behavior back to auto 2021-04-05 17:10:19 -04:00
Nick Sweeting 5009a845b5 try latest=true 2021-04-05 17:01:42 -04:00
Nick Sweeting 26f591fd78 update docker tags github action 2021-04-05 15:48:30 -04:00
Nick Sweeting 84b722c2d8 remove postinstall step 2021-04-03 15:53:32 -04:00
Nick Sweeting ffd119c66c fix linter 2021-04-03 15:51:27 -04:00
Nick Sweeting eef9adbfcb fix select invalid test 2021-04-03 15:50:48 -04:00
Nick Sweeting 7162649b03 fix search fields on admin snapshots list 2021-04-01 14:00:07 -04:00
Nick Sweeting 41d6a09719 add npm postinstall command to install archivebox via pip 2021-04-01 14:00:07 -04:00
Nick Sweeting 5492c204b8 tweak add page copy 2021-04-01 14:00:07 -04:00
Nick Sweeting f59b6d4189 only add url-list lines that are real urls 2021-04-01 14:00:07 -04:00
Nick Sweeting d73f7d7d96 add db_index on url field 2021-04-01 14:00:07 -04:00
Nick Sweeting c438e49871 fix clicking on tags on snapshot list 2021-04-01 14:00:07 -04:00
Nick Sweeting 0e39a2098d split migrations and data dir check 2021-04-01 14:00:07 -04:00
Nick Sweeting e96141cee9 Update docker-compose.yml 2021-04-01 13:06:27 -04:00
Nick Sweeting 5d7ad9b1b4 add form parser option 2021-04-01 02:34:16 -04:00
Nick Sweeting 59d5423483 fix snapshot icon caching and ordering 2021-04-01 02:22:15 -04:00
Nick Sweeting fc5d99b4b4 fix missing package.json repo 2021-04-01 01:40:02 -04:00
Nick Sweeting ff9445648b fix navbar and tags display 2021-04-01 01:39:34 -04:00
Nick Sweeting 7ee7a94e67 fix str split using old overwrite pipe instead of append pipe 2021-03-31 11:30:15 -04:00
Nick Sweeting 005da31f8f add overwrite flag option to schedule cmd 2021-03-31 11:29:51 -04:00
Nick Sweeting 534ae60ab0 dont overwrite the log on each run, and use dedicated log for scheduled archiving 2021-03-31 11:20:42 -04:00
Nick Sweeting 5d3a03b299 use stderr and hint in case of parser returning no urls instead of bare exception 2021-03-31 01:39:01 -04:00
Nick Sweeting 05da16db99 Merge branch 'dev' 2021-03-31 01:06:04 -04:00
Nick Sweeting 8ce93ff787 use KEY, NAME, and PARSER to define parsers instead of hardcoding in init 2021-03-31 01:05:49 -04:00
Nick Sweeting 36f0646501 Merge pull request #669 from FliegendeWurst/fix-issue-235
add command: --parser option (fixes #235)
2021-03-31 00:53:47 -04:00
Nick Sweeting 2656e59215 change list style 2021-03-31 00:47:42 -04:00
Nick Sweeting fe2b682b1f Merge pull request #680 from ArchiveBox/dev 2021-03-31 00:04:26 -04:00
Nick Sweeting 354b4627ed fix tests 2021-03-30 23:39:15 -04:00
Nick Sweeting 0090d36d23 add mercury in extractors list 2021-03-30 23:16:44 -04:00
Nick Sweeting b4da26e990 Merge pull request #685 from ArchiveBox/dependabot/npm_and_yarn/y18n-5.0.5
Bump y18n from 5.0.1 to 5.0.5
2021-03-30 15:15:18 -04:00
dependabot[bot] fc3798a9e8 Bump y18n from 5.0.1 to 5.0.5
Bumps [y18n](https://github.com/yargs/y18n) from 5.0.1 to 5.0.5.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/compare/v5.0.1...v5.0.5)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-30 18:50:42 +00:00
Nick Sweeting bd8a01e677 sigh vendor yet another dependency 2021-03-30 14:49:42 -04:00
FliegendeWurst 60bd9a902e add command: --parser option 2021-03-28 10:09:11 +02:00
Nick Sweeting a58ad5b272 allow larger tags 2021-03-27 05:52:42 -04:00
Nick Sweeting 7e7c134ec0 use exc 2021-03-27 05:32:07 -04:00
Nick Sweeting 63919dc90a Merge branch 'debug-toolbar' into dev 2021-03-27 05:29:15 -04:00
Nick Sweeting 09842c1f7e autocreate logs dir 2021-03-27 05:28:54 -04:00
Nick Sweeting 5568b3b612 fix adding and removing tags via admin snapshot list 2021-03-27 05:27:02 -04:00
Nick Sweeting 6fb7bbf2fb Merge pull request #655 from ArchiveBox/debug-toolbar 2021-03-27 05:14:51 -04:00
Nick Sweeting 49b77b91bb fix unused var 2021-03-27 05:12:54 -04:00
Nick Sweeting 5e57c9e371 Update README.md 2021-03-27 05:04:45 -04:00
Nick Sweeting c6d8719e24 Update README.md 2021-03-27 05:01:27 -04:00
Nick Sweeting 54a923838f Update README.md 2021-03-27 04:57:49 -04:00
Nick Sweeting b272f26b98 Update README.md 2021-03-27 04:55:47 -04:00
Nick Sweeting 5aab5a2a2b Update README.md 2021-03-27 04:54:59 -04:00
Nick Sweeting 3e26ae4a66 support finding multiple urls as substrings in text 2021-03-27 04:30:40 -04:00
Nick Sweeting f3a3d76439 support adding urls with tags directly via CLI and add page 2021-03-27 04:30:15 -04:00
Nick Sweeting 5fb9ca389f check more url parsing invariants on startup 2021-03-27 03:57:22 -04:00
Nick Sweeting fea0b89dbe add tag cli option 2021-03-27 03:57:05 -04:00
Nick Sweeting 334061f17e bump version number 2021-03-27 02:22:54 -04:00
Nick Sweeting d486f8c162 use tempfile when logs dir is not available 2021-03-27 02:17:12 -04:00
Nick Sweeting 3c2f4c95cf fix unused import 2021-03-27 01:50:26 -04:00
Nick Sweeting 56c6b11b14 add comment 2021-03-27 01:19:32 -04:00
Nick Sweeting bd6d9c165b enforce utf8 on literally all file operations because windows sucks 2021-03-27 01:16:29 -04:00
Nick Sweeting 185d2f9f9b check for JSON1 extesnion in Sqlite on startup 2021-03-27 00:27:58 -04:00
Nick Sweeting 177d22da50 fix clicking add tag when no tag is selected throwing 500 2021-03-01 01:39:06 -05:00
Nick Sweeting 76ff789164 fix tags page rendering when snapshot has no updated timestamp 2021-03-01 00:40:41 -05:00
Nick Sweeting 0036e9cce2 add migration 2021-02-28 22:55:12 -05:00
Nick Sweeting 9fc9f52cfb increase sqlite timeout and dont check for same thread condition 2021-02-28 22:55:00 -05:00
Nick Sweeting 1cabde3ccd remove atomic transactions 2021-02-28 22:54:40 -05:00
Nick Sweeting 844b5c5e20 minor db fix 2021-02-28 22:54:04 -05:00
Nick Sweeting 62089fdb22 close sqlite3 connections if unused 2021-02-28 22:53:34 -05:00
Nick Sweeting 6aef1dd98d dont check db if init is pending 2021-02-28 22:53:23 -05:00
Nick Sweeting 308be35367 Update bug_report.md 2021-02-24 23:08:27 -05:00
Nick Sweeting acbce25201 missing migrations 2021-02-18 08:05:05 -05:00
Nick Sweeting 9ead76f9f8 cross link snapshot index, admin, and actions pages using uuids 2021-02-18 08:04:50 -05:00
Nick Sweeting c257ae240e only enable debug toolbar if nothreading is on 2021-02-18 08:04:26 -05:00
Nick Sweeting ca2bb673ea improve inline admin forms and add tags autocomplete 2021-02-18 08:04:14 -05:00
Nick Sweeting 33df9c1ebe fix after and before in remove tests 2021-02-18 06:21:44 -05:00
Nick Sweeting 9c07fbdc0b unused import fix 2021-02-18 05:56:00 -05:00
Nick Sweeting 4f5bb3776c fix sql err 2021-02-18 05:51:53 -05:00
Nick Sweeting 106f6adc59 fix lgtm alerts 2021-02-18 04:29:24 -05:00
Nick Sweeting 46a4197514 fix tests 2021-02-18 04:26:56 -05:00
Nick Sweeting 13c2b7c8eb fix url used by gridview 2021-02-18 02:38:30 -05:00
Nick Sweeting e3b69c450c move db checks under check_db if statement 2021-02-18 02:37:33 -05:00
Nick Sweeting 74a9dd8880 add missing migrations 2021-02-18 02:36:21 -05:00
Nick Sweeting 7282f4a42a add favicon and robots to internal static dir 2021-02-18 02:35:51 -05:00
Nick Sweeting dcc812b9f5 make output link easier to see on archiveresult admin list 2021-02-18 02:35:33 -05:00
Nick Sweeting df47486d31 make admin link easier to find on snapshot detail page 2021-02-18 02:35:11 -05:00
Nick Sweeting 8369ca0b87 fix snapshot detail page titles garbled due to double escaping 2021-02-18 02:34:56 -05:00
Nick Sweeting ba2afa0672 allow cancelling init partway through and show hints to resume 2021-02-18 02:34:42 -05:00
Nick Sweeting 75e1bfd0a9 create_or_update ArchiveResults from history instead of get_or_create 2021-02-18 02:34:20 -05:00
Nick Sweeting 69e0302c2a only add https to url if needed when prompting to save new snapshot 2021-02-18 02:34:03 -05:00
Nick Sweeting 9918bddb7e remove emptystring tags before saving 2021-02-18 02:33:42 -05:00
Nick Sweeting f1823381d0 improve latest title logic to take longest title 2021-02-18 02:33:28 -05:00
Nick Sweeting ad4bd49163 increase max title length to 512 2021-02-18 02:33:08 -05:00
Nick Sweeting 4e5671dda9 add ability to add and remove tags directly from snapshot list 2021-02-18 02:32:58 -05:00
Nick Sweeting c877d67e0c check if pragma is already wal before setting 2021-02-18 02:31:42 -05:00
Nick Sweeting 7b7aa239fd autocreate db cache table when needed and reenable WAL mode 2021-02-17 18:26:04 -05:00
Nick Sweeting d60d73754d fix favicon.ico and robots.txt served by runserver 2021-02-17 18:25:47 -05:00
Nick Sweeting a8a6752b06 add CACHE_BACKEND options to settings for easier dev 2021-02-17 18:25:23 -05:00
Nick Sweeting 3457773988 fix ArchiveResult extractor showing up on top of dropdown in admin inline form 2021-02-17 18:25:01 -05:00
Nick Sweeting 05dbb1c160 add healthcheck to dockerfile 2021-02-17 18:24:38 -05:00
Nick Sweeting 084cf7ff51 add more explanation about snapshot.save timestamp bump 2021-02-17 13:34:46 -05:00
Nick Sweeting 265bcc0264 fix lint errors2 2021-02-16 16:29:41 -05:00
Nick Sweeting 6f0eec92eb fix lint errors 2021-02-16 16:26:48 -05:00
Nick Sweeting d89034dcde disable debug_toolbar by default 2021-02-16 16:23:09 -05:00
Nick Sweeting 71cf8d5224 add migrations 2021-02-16 15:57:13 -05:00
Nick Sweeting 68e22fdaba add uwsgi config file 2021-02-16 15:56:46 -05:00
Nick Sweeting 908262b133 add dev dependencies install commented out in Dockerfile 2021-02-16 15:55:47 -05:00
Nick Sweeting 118bddb41a fix admin title and url truncation and add edit/admin link and ID to Snapshot detail page 2021-02-16 15:55:07 -05:00
Nick Sweeting 05e891632c add snapshot_id to Link and uuid to ArchiveResult 2021-02-16 15:54:27 -05:00
Nick Sweeting 0b8bdd8cc5 show dir size after each snapshot archiving process in cli 2021-02-16 15:53:52 -05:00
Nick Sweeting acb932ba12 improve readability and mercury error handling and fix output path to be relative 2021-02-16 15:53:11 -05:00
Nick Sweeting c95698e608 bump Snapshot.updated time after each extractor, change extractor order 2021-02-16 15:52:18 -05:00
Nick Sweeting 28e50c5e49 tweak snapshot asset serving logic to show multiple choices in case of conflict 2021-02-16 15:51:56 -05:00
Nick Sweeting a6c64f2560 only get archive_size if index.html is present within dir 2021-02-16 15:51:17 -05:00
Nick Sweeting c24e4bf11d tweak django debug toolbar panels and add djdt_flamegraph 2021-02-16 15:51:02 -05:00
Nick Sweeting 1e2188517b speed up latest_title by preferring db title if present 2021-02-16 15:49:53 -05:00
Nick Sweeting 8b236b9367 cache dir size, snapshot icons, tags str, and title in django cache 2021-02-16 15:49:29 -05:00
Nick Sweeting 51440ede3a show extra fields on ArchiveResult admin list and detail page 2021-02-16 15:47:49 -05:00
Nick Sweeting 74c882ef16 add nothreading arg to archivebox server for perf profiling 2021-02-16 15:46:58 -05:00
Nick Sweeting 8cde6bc676 add gist.github.com to git domains 2021-02-16 07:36:09 -05:00
Nick Sweeting bdf1b102be load ArchiveResults from orphaned links history during init 2021-02-16 06:20:05 -05:00
Nick Sweeting d0f8a5e710 change mercury atomic_write output order 2021-02-16 06:19:16 -05:00
Nick Sweeting e62ecffeb0 disable django caches explicitly 2021-02-16 06:18:39 -05:00
Nick Sweeting 988a10a9f6 fix warc path in snapshot_icons 2021-02-16 06:18:05 -05:00
Nick Sweeting 48eddadc9b inline archive_size and archive_path snapshot methods 2021-02-16 06:16:02 -05:00
Nick Sweeting 46a7b972ee disable WAL and cache table for now 2021-02-16 06:13:50 -05:00
Nick Sweeting 24ae01cdbe add ArchiveResult admin 2021-02-16 06:13:27 -05:00
Nick Sweeting 87069dd41e fix wsgi.py 2021-02-16 04:16:51 -05:00
Nick Sweeting 8c2d81c041 always serve static files 2021-02-16 04:16:24 -05:00
Nick Sweeting fb8e6cabcb fix error log location 2021-02-16 04:15:09 -05:00
Nick Sweeting 26fa63749d add debug toolbar 2021-02-16 02:50:05 -05:00
Nick Sweeting fad2620c62 minor tweaks 2021-02-16 02:49:31 -05:00
Nick Sweeting ae43d35e65 fix color option in docker 2021-02-16 01:26:26 -05:00
Nick Sweeting 4eac9d0ec1 show extra system setup info in version output 2021-02-16 01:23:58 -05:00
Nick Sweeting 82de67db34 fix missing/outdated template variables 2021-02-16 01:23:31 -05:00
Nick Sweeting 22da885148 log every archivebox command run to the errors.log 2021-02-16 01:23:01 -05:00
Nick Sweeting 19f7c907e0 exec archivebox from docker entrypoint script to avoid nesting pid under bash 2021-02-16 01:22:36 -05:00
Nick Sweeting 10d687c55c add new MEDIA_MAX_SIZE=750m size option for limiting youtubedl downloads 2021-02-16 01:21:19 -05:00
Nick Sweeting 49939f3eaa only accept stdin if args are not passed, fix stdin hang in docker 2021-02-16 01:20:47 -05:00
Nick Sweeting 293686962a add more info about -it vs -T flags for docker users 2021-02-16 00:03:51 -05:00
Nick Sweeting 5cccd55ca7 clarify docker input and tty flags when piping stdin 2021-02-15 23:32:24 -05:00
Nick Sweeting de1a939df4 fix public index pagination links and total count 2021-02-15 21:07:12 -05:00
Nick Sweeting 108cb100b3 hotlink direclty to specific archivebox release from footer info 2021-02-15 21:06:56 -05:00
Nick Sweeting 56ec19fb29 allow hotlinking directly to a specific extractor output using hash on Snapshot detail page 2021-02-15 20:55:22 -05:00
Nick Sweeting a089136fea fix missing stdout template var on /add when not rendering success page 2021-02-15 20:54:47 -05:00
Nick Sweeting b6d7c74680 speed up the Snapshot handling view and show index page when extractor output is missing or multiple snapshots returned 2021-02-15 20:52:08 -05:00
Nick Sweeting 0375853683 log error tracebacks to logs/errors.log file and filter noisy 404s and 200s from log output 2021-02-15 20:51:23 -05:00
Nick Sweeting 0ec9bfb971 fix dead missing template variables 2021-02-15 20:50:12 -05:00
Nick Sweeting b3a50a2c10 fix server quick-init param not being passed properly to subcommand 2021-02-15 20:49:40 -05:00
Nick Sweeting b06e256ad9 fix add command not updating snapshot detail index pages when passed index-only and overwrite flags together 2021-02-15 20:49:23 -05:00
Nick Sweeting 8e98cef7ad fix after and before args flipped when filtering 2021-02-15 20:48:51 -05:00
Nick Sweeting 33d180afe7 allow filtering snapshots by timestamp in list, update, and remove cmds 2021-02-15 20:48:35 -05:00
Nick Sweeting 0c9db1c554 remove symbols from random secret key for easier copy pastin 2021-02-15 20:45:42 -05:00
Nick Sweeting 4faef03ba3 compute snapshot properties directly without loading whole Link 2021-02-15 20:44:08 -05:00
Nick Sweeting 9ce3bd5bdc use index.LINK_FILTERS to validate filter-type args instead of hardocding them twice 2021-02-15 20:43:36 -05:00
Nick Sweeting c28ad8bd1b fix AddLinkForm widget complaining about missing template var class 2021-02-15 20:42:59 -05:00
Nick Sweeting 78463c243a remove unused GIT_SHA config option 2021-02-15 20:42:33 -05:00
Nick Sweeting 9cd4ba38f0 add new SNAPSHOTS_PER_PAGE pagination limit config 2021-02-15 20:42:00 -05:00
Nick Sweeting 3e96871386 add comment explaining commented out lines in docker-compose file 2021-02-15 14:53:03 -05:00
Nick Sweeting 00ae1f15a7 ignore shm db file and config files in archivebox data dir on init 2021-02-15 14:52:37 -05:00
Nick Sweeting 3c3bae02d2 add quick-init option to skip reimporting all snapshot dirs on init 2021-02-15 14:52:10 -05:00
Nick Sweeting e61e12c889 use setup.py to determine dependencies in Dockerfile instead of egg-info requires.txt 2021-02-15 14:51:32 -05:00
Nick Sweeting 0407d03b6b add cli tests file back 2021-02-15 13:39:49 -05:00
Nick Sweeting 128e7f0e8d dont show progress bars in docker by default 2021-02-15 13:39:37 -05:00
Nick Sweeting 611216765d switch sqlite to use WAL mode by default to prevent database locked errors 2021-02-15 13:39:03 -05:00
Nick Sweeting 188670eb8b disable sonic by default in docker-compose and add instructions 2021-02-15 13:38:32 -05:00
Nick Sweeting e8069f8043 Update README.md 2021-02-09 21:58:25 -05:00
Nick Sweeting 8439f3f532 Update README.md 2021-02-09 21:57:53 -05:00
Nick Sweeting 7e18fb8765 Update README.md 2021-02-09 21:56:40 -05:00
Nick Sweeting af09ac0e7f Update README.md 2021-02-09 21:52:55 -05:00
Nick Sweeting 2b9282e754 send some love to monadical 2021-02-09 21:45:28 -05:00
Nick Sweeting 7d62fc23fa fix macOS unnecessary version limitation 2021-02-09 21:40:46 -05:00
Nick Sweeting 9ac1f8c5a1 Update README.md 2021-02-09 21:38:25 -05:00
Nick Sweeting 7b1b1a2374 make arm support for apt explicit 2021-02-09 12:45:24 -05:00
Nick Sweeting 90ef5e14b5 fix dangling small and update apt sources instructions 2021-02-09 12:36:30 -05:00
Nick Sweeting 9766ea21a7 Merge pull request #652 from ArchiveBox/dev 2021-02-09 16:24:30 +02:00
Nick Sweeting 39e7f84156 bump version 2021-02-09 00:13:16 -05:00
Nick Sweeting 683a08772b change wording of db not found error 2021-02-08 23:27:46 -05:00
Nick Sweeting 6705354e57 fix assertion 2021-02-08 23:24:48 -05:00
Nick Sweeting 0e6ac39ee1 automate deb dist better and bump version 2021-02-08 23:22:24 -05:00
Nick Sweeting a49884ade8 fix emptystrings in cmd_version causing exception 2021-02-08 23:22:02 -05:00
Nick Sweeting a32eac3f11 skip tests during debian build 2021-02-08 20:39:29 -05:00
Nick Sweeting 66fb5b278a move debsign to release not build 2021-02-08 20:39:20 -05:00
Nick Sweeting 08161a526b slightly tweak test verbiage 2021-02-08 12:14:09 -05:00
Nick Sweeting d4aa8f6237 Update README.md 2021-02-02 11:52:02 -05:00
Nick Sweeting 97c113d753 add export example to output formats 2021-02-02 11:48:56 -05:00
Nick Sweeting c7fec8defe Update README.md 2021-02-02 11:29:57 -05:00
Nick Sweeting 7b10e88a69 update detail page screenshots 2021-02-02 11:28:15 -05:00
Nick Sweeting 171bbeb69b catch exception on import of old index.json into ArchiveResult 2021-02-01 16:31:29 -05:00
Nick Sweeting 0aea5ed3e8 fix handling of skipped ArchiveResult entries with null output 2021-02-01 14:37:34 -05:00
Nick Sweeting b90afc8c04 nodesource apt repo not supported on i386 2021-02-01 06:05:12 -05:00
Nick Sweeting 7e7ffcac18 bump pip and node versions 2021-02-01 05:45:21 -05:00
Nick Sweeting 6ab3cd2a66 push all tags 2021-02-01 05:38:13 -05:00
Nick Sweeting 8cadcfbd96 bump submodule versions 2021-02-01 05:16:04 -05:00
Nick Sweeting c4b02be24d remove dead tests code 2021-02-01 05:14:43 -05:00
Nick Sweeting 783f597955 minor build fixes 2021-02-01 05:13:46 -05:00
Nick Sweeting aa84a7ff2b fix migration creating conflicting tags based on slug 2021-02-01 05:13:23 -05:00
Nick Sweeting 4d21a04ab5 0.5.4 release 2021-02-01 03:09:31 -05:00
Nick Sweeting 4123cdc15f fix shellscript perms 2021-02-01 02:52:46 -05:00
Nick Sweeting ea15a71012 Merge pull request #636 from ArchiveBox/dev 2021-02-01 09:51:51 +02:00
165 changed files with 9612 additions and 4443 deletions

No files matched your search

+7 -2
View File
@@ -5,16 +5,21 @@ __pycache__/
.mypy_cache/
.pytest_cache/
.github/
.git/
.pdm-build/
.pdm-python/
.eggs/
venv/
.venv/
.docker-venv/
node_modules/
build/
dist/
pip_dist/
!pip_dist/archivebox.egg-info/requires.txt
brew_dist/
deb_dist/
pip_dist/
assets/
data/
-6
View File
@@ -1,6 +0,0 @@
[flake8]
ignore = D100,D101,D102,D103,D104,D105,D202,D203,D205,D400,E131,E241,E252,E266,E272,E701,E731,W293,W503,W291,W391
select = F,E9,W
max-line-length = 130
max-complexity = 10
exclude = migrations,tests,node_modules,vendor,venv,.venv,.venv2,.docker-venv
+2 -2
View File
@@ -1,8 +1,8 @@
---
name: 🐞 Bug report
about: Create a report to help us improve
title: 'Bugfix: ...'
labels: 'changes: bugfixes'
title: 'Bug: ...'
labels: 'bug'
assignees: ''
---
+6 -6
View File
@@ -9,10 +9,10 @@ env:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
fetch-depth: 1
@@ -38,17 +38,17 @@ jobs:
run: |
cd deb_dist/
sudo apt-get install ./archivebox*.deb
cd ..
python3 -c 'from distutils.core import run_setup; result = run_setup("./setup.py", stop_after="init"); print("\n".join(result.install_requires + result.extras_require["sonic"]))' > ./requirements.txt
python3 -m pip install -r ./requirements.txt
- name: Check ArchiveBox version
run: |
# must create dir needed for snaps to run as non-root on github actions
sudo mkdir -p /run/user/1001 && sudo chmod -R 777 /run/user/1001
mkdir "${{ github.workspace }}/data" && cd "${{ github.workspace }}/data"
archivebox init
archivebox config --set SAVE_READABILITY=False
archivebox config --set SAVE_MERCURY=False
archivebox config --set SAVE_SINGLEFILE=False
archivebox --version
archivebox init --setup
- name: Add some links to test
run: |
+23 -16
View File
@@ -1,10 +1,13 @@
name: Build Docker image
on:
on: workflow_dispatch
release:
types:
- created
workflow_dispatch:
push:
branches:
- '**'
tags:
- 'v*'
# pull_request:
env:
DOCKER_IMAGE: archivebox-ci
@@ -15,20 +18,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
with:
version: latest
install: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Builder instance name
run: echo ${{ steps.buildx.outputs.name }}
@@ -37,7 +41,7 @@ jobs:
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
@@ -45,7 +49,7 @@ jobs:
${{ runner.os }}-buildx-
- name: Docker Login
uses: docker/login-action@v1
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKER_USERNAME }}
@@ -53,17 +57,20 @@ jobs:
- name: Collect Docker tags
id: docker_meta
uses: crazy-max/ghaction-docker-meta@v1
uses: docker/metadata-action@v5
with:
images: archivebox/archivebox,nikisweeting/archivebox
tag-sha: true
tag-semver: |
{{version}}
{{major}}.{{minor}}
flavor: |
latest=auto
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v5
with:
context: ./
file: ./Dockerfile
@@ -72,7 +79,7 @@ jobs:
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+2 -1
View File
@@ -23,11 +23,12 @@ jobs:
cd brew_dist/
brew install --build-bottle ./archivebox.rb
# brew bottle archivebox
archivebox version
- name: Add some links to test
run: |
mkdir data && cd data
archivebox init
archivebox init --setup
archivebox add 'https://example.com'
archivebox version
archivebox status
+3 -2
View File
@@ -28,7 +28,8 @@ jobs:
- name: Lint with flake8
run: |
cd archivebox
# one pass for show-stopper syntax errors or undefined names
flake8 archivebox --count --show-source --statistics
flake8 . --count --show-source --statistics
# one pass for small stylistic things
flake8 archivebox --count --max-line-length="$MAX_LINE_LENGTH" --statistics
flake8 . --count --max-line-length="$MAX_LINE_LENGTH" --statistics
+27 -17
View File
@@ -7,29 +7,35 @@ on:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: 3.9
python-version: 3.11
architecture: x64
- name: Build Python Package
- uses: pdm-project/setup-pdm@v3
- name: Install dependencies
run: pdm install --fail-fast --no-lock --group :all --no-self
- name: Build package
run: |
pip3 install --upgrade pip setuptools wheel
rm -Rf pip_dist/*.whl
python3 setup.py \
sdist --dist-dir=./pip_dist \
bdist_wheel --dist-dir=./pip_dist \
egg_info --egg-base=./pip_dist
pip install pip_dist/archivebox-*.whl
rm ./dist/archivebox-*.whl
pdm build
- name: Install from build
run: pip install ./dist/archivebox-*.whl
- name: Add some links to test
run: |
@@ -39,6 +45,15 @@ jobs:
archivebox version
archivebox status
- name: Publish package distributions to PyPI
run: pdm publish --no-build
# - name: Push build to PyPI
# run: |
# cd pip_dist/
# python3 -m twine upload --repository testpypi pip_dist/*.{whl,tar.gz}
# python3 -m twine upload --repository pypi pip_dist/*.{whl,tar.gz}
# - name: Commit built package
# run: |
# cd pip_dist/
@@ -54,8 +69,3 @@ jobs:
# branch: ${{ github.ref }}
# directory: pip_dist
# - name: Push build to PyPI
# run: |
# cd pip_dist/
# python3 -m twine upload --repository testpypi pip_dist/*.{whl,tar.gz}
# python3 -m twine upload --repository pypi pip_dist/*.{whl,tar.gz}
+10 -7
View File
@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-20.04, macos-latest, windows-latest]
python: [3.7]
python: [3.9]
steps:
- uses: actions/checkout@v2
@@ -24,15 +24,18 @@ jobs:
### Setup Python & JS Languages
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
architecture: x64
- name: Set up Node JS 14.7.0
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.7.0
node-version: 18.12.0
- name: Setup PDM
uses: pdm-project/setup-pdm@v3
### Install Python & JS Dependencies
- name: Get pip cache dir
@@ -51,9 +54,9 @@ jobs:
- name: Install pip dependencies
run: |
python -m pip install --upgrade pip setuptools wheel pytest bottle
python -m pip install --upgrade pip setuptools wheel pytest bottle build
./bin/build_pip.sh
python -m pip install .
pdm install
- name: Get npm cache dir
id: npm-cache
@@ -90,7 +93,7 @@ jobs:
# TODO: remove this exception for windows once we get tests passing on that platform
if: ${{ !contains(matrix.os, 'windows') }}
run: |
python -m pytest -s --ignore=archivebox/vendor
python -m pytest -s --basetemp=tests/out --ignore=archivebox/vendor --ignore=deb_dist --ignore=pip_dist --ignore=brew_dist
docker_tests:
runs-on: ubuntu-latest
+5
View File
@@ -13,6 +13,8 @@ venv/
node_modules/
# Packaging artifacts
.pdm-python
.pdm-build
archivebox.egg-info
archivebox-*.tar.gz
build/
@@ -24,3 +26,6 @@ data1/
data2/
data3/
output/
# vim
*.sw?
+3
View File
@@ -23,3 +23,6 @@
[submodule "archivebox/vendor/django-taggit"]
path = archivebox/vendor/django-taggit
url = https://github.com/jazzband/django-taggit
[submodule "archivebox/vendor/python-atomicwrites"]
path = archivebox/vendor/python-atomicwrites
url = https://github.com/untitaker/python-atomicwrites
+249 -69
View File
@@ -1,117 +1,297 @@
# This is the Dockerfile for ArchiveBox, it bundles the following dependencies:
# python3, ArchiveBox, curl, wget, git, chromium, youtube-dl, single-file
# python3, ArchiveBox, curl, wget, git, chromium, youtube-dl, yt-dlp, single-file
# Usage:
# git submodule update --init --recursive
# git pull --recurse-submodules
# docker build . -t archivebox --no-cache
# docker run -v "$PWD/data":/data archivebox init
# docker run -v "$PWD/data":/data archivebox add 'https://example.com'
# docker run -v "$PWD/data":/data -it archivebox manage createsuperuser
# docker run -v "$PWD/data":/data -p 8000:8000 archivebox server
# Multi-arch build:
# docker buildx create --use
# docker buildx build . --platform=linux/amd64,linux/arm64,linux/arm/v7 --push -t archivebox/archivebox:latest -t archivebox/archivebox:dev
#
# Read more about [developing Archivebox](https://github.com/ArchiveBox/ArchiveBox#archivebox-development).
FROM python:3.9-slim-buster
# Use Debian 12 w/ faster package updates: https://packages.debian.org/bookworm-backports/
FROM python:3.11-slim-bookworm
LABEL name="archivebox" \
maintainer="Nick Sweeting <archivebox-docker@sweeting.me>" \
maintainer="Nick Sweeting <dockerfile@archivebox.io>" \
description="All-in-one personal internet archiving container" \
homepage="https://github.com/ArchiveBox/ArchiveBox" \
documentation="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#docker"
# System-level base config
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
######### Environment Variables #################################
# Global system-level config
ENV TZ=UTC \
LANGUAGE=en_US:en \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
PYTHONIOENCODING=UTF-8 \
PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1
PIP_DISABLE_PIP_VERSION_CHECK=1 \
npm_config_loglevel=error
# Application-level base config
# Version config
ENV PYTHON_VERSION=3.11 \
NODE_VERSION=21
# User config
ENV ARCHIVEBOX_USER="archivebox" \
DEFAULT_PUID=911 \
DEFAULT_PGID=911
# Global paths
ENV CODE_DIR=/app \
VENV_PATH=/venv \
DATA_DIR=/data \
NODE_DIR=/node \
ARCHIVEBOX_USER="archivebox"
GLOBAL_VENV=/venv \
PLAYWRIGHT_BROWSERS_PATH=/browsers
# Application-level paths
ENV APP_VENV=/app/.venv \
NODE_MODULES=/app/node_modules
# Build shell config
ENV PATH="$PATH:$GLOBAL_VENV/bin:$APP_VENV/bin:$NODE_MODULES/.bin"
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
######### System Environment ####################################
# Detect ArchiveBox version number by reading package.json
COPY --chown=root:root --chmod=755 package.json "$CODE_DIR/"
RUN grep '"version": ' "${CODE_DIR}/package.json" | awk -F'"' '{print $4}' > /VERSION.txt
# Force apt to leave downloaded binaries in /var/cache/apt (massively speeds up Docker builds)
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
# Print debug info about build and save it to disk, for human eyes only, not used by anything else
RUN (echo "[i] Docker build for ArchiveBox $(cat /VERSION.txt) starting..." \
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})" \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& echo \
&& echo "GLOBAL_VENV=${GLOBAL_VENV} APP_VENV=${APP_VENV} NODE_MODULES=${NODE_MODULES}" \
&& echo "PYTHON=${PYTHON_VERSION} NODE=${NODE_VERSION} PATH=${PATH}" \
&& echo "CODE_DIR=${CODE_DIR} DATA_DIR=${DATA_DIR}" \
&& echo \
&& uname -a \
&& cat /etc/os-release | head -n7 \
&& which bash && bash --version | head -n1 \
&& which dpkg && dpkg --version | head -n1 \
&& echo -e '\n\n' && env && echo -e '\n\n' \
) | tee -a /VERSION.txt
# Create non-privileged user for archivebox and chrome
RUN groupadd --system $ARCHIVEBOX_USER \
&& useradd --system --create-home --gid $ARCHIVEBOX_USER --groups audio,video $ARCHIVEBOX_USER
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
&& groupadd --system $ARCHIVEBOX_USER \
&& useradd --system --create-home --gid $ARCHIVEBOX_USER --groups audio,video $ARCHIVEBOX_USER \
&& usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
&& groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u $ARCHIVEBOX_USER) PGID=$(id -g $ARCHIVEBOX_USER)\n\n" \
| tee -a /VERSION.txt
# DEFAULT_PUID and DEFAULT_PID are overriden by PUID and PGID in /bin/docker_entrypoint.sh at runtime
# https://docs.linuxserver.io/general/understanding-puid-and-pgid
# Install system dependencies
RUN apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
apt-transport-https ca-certificates gnupg2 zlib1g-dev \
dumb-init gosu cron unzip curl \
# Install system apt dependencies (adding backports to access more recent apt updates)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing APT base system dependencies for $TARGETPLATFORM..." \
&& echo 'deb https://deb.debian.org/debian bookworm-backports main contrib non-free' >> /etc/apt/sources.list.d/backports.list \
&& mkdir -p /etc/apt/keyrings \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports --no-install-recommends \
# 1. packaging dependencies
apt-transport-https ca-certificates apt-utils gnupg2 curl wget \
# 2. docker and init system dependencies
zlib1g-dev dumb-init gosu cron unzip grep \
# 3. frivolous CLI helpers to make debugging failed archiving easier
# nano iputils-ping dnsutils htop procps jq yq
&& rm -rf /var/lib/apt/lists/*
# Install apt dependencies
RUN apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
wget curl chromium git ffmpeg youtube-dl ripgrep \
fontconfig fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-symbola fonts-noto fonts-freefont-ttf \
&& rm -rf /var/lib/apt/lists/*
# Install apt development dependencies
# RUN apt-get install -qq \
# && apt-get install -qq -y --no-install-recommends \
# python3 python3-dev python3-pip python3-venv python3-all \
# dh-python debhelper devscripts dput software-properties-common \
# python3-distutils python3-setuptools python3-wheel python3-stdeb
######### Language Environments ####################################
# Install Node environment
RUN curl -s https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
&& echo 'deb https://deb.nodesource.com/node_15.x buster main' >> /etc/apt/sources.list \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing Node $NODE_VERSION environment in $NODE_MODULES..." \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" >> /etc/apt/sources.list.d/nodejs.list \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
nodejs \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -qq -y -t bookworm-backports --no-install-recommends \
nodejs libatomic1 python3-minimal \
&& rm -rf /var/lib/apt/lists/* \
# Update NPM to latest version
&& npm i -g npm --cache /root/.npm \
# Save version info
&& ( \
which node && node --version \
&& which npm && npm --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install Python environment
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Setting up Python $PYTHON_VERSION runtime..." \
# tell PDM to allow using global system python site packages
# && rm /usr/lib/python3*/EXTERNALLY-MANAGED \
# create global virtual environment GLOBAL_VENV to use (better than using pip install --global)
# && python3 -m venv --system-site-packages --symlinks $GLOBAL_VENV \
# && python3 -m venv --system-site-packages $GLOBAL_VENV \
# && python3 -m venv $GLOBAL_VENV \
# install global dependencies / python build dependencies in GLOBAL_VENV
# && pip install --upgrade pip setuptools wheel \
# Save version info
&& ( \
which python3 && python3 --version | grep " $PYTHON_VERSION" \
&& which pip && pip --version \
# && which pdm && pdm --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
######### Extractor Dependencies ##################################
# Install apt dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing APT extractor dependencies globally using apt..." \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports --no-install-recommends \
curl wget git yt-dlp ffmpeg ripgrep \
# Packages we have also needed in the past:
# youtube-dl wget2 aria2 python3-pyxattr rtmpdump libfribidi-bin mpv \
# fontconfig fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-symbola fonts-noto fonts-freefont-ttf \
&& rm -rf /var/lib/apt/lists/* \
# Save version info
&& ( \
which curl && curl --version | head -n1 \
&& which wget && wget --version | head -n1 \
&& which yt-dlp && yt-dlp --version | head -n1 \
&& which git && git --version | head -n1 \
&& which rg && rg --version | head -n1 \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install chromium browser using playwright
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked,id=browsers-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing Browser binary dependencies to $PLAYWRIGHT_BROWSERS_PATH..." \
&& apt-get update -qq \
&& if [[ "$TARGETPLATFORM" == *amd64* || "$TARGETPLATFORM" == *arm64* ]]; then \
# install Chromium using playwright
pip install playwright \
&& cp -r /root/.cache/ms-playwright "$PLAYWRIGHT_BROWSERS_PATH" \
&& playwright install --with-deps chromium \
&& export CHROME_BINARY="$(python -c 'from playwright.sync_api import sync_playwright; print(sync_playwright().start().chromium.executable_path)')"; \
else \
# fall back to installing Chromium via apt-get on platforms not supported by playwright (e.g. risc, ARMv7, etc.)
apt-get install -qq -y -t bookworm-backports --no-install-recommends \
chromium fontconfig fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-symbola fonts-noto fonts-freefont-ttf \
&& export CHROME_BINARY="$(which chromium)"; \
fi \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s "$CHROME_BINARY" /usr/bin/chromium-browser \
&& mkdir -p "/home/${ARCHIVEBOX_USER}/.config/chromium/Crash Reports/pending/" \
&& chown -R $ARCHIVEBOX_USER "/home/${ARCHIVEBOX_USER}/.config" \
&& chown -R $ARCHIVEBOX_USER "$PLAYWRIGHT_BROWSERS_PATH" \
# Save version info
&& ( \
which chromium-browser && /usr/bin/chromium-browser --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install Node dependencies
WORKDIR "$NODE_DIR"
ENV PATH="${PATH}:$NODE_DIR/node_modules/.bin" \
npm_config_loglevel=error
ADD ./package.json ./package.json
ADD ./package-lock.json ./package-lock.json
RUN npm ci
# Install Python dependencies
WORKDIR "$CODE_DIR"
ENV PATH="${PATH}:$VENV_PATH/bin"
RUN python -m venv --clear --symlinks "$VENV_PATH" \
&& pip install --upgrade --quiet pip setuptools
ADD ./pip_dist/archivebox.egg-info/requires.txt "$CODE_DIR/pip_dist/archivebox.egg-info/requires.txt"
RUN apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
build-essential python-dev python3-dev \
&& grep -B 1000 -E '^$' "$CODE_DIR/pip_dist/archivebox.egg-info/requires.txt" | pip install --quiet -r /dev/stdin \
&& pip install --quiet "sonic-client==0.0.5" \
&& apt-get purge -y build-essential python-dev python3-dev \
COPY --chown=root:root --chmod=755 "package.json" "package-lock.json" "$CODE_DIR/"
RUN --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing NPM extractor dependencies from package.json into $NODE_MODULES..." \
&& npm ci --prefer-offline --no-audit --cache /root/.npm \
&& ( \
which node && node --version \
&& which npm && npm version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
######### Build Dependencies ####################################
# Install ArchiveBox Python dependencies
WORKDIR "$CODE_DIR"
COPY --chown=root:root --chmod=755 "./pyproject.toml" "requirements.txt" "$CODE_DIR/"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing PIP ArchiveBox dependencies from requirements.txt for ${TARGETPLATFORM}..." \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports --no-install-recommends \
build-essential \
libssl-dev libldap2-dev libsasl2-dev \
python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps \
# && ln -s "$GLOBAL_VENV" "$APP_VENV" \
# && pdm use --venv in-project \
# && pdm run python -m ensurepip \
# && pdm sync --fail-fast --no-editable --group :all --no-self \
# && pdm export -o requirements.txt --without-hashes \
# && source $GLOBAL_VENV/bin/activate \
&& pip install -r requirements.txt \
&& apt-get purge -y \
build-essential \
# these are only needed to build CPython libs, we discard after build phase to shrink layer size
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Install ArchiveBox Python package and its dependencies
WORKDIR "$CODE_DIR"
ADD . "$CODE_DIR"
RUN pip install -e .
# Install ArchiveBox Python package from source
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[*] Installing PIP ArchiveBox package from $CODE_DIR..." \
&& apt-get update -qq \
# install C compiler to build deps on platforms that dont have 32-bit wheels available on pypi
&& apt-get install -qq -y -t bookworm-backports --no-install-recommends \
build-essential \
# INSTALL ARCHIVEBOX python package globally from CODE_DIR, with all optional dependencies
&& pip install -e "$CODE_DIR"[sonic,ldap] \
# save docker image size and always remove compilers / build tools after building is complete
&& apt-get purge -y build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
####################################################
# Setup ArchiveBox runtime config
WORKDIR "$DATA_DIR"
ENV IN_DOCKER=True \
CHROME_SANDBOX=False \
CHROME_BINARY="chromium" \
USE_SINGLEFILE=True \
SINGLEFILE_BINARY="$NODE_DIR/node_modules/.bin/single-file" \
USE_READABILITY=True \
READABILITY_BINARY="$NODE_DIR/node_modules/.bin/readability-extractor" \
USE_MERCURY=True \
MERCURY_BINARY="$NODE_DIR/node_modules/.bin/mercury-parser"
ENV IN_DOCKER=True
## No need to set explicitly, these values will be autodetected by archivebox in docker:
# CHROME_SANDBOX=False \
# WGET_BINARY="wget" \
# YOUTUBEDL_BINARY="yt-dlp" \
# CHROME_BINARY="/usr/bin/chromium-browser" \
# USE_SINGLEFILE=True \
# SINGLEFILE_BINARY="$NODE_MODULES/.bin/single-file" \
# USE_READABILITY=True \
# READABILITY_BINARY="$NODE_MODULES/.bin/readability-extractor" \
# USE_MERCURY=True \
# MERCURY_BINARY="$NODE_MODULES/.bin/postlight-parser"
# Print version for nice docker finish summary
# RUN archivebox version
RUN /app/bin/docker_entrypoint.sh archivebox version
RUN (echo -e "\n\n[√] Finished Docker build succesfully. Saving build summary in: /VERSION.txt" \
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})" \
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ}\n\n" \
&& "$CODE_DIR/bin/docker_entrypoint.sh" \
archivebox version 2>&1 \
) | tee -a /VERSION.txt
####################################################
# Open up the interfaces to the outside world
WORKDIR "$DATA_DIR"
VOLUME "$DATA_DIR"
EXPOSE 8000
# Optional:
# HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
# CMD curl --silent 'http://localhost:8000/admin/login/' || exit 1
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
CMD ["archivebox", "server", "0.0.0.0:8000"]
CMD ["archivebox", "server", "--quick-init", "0.0.0.0:8000"]
-4
View File
@@ -1,4 +0,0 @@
graft archivebox
global-exclude .DS_Store
global-exclude __pycache__
global-exclude *.pyc
-12
View File
@@ -1,12 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[packages]
# see setup.py for package dependency list
"e1839a8" = {path = ".", editable = true}
[dev-packages]
# see setup.py for dev package dependency list
"e1839a8" = {path = ".", extras = ["dev"], editable = true}
+708 -310
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
# Security Policy
---
## Security Information
Please see this wiki page for important notices about ArchiveBox security, publishing your archives securely, and the dangers of executing archived JS:
https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
Also see this section of the README about important caveats when running ArchiveBox:
https://github.com/ArchiveBox/ArchiveBox?tab=readme-ov-file#caveats
You can also read these pages for more information about ArchiveBox's internals, development environment, DB schema, and more:
- https://github.com/ArchiveBox/ArchiveBox#archive-layout
- https://github.com/ArchiveBox/ArchiveBox#archivebox-development
- https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives
- https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting
---
## Reporting a Vulnerability
We use Github's built-in [Private Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) feature to accept vulnerability reports.
1. Go to the Security tab on our Github repo: https://github.com/ArchiveBox/ArchiveBox/security
2. Click the ["Report a Vulnerability"](https://github.com/ArchiveBox/ArchiveBox/security/advisories/new) button
3. Fill out the form to submit the details of the report and it will be securely sent to the maintainers
You can also contact the maintainers via our public [Zulip Chat Server zulip.archivebox.io](https://zulip.archivebox.io) or [Twitter DMs @ArchiveBoxApp](https://twitter.com/ArchiveBoxApp).
+3 -1
View File
@@ -1 +1,3 @@
theme: jekyll-theme-merlot
production_url: https://archivebox.io
theme: jekyll-theme-merlot
# Github Pages static site settings for https://archivebox.io
+1 -1
View File
@@ -3,4 +3,4 @@ ignore = D100,D101,D102,D103,D104,D105,D202,D203,D205,D400,E131,E241,E252,E266,E
select = F,E9,W
max-line-length = 130
max-complexity = 10
exclude = migrations,tests,node_modules,vendor,static,venv,.venv,.venv2,.docker-venv
exclude = migrations,tests,node_modules,vendor,venv,.venv,.venv2,.docker-venv
Regular → Executable
View File
File mode changed.
+18 -7
View File
@@ -8,18 +8,17 @@ import argparse
from typing import Optional, Dict, List, IO, Union
from pathlib import Path
from ..config import OUTPUT_DIR
from ..config import OUTPUT_DIR, check_data_folder, check_migrations
from importlib import import_module
CLI_DIR = Path(__file__).resolve().parent
# these common commands will appear sorted before any others for ease-of-use
meta_cmds = ('help', 'version')
main_cmds = ('init', 'info', 'config')
archive_cmds = ('add', 'remove', 'update', 'list', 'status')
fake_db = ("oneshot",)
meta_cmds = ('help', 'version') # dont require valid data folder at all
main_cmds = ('init', 'config', 'setup') # dont require existing db present
archive_cmds = ('add', 'remove', 'update', 'list', 'status') # require existing db present
fake_db = ("oneshot",) # use fake in-memory db
display_first = (*meta_cmds, *main_cmds, *archive_cmds)
@@ -61,9 +60,21 @@ def run_subcommand(subcommand: str,
pwd: Union[Path, str, None]=None) -> None:
"""Run a given ArchiveBox subcommand with the given list of args"""
subcommand_args = subcommand_args or []
if subcommand not in meta_cmds:
from ..config import setup_django
setup_django(in_memory_db=subcommand in fake_db, check_db=subcommand in archive_cmds)
cmd_requires_db = subcommand in archive_cmds
init_pending = '--init' in subcommand_args or '--quick-init' in subcommand_args
if cmd_requires_db:
check_data_folder(pwd)
setup_django(in_memory_db=subcommand in fake_db, check_db=cmd_requires_db and not init_pending)
if cmd_requires_db:
check_migrations()
module = import_module('.archivebox_{}'.format(subcommand), __package__)
module.main(args=subcommand_args, stdin=stdin, pwd=pwd) # type: ignore
+29 -2
View File
@@ -10,6 +10,7 @@ from typing import List, Optional, IO
from ..main import add
from ..util import docstring
from ..parsers import PARSERS
from ..config import OUTPUT_DIR, ONLY_NEW
from ..logging_util import SmartFormatter, accept_stdin, stderr
@@ -23,11 +24,23 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
formatter_class=SmartFormatter,
)
parser.add_argument(
'--update-all', #'-n',
'--tag', '-t',
type=str,
default='',
help="Tag the added URLs with the provided tags e.g. --tag=tag1,tag2,tag3",
)
parser.add_argument(
'--update', #'-u',
action='store_true',
default=not ONLY_NEW, # when ONLY_NEW=True we skip updating old links
help="Also retry previously skipped/failed links when adding new links",
)
parser.add_argument(
'--update-all', #'-n',
action='store_true',
default=False,
help="Also update ALL links in index when finished adding new links",
)
parser.add_argument(
'--index-only', #'-o',
action='store_true',
@@ -73,9 +86,20 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
This does not take precedence over the configuration",
default=""
)
parser.add_argument(
"--parser",
type=str,
help="Parser used to read inputted URLs.",
default="auto",
choices=["auto", *PARSERS.keys()],
)
command = parser.parse_args(args or ())
urls = command.urls
stdin_urls = accept_stdin(stdin)
stdin_urls = ''
if not urls:
stdin_urls = accept_stdin(stdin)
if (stdin_urls and urls) or (not stdin and not urls):
stderr(
'[X] You must pass URLs/paths to add via stdin or CLI arguments.\n',
@@ -85,11 +109,14 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
add(
urls=stdin_urls or urls,
depth=command.depth,
tag=command.tag,
update=command.update,
update_all=command.update_all,
index_only=command.index_only,
overwrite=command.overwrite,
init=command.init,
extractors=command.extract,
parser=command.parser,
out_dir=pwd or OUTPUT_DIR,
)
+4 -1
View File
@@ -45,7 +45,10 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
help='KEY or KEY=VALUE formatted config values to get or set',
)
command = parser.parse_args(args or ())
config_options_str = accept_stdin(stdin)
config_options_str = ''
if not command.config_options:
config_options_str = accept_stdin(stdin)
config(
config_options_str=config_options_str,
+12
View File
@@ -27,11 +27,23 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
action='store_true',
help='Ignore unrecognized files in current directory and initialize anyway',
)
parser.add_argument(
'--quick', '-q',
action='store_true',
help='Run any updates or migrations without rechecking all snapshot dirs',
)
parser.add_argument(
'--setup', #'-s',
action='store_true',
help='Automatically install dependencies and extras used for archiving',
)
command = parser.parse_args(args or ())
reject_stdin(__command__, stdin)
init(
force=command.force,
quick=command.quick,
setup=command.setup,
out_dir=pwd or OUTPUT_DIR,
)
+11 -11
View File
@@ -12,6 +12,7 @@ from ..main import list_all
from ..util import docstring
from ..config import OUTPUT_DIR
from ..index import (
LINK_FILTERS,
get_indexed_folders,
get_archived_folders,
get_unarchived_folders,
@@ -23,7 +24,7 @@ from ..index import (
get_corrupted_folders,
get_unrecognized_folders,
)
from ..logging_util import SmartFormatter, accept_stdin, stderr
from ..logging_util import SmartFormatter, reject_stdin, stderr
@docstring(list_all.__doc__)
@@ -44,7 +45,7 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
group.add_argument(
'--json', #'-j',
action='store_true',
help="Print the output in JSON format with all columns included.",
help="Print the output in JSON format with all columns included",
)
group.add_argument(
'--html',
@@ -59,19 +60,19 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
parser.add_argument(
'--sort', #'-s',
type=str,
help="List the links sorted using the given key, e.g. timestamp or updated.",
help="List the links sorted using the given key, e.g. timestamp or updated",
default=None,
)
parser.add_argument(
'--before', #'-b',
type=float,
help="List only links bookmarked before the given timestamp.",
help="List only links bookmarked before (less than) the given timestamp",
default=None,
)
parser.add_argument(
'--after', #'-a',
type=float,
help="List only links bookmarked after the given timestamp.",
help="List only links bookmarked after (greater than or equal to) the given timestamp",
default=None,
)
parser.add_argument(
@@ -96,9 +97,9 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
)
)
parser.add_argument(
'--filter-type',
'--filter-type', '-t',
type=str,
choices=('exact', 'substring', 'domain', 'regex', 'tag', 'search'),
choices=(*LINK_FILTERS.keys(), 'search'),
default='exact',
help='Type of pattern matching to use when filtering URLs',
)
@@ -107,20 +108,19 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
nargs='*',
type=str,
default=None,
help='List only URLs matching these filter patterns.'
help='List only URLs matching these filter patterns'
)
command = parser.parse_args(args or ())
filter_patterns_str = accept_stdin(stdin)
reject_stdin(stdin)
if command.with_headers and not (command.json or command.html or command.csv):
stderr(
'[X] --with-headers can only be used with --json, --html or --csv options.\n',
'[X] --with-headers can only be used with --json, --html or --csv options\n',
color='red',
)
raise SystemExit(2)
matching_folders = list_all(
filter_patterns_str=filter_patterns_str,
filter_patterns=command.filter_patterns,
filter_type=command.filter_type,
status=command.status,
+4 -1
View File
@@ -50,8 +50,11 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
help= "Path to save the single archive folder to, e.g. ./example.com_archive"
)
command = parser.parse_args(args or ())
stdin_url = None
url = command.url
stdin_url = accept_stdin(stdin)
if not url:
stdin_url = accept_stdin(stdin)
if (stdin_url and url) or (not stdin and not url):
stderr(
'[X] You must pass a URL/path to add via stdin or CLI arguments.\n',
+4 -1
View File
@@ -61,7 +61,10 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
help='URLs matching this filter pattern will be removed from the index.'
)
command = parser.parse_args(args or ())
filter_str = accept_stdin(stdin)
filter_str = None
if not command.filter_patterns:
filter_str = accept_stdin(stdin)
remove(
filter_str=filter_str,
+13 -1
View File
@@ -44,7 +44,17 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
type=int,
choices=[0, 1],
default=0,
help='Depth to archive to [0] or 1, see "add" command help for more info.',
help='Depth to archive to [0] or 1, see "add" command help for more info',
)
parser.add_argument(
'--overwrite',
action='store_true',
help='Re-archive any URLs that have been previously archived, overwriting existing Snapshots',
)
parser.add_argument(
'--update',
action='store_true',
help='Re-pull any URLs that have been previously added, as needed to fill missing ArchiveResults',
)
group.add_argument(
'--clear', # '-c'
@@ -88,6 +98,8 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
quiet=command.quiet,
every=command.every,
depth=command.depth,
overwrite=command.overwrite,
update=command.update,
import_path=command.import_path,
out_dir=pwd or OUTPUT_DIR,
)
+13 -2
View File
@@ -38,10 +38,20 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
action='store_true',
help='Enable DEBUG=True mode with more verbose errors',
)
parser.add_argument(
'--nothreading',
action='store_true',
help='Force runserver to run in single-threaded mode',
)
parser.add_argument(
'--init',
action='store_true',
help='Run archivebox init before starting the server',
help='Run a full archivebox init/upgrade before starting the server',
)
parser.add_argument(
'--quick-init', '-i',
action='store_true',
help='Run quick archivebox init/upgrade before starting the server',
)
parser.add_argument(
'--createsuperuser',
@@ -52,10 +62,11 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
reject_stdin(__command__, stdin)
server(
runserver_args=command.runserver_args,
runserver_args=command.runserver_args + (['--nothreading'] if command.nothreading else []),
reload=command.reload,
debug=command.debug,
init=command.init,
quick_init=command.quick_init,
createsuperuser=command.createsuperuser,
out_dir=pwd or OUTPUT_DIR,
)
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
__package__ = 'archivebox.cli'
__command__ = 'archivebox setup'
import sys
import argparse
from typing import Optional, List, IO
from ..main import setup
from ..util import docstring
from ..config import OUTPUT_DIR
from ..logging_util import SmartFormatter, reject_stdin
@docstring(setup.__doc__)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=setup.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
# parser.add_argument(
# '--force', # '-f',
# action='store_true',
# help='Overwrite any existing packages that conflict with the ones ArchiveBox is trying to install',
# )
command = parser.parse_args(args or ()) # noqa
reject_stdin(__command__, stdin)
setup(
# force=command.force,
out_dir=pwd or OUTPUT_DIR,
)
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
+7 -3
View File
@@ -12,6 +12,7 @@ from ..main import update
from ..util import docstring
from ..config import OUTPUT_DIR
from ..index import (
LINK_FILTERS,
get_indexed_folders,
get_archived_folders,
get_unarchived_folders,
@@ -89,9 +90,9 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
)
)
parser.add_argument(
'--filter-type',
'--filter-type', '-t',
type=str,
choices=('exact', 'substring', 'domain', 'regex', 'tag', 'search'),
choices=(*LINK_FILTERS.keys(), 'search'),
default='exact',
help='Type of pattern matching to use when filtering URLs',
)
@@ -110,7 +111,10 @@ def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional
default=""
)
command = parser.parse_args(args or ())
filter_patterns_str = accept_stdin(stdin)
filter_patterns_str = None
if not command.filter_patterns:
filter_patterns_str = accept_stdin(stdin)
update(
resume=command.resume,
Executable → Regular
+9 -9
View File
@@ -75,23 +75,23 @@ def output_hidden(show_failing=True):
yield
return
sys.stdout = open('stdout.txt', 'w+')
sys.stderr = open('stderr.txt', 'w+')
sys.stdout = open('stdout.txt', 'w+', encoding='utf-8')
sys.stderr = open('stderr.txt', 'w+', encoding='utf-8')
try:
yield
sys.stdout.close()
sys.stderr.close()
sys.stdout = stdout
sys.stderr = stderr
except:
except Exception:
sys.stdout.close()
sys.stderr.close()
sys.stdout = stdout
sys.stderr = stderr
if show_failing:
with open('stdout.txt', 'r') as f:
with open('stdout.txt', 'r', encoding='utf-8') as f:
print(f.read())
with open('stderr.txt', 'r') as f:
with open('stderr.txt', 'r', encoding='utf-8') as f:
print(f.read())
raise
finally:
@@ -116,7 +116,7 @@ class TestInit(unittest.TestCase):
assert len(load_main_index(out_dir=OUTPUT_DIR)) == 0
def test_conflicting_init(self):
with open(Path(OUTPUT_DIR) / 'test_conflict.txt', 'w+') as f:
with open(Path(OUTPUT_DIR) / 'test_conflict.txt', 'w+', encoding='utf-8') as f:
f.write('test')
try:
@@ -132,7 +132,7 @@ class TestInit(unittest.TestCase):
try:
load_main_index(out_dir=OUTPUT_DIR)
assert False, 'load_main_index should raise an exception when no index is present'
except:
except Exception:
pass
def test_no_dirty_state(self):
@@ -161,7 +161,7 @@ class TestAdd(unittest.TestCase):
def test_add_arg_file(self):
test_file = Path(OUTPUT_DIR) / 'test.txt'
with open(test_file, 'w+') as f:
with open(test_file, 'w+', encoding='utf') as f:
f.write(test_urls)
with output_hidden():
@@ -216,7 +216,7 @@ class TestRemove(unittest.TestCase):
with output_hidden(show_failing=False):
archivebox_remove.main(['--yes', '--delete', 'https://doesntexist.com'])
assert False, 'Should raise if no URLs match'
except:
except Exception:
pass
+313 -91
View File
@@ -26,17 +26,21 @@ import io
import re
import sys
import json
import inspect
import getpass
import platform
import shutil
import django
from sqlite3 import dbapi2 as sqlite3
from hashlib import md5
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, Type, Tuple, Dict, Union, List
from subprocess import run, PIPE, DEVNULL
from configparser import ConfigParser
from collections import defaultdict
import importlib.metadata
from .config_stubs import (
SimpleConfigValueDict,
@@ -46,6 +50,26 @@ from .config_stubs import (
ConfigDefaultDict,
)
### Pre-Fetch Minimal System Config
SYSTEM_USER = getpass.getuser() or os.getlogin()
try:
import pwd
SYSTEM_USER = pwd.getpwuid(os.geteuid()).pw_name or SYSTEM_USER
except KeyError:
# Process' UID might not map to a user in cases such as running the Docker image
# (where `archivebox` is 999) as a different UID.
pass
except ModuleNotFoundError:
# pwd is only needed for some linux systems, doesn't exist on windows
pass
except Exception:
# this should never happen, uncomment to debug
# raise
pass
############################### Config Schema ##################################
CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
@@ -54,6 +78,9 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
'USE_COLOR': {'type': bool, 'default': lambda c: c['IS_TTY']},
'SHOW_PROGRESS': {'type': bool, 'default': lambda c: (c['IS_TTY'] and platform.system() != 'Darwin')}, # progress bars are buggy on mac, disable for now
'IN_DOCKER': {'type': bool, 'default': False},
'IN_QEMU': {'type': bool, 'default': False},
'PUID': {'type': int, 'default': os.getuid()},
'PGID': {'type': int, 'default': os.getgid()},
# TODO: 'SHOW_HINTS': {'type: bool, 'default': True},
},
@@ -63,20 +90,47 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
'ONLY_NEW': {'type': bool, 'default': True},
'TIMEOUT': {'type': int, 'default': 60},
'MEDIA_TIMEOUT': {'type': int, 'default': 3600},
'OUTPUT_PERMISSIONS': {'type': str, 'default': '755'},
'OUTPUT_PERMISSIONS': {'type': str, 'default': '644'},
'RESTRICT_FILE_NAMES': {'type': str, 'default': 'windows'},
'URL_BLACKLIST': {'type': str, 'default': r'\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$'}, # to avoid downloading code assets as their own pages
'URL_DENYLIST': {'type': str, 'default': r'\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$', 'aliases': ('URL_BLACKLIST',)}, # to avoid downloading code assets as their own pages
'URL_ALLOWLIST': {'type': str, 'default': None, 'aliases': ('URL_WHITELIST',)},
'ADMIN_USERNAME': {'type': str, 'default': None},
'ADMIN_PASSWORD': {'type': str, 'default': None},
'ENFORCE_ATOMIC_WRITES': {'type': bool, 'default': True},
'TAG_SEPARATOR_PATTERN': {'type': str, 'default': r'[,]'},
},
'SERVER_CONFIG': {
'SECRET_KEY': {'type': str, 'default': None},
'BIND_ADDR': {'type': str, 'default': lambda c: ['127.0.0.1:8000', '0.0.0.0:8000'][c['IN_DOCKER']]},
'ALLOWED_HOSTS': {'type': str, 'default': '*'},
'DEBUG': {'type': bool, 'default': False},
'PUBLIC_INDEX': {'type': bool, 'default': True},
'PUBLIC_SNAPSHOTS': {'type': bool, 'default': True},
'PUBLIC_ADD_VIEW': {'type': bool, 'default': False},
'FOOTER_INFO': {'type': str, 'default': 'Content is hosted for personal archiving purposes only. Contact server owner for any takedown requests.'},
'SECRET_KEY': {'type': str, 'default': None},
'BIND_ADDR': {'type': str, 'default': lambda c: ['127.0.0.1:8000', '0.0.0.0:8000'][c['IN_DOCKER']]},
'ALLOWED_HOSTS': {'type': str, 'default': '*'},
'DEBUG': {'type': bool, 'default': False},
'PUBLIC_INDEX': {'type': bool, 'default': True},
'PUBLIC_SNAPSHOTS': {'type': bool, 'default': True},
'PUBLIC_ADD_VIEW': {'type': bool, 'default': False},
'FOOTER_INFO': {'type': str, 'default': 'Content is hosted for personal archiving purposes only. Contact server owner for any takedown requests.'},
'SNAPSHOTS_PER_PAGE': {'type': int, 'default': 40},
'CUSTOM_TEMPLATES_DIR': {'type': str, 'default': None},
'TIME_ZONE': {'type': str, 'default': 'UTC'},
'TIMEZONE': {'type': str, 'default': 'UTC'},
'REVERSE_PROXY_USER_HEADER': {'type': str, 'default': 'Remote-User'},
'REVERSE_PROXY_WHITELIST': {'type': str, 'default': ''},
'LOGOUT_REDIRECT_URL': {'type': str, 'default': '/'},
'PREVIEW_ORIGINALS': {'type': bool, 'default': True},
'LDAP': {'type': bool, 'default': False},
'LDAP_SERVER_URI': {'type': str, 'default': None},
'LDAP_BIND_DN': {'type': str, 'default': None},
'LDAP_BIND_PASSWORD': {'type': str, 'default': None},
'LDAP_USER_BASE': {'type': str, 'default': None},
'LDAP_USER_FILTER': {'type': str, 'default': None},
'LDAP_USERNAME_ATTR': {'type': str, 'default': None},
'LDAP_FIRSTNAME_ATTR': {'type': str, 'default': None},
'LDAP_LASTNAME_ATTR': {'type': str, 'default': None},
'LDAP_EMAIL_ATTR': {'type': str, 'default': None},
},
'ARCHIVE_METHOD_TOGGLES': {
@@ -95,36 +149,49 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
'SAVE_GIT': {'type': bool, 'default': True, 'aliases': ('FETCH_GIT',)},
'SAVE_MEDIA': {'type': bool, 'default': True, 'aliases': ('FETCH_MEDIA',)},
'SAVE_ARCHIVE_DOT_ORG': {'type': bool, 'default': True, 'aliases': ('SUBMIT_ARCHIVE_DOT_ORG',)},
'SAVE_ALLOWLIST': {'type': dict, 'default': {},},
'SAVE_DENYLIST': {'type': dict, 'default': {},},
},
'ARCHIVE_METHOD_OPTIONS': {
'RESOLUTION': {'type': str, 'default': '1440,2000', 'aliases': ('SCREENSHOT_RESOLUTION',)},
'GIT_DOMAINS': {'type': str, 'default': 'github.com,bitbucket.org,gitlab.com'},
'GIT_DOMAINS': {'type': str, 'default': 'github.com,bitbucket.org,gitlab.com,gist.github.com'},
'CHECK_SSL_VALIDITY': {'type': bool, 'default': True},
'MEDIA_MAX_SIZE': {'type': str, 'default': '750m'},
'CURL_USER_AGENT': {'type': str, 'default': 'ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/) curl/{CURL_VERSION}'},
'WGET_USER_AGENT': {'type': str, 'default': 'ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/) wget/{WGET_VERSION}'},
'CHROME_USER_AGENT': {'type': str, 'default': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36'},
'CURL_USER_AGENT': {'type': str, 'default': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/) curl/{CURL_VERSION}'},
'WGET_USER_AGENT': {'type': str, 'default': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/) wget/{WGET_VERSION}'},
'CHROME_USER_AGENT': {'type': str, 'default': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/{VERSION} (+https://github.com/ArchiveBox/ArchiveBox/)'},
'COOKIES_FILE': {'type': str, 'default': None},
'CHROME_USER_DATA_DIR': {'type': str, 'default': None},
'CHROME_TIMEOUT': {'type': int, 'default': 0},
'CHROME_HEADLESS': {'type': bool, 'default': True},
'CHROME_SANDBOX': {'type': bool, 'default': lambda c: not c['IN_DOCKER']},
'YOUTUBEDL_ARGS': {'type': list, 'default': ['--write-description',
'YOUTUBEDL_ARGS': {'type': list, 'default': lambda c: [
'--write-description',
'--write-info-json',
'--write-annotations',
'--write-thumbnail',
'--no-call-home',
'--all-subs',
'--write-sub',
'--write-auto-subs',
'--convert-subs=srt',
'--yes-playlist',
'--continue',
# This flag doesn't exist in youtube-dl
# only in yt-dlp
'--no-abort-on-error',
# --ignore-errors must come AFTER
# --no-abort-on-error
# https://github.com/yt-dlp/yt-dlp/issues/4914
'--ignore-errors',
'--geo-bypass',
'--add-metadata',
'--max-filesize=750m',
'--format=(bv*+ba/b)[filesize<={}][filesize_approx<=?{}]/(bv*+ba/b)'.format(c['MEDIA_MAX_SIZE'], c['MEDIA_MAX_SIZE']),
]},
'WGET_ARGS': {'type': list, 'default': ['--no-verbose',
'--adjust-extension',
@@ -140,6 +207,8 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
'--compressed'
]},
'GIT_ARGS': {'type': list, 'default': ['--recursive']},
'SINGLEFILE_ARGS': {'type': list, 'default' : None},
'FAVICON_PROVIDER': {'type': str, 'default': 'https://www.google.com/s2/favicons?domain={}'},
},
'SEARCH_BACKEND_CONFIG' : {
@@ -152,6 +221,12 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
# SONIC
'SONIC_COLLECTION': {'type': str, 'default': 'archivebox'},
'SONIC_BUCKET': {'type': str, 'default': 'snapshots'},
'SEARCH_BACKEND_TIMEOUT': {'type': int, 'default': 90},
# SQLite3 FTS5
'FTS_SEPARATE_DATABASE': {'type': bool, 'default': True},
'FTS_TOKENIZERS': {'type': str, 'default': 'porter unicode61 remove_diacritics 2'},
# Default from https://www.sqlite.org/limits.html#max_length
'FTS_SQLITE_MAX_LENGTH': {'type': int, 'default': int(1e9)},
},
'DEPENDENCY_CONFIG': {
@@ -165,20 +240,22 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = {
'USE_NODE': {'type': bool, 'default': True},
'USE_YOUTUBEDL': {'type': bool, 'default': True},
'USE_RIPGREP': {'type': bool, 'default': True},
'CURL_BINARY': {'type': str, 'default': 'curl'},
'GIT_BINARY': {'type': str, 'default': 'git'},
'WGET_BINARY': {'type': str, 'default': 'wget'},
'SINGLEFILE_BINARY': {'type': str, 'default': 'single-file'},
'READABILITY_BINARY': {'type': str, 'default': 'readability-extractor'},
'MERCURY_BINARY': {'type': str, 'default': 'mercury-parser'},
'YOUTUBEDL_BINARY': {'type': str, 'default': 'youtube-dl'},
'WGET_BINARY': {'type': str, 'default': 'wget'}, # also can accept wget2
'SINGLEFILE_BINARY': {'type': str, 'default': lambda c: bin_path('single-file')},
'READABILITY_BINARY': {'type': str, 'default': lambda c: bin_path('readability-extractor')},
'MERCURY_BINARY': {'type': str, 'default': lambda c: bin_path('postlight-parser')},
'YOUTUBEDL_BINARY': {'type': str, 'default': 'yt-dlp'}, # also can accept youtube-dl
'NODE_BINARY': {'type': str, 'default': 'node'},
'RIPGREP_BINARY': {'type': str, 'default': 'rg'},
'CHROME_BINARY': {'type': str, 'default': None},
'POCKET_CONSUMER_KEY': {'type': str, 'default': None},
'POCKET_ACCESS_TOKENS': {'type': dict, 'default': {}},
'READWISE_READER_TOKENS': {'type': dict, 'default': {}},
},
}
@@ -246,7 +323,7 @@ STATICFILE_EXTENSIONS = {
# that can be downloaded as-is, not html pages that need to be rendered
'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'wbmp', 'ico', 'jng', 'bmp',
'svg', 'svgz', 'webp', 'ps', 'eps', 'ai',
'mp3', 'mp4', 'm4a', 'mpeg', 'mpg', 'mkv', 'mov', 'webm', 'm4v',
'mp3', 'mp4', 'm4a', 'mpeg', 'mpg', 'mkv', 'mov', 'webm', 'm4v',
'flv', 'wmv', 'avi', 'ogg', 'ts', 'm3u8',
'pdf', 'txt', 'rtf', 'rtfd', 'doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx',
'atom', 'rss', 'css', 'js', 'json',
@@ -255,7 +332,7 @@ STATICFILE_EXTENSIONS = {
# Less common extensions to consider adding later
# jar, swf, bin, com, exe, dll, deb
# ear, hqx, eot, wmlc, kml, kmz, cco, jardiff, jnlp, run, msi, msp, msm,
# ear, hqx, eot, wmlc, kml, kmz, cco, jardiff, jnlp, run, msi, msp, msm,
# pl pm, prc pdb, rar, rpm, sea, sit, tcl tk, der, pem, crt, xpi, xspf,
# ra, mng, asx, asf, 3gpp, 3gp, mid, midi, kar, jad, wml, htc, mml
@@ -263,18 +340,62 @@ STATICFILE_EXTENSIONS = {
# html, htm, shtml, xhtml, xml, aspx, php, cgi
}
# When initializing archivebox in a new directory, we check to make sure the dir is
# actually empty so that we dont clobber someone's home directory or desktop by accident.
# These files are exceptions to the is_empty check when we're trying to init a new dir,
# as they could be from a previous archivebox version, system artifacts, dependencies, etc.
ALLOWED_IN_OUTPUT_DIR = {
'.gitignore',
'lost+found',
'.DS_Store',
'.venv',
'venv',
'virtualenv',
'.virtualenv',
'node_modules',
'package.json',
'package-lock.json',
'yarn.lock',
'static',
'sonic',
'search.sqlite3',
ARCHIVE_DIR_NAME,
SOURCES_DIR_NAME,
LOGS_DIR_NAME,
SQL_INDEX_FILENAME,
f'{SQL_INDEX_FILENAME}-wal',
f'{SQL_INDEX_FILENAME}-shm',
JSON_INDEX_FILENAME,
HTML_INDEX_FILENAME,
ROBOTS_TXT_FILENAME,
FAVICON_FILENAME,
CONFIG_FILENAME,
f'{CONFIG_FILENAME}.bak',
'static_index.json',
}
def get_version(config):
return importlib.metadata.version(__package__ or 'archivebox')
def get_commit_hash(config):
try:
return list((config['PACKAGE_DIR'] / '../.git/refs/heads/').glob('*'))[0].read_text().strip()
except Exception:
return None
############################## Derived Config ##################################
ALLOWDENYLIST_REGEX_FLAGS: int = re.IGNORECASE | re.UNICODE | re.MULTILINE
DYNAMIC_CONFIG_SCHEMA: ConfigDefaultDict = {
'TERM_WIDTH': {'default': lambda c: lambda: shutil.get_terminal_size((100, 10)).columns},
'USER': {'default': lambda c: getpass.getuser() or os.getlogin()},
'USER': {'default': lambda c: SYSTEM_USER},
'ANSI': {'default': lambda c: DEFAULT_CLI_COLORS if c['USE_COLOR'] else {k: '' for k in DEFAULT_CLI_COLORS.keys()}},
'PACKAGE_DIR': {'default': lambda c: Path(__file__).resolve().parent},
'TEMPLATES_DIR': {'default': lambda c: c['PACKAGE_DIR'] / TEMPLATES_DIR_NAME},
'CUSTOM_TEMPLATES_DIR': {'default': lambda c: c['CUSTOM_TEMPLATES_DIR'] and Path(c['CUSTOM_TEMPLATES_DIR'])},
'OUTPUT_DIR': {'default': lambda c: Path(c['OUTPUT_DIR']).resolve() if c['OUTPUT_DIR'] else Path(os.curdir).resolve()},
'ARCHIVE_DIR': {'default': lambda c: c['OUTPUT_DIR'] / ARCHIVE_DIR_NAME},
@@ -283,18 +404,25 @@ DYNAMIC_CONFIG_SCHEMA: ConfigDefaultDict = {
'CONFIG_FILE': {'default': lambda c: Path(c['CONFIG_FILE']).resolve() if c['CONFIG_FILE'] else c['OUTPUT_DIR'] / CONFIG_FILENAME},
'COOKIES_FILE': {'default': lambda c: c['COOKIES_FILE'] and Path(c['COOKIES_FILE']).resolve()},
'CHROME_USER_DATA_DIR': {'default': lambda c: find_chrome_data_dir() if c['CHROME_USER_DATA_DIR'] is None else (Path(c['CHROME_USER_DATA_DIR']).resolve() if c['CHROME_USER_DATA_DIR'] else None)}, # None means unset, so we autodetect it with find_chrome_Data_dir(), but emptystring '' means user manually set it to '', and we should store it as None
'URL_BLACKLIST_PTN': {'default': lambda c: c['URL_BLACKLIST'] and re.compile(c['URL_BLACKLIST'] or '', re.IGNORECASE | re.UNICODE | re.MULTILINE)},
'ARCHIVEBOX_BINARY': {'default': lambda c: sys.argv[0]},
'VERSION': {'default': lambda c: json.loads((Path(c['PACKAGE_DIR']) / 'package.json').read_text().strip())['version']},
'GIT_SHA': {'default': lambda c: c['VERSION'].split('+')[-1] or 'unknown'},
'URL_DENYLIST_PTN': {'default': lambda c: c['URL_DENYLIST'] and re.compile(c['URL_DENYLIST'] or '', ALLOWDENYLIST_REGEX_FLAGS)},
'URL_ALLOWLIST_PTN': {'default': lambda c: c['URL_ALLOWLIST'] and re.compile(c['URL_ALLOWLIST'] or '', ALLOWDENYLIST_REGEX_FLAGS)},
'DIR_OUTPUT_PERMISSIONS': {'default': lambda c: c['OUTPUT_PERMISSIONS'].replace('6', '7').replace('4', '5')},
'ARCHIVEBOX_BINARY': {'default': lambda c: sys.argv[0] or bin_path('archivebox')},
'VERSION': {'default': lambda c: get_version(c)},
'COMMIT_HASH': {'default': lambda c: get_commit_hash(c)},
'PYTHON_BINARY': {'default': lambda c: sys.executable},
'PYTHON_ENCODING': {'default': lambda c: sys.stdout.encoding.upper()},
'PYTHON_VERSION': {'default': lambda c: '{}.{}.{}'.format(*sys.version_info[:3])},
'DJANGO_BINARY': {'default': lambda c: django.__file__.replace('__init__.py', 'bin/django-admin.py')},
'DJANGO_BINARY': {'default': lambda c: inspect.getfile(django)},
'DJANGO_VERSION': {'default': lambda c: '{}.{}.{} {} ({})'.format(*django.VERSION)},
'SQLITE_BINARY': {'default': lambda c: inspect.getfile(sqlite3)},
'SQLITE_VERSION': {'default': lambda c: sqlite3.version},
#'SQLITE_JOURNAL_MODE': {'default': lambda c: 'wal'}, # set at runtime below, interesting but unused for now
#'SQLITE_OPTIONS': {'default': lambda c: ['JSON1']}, # set at runtime below
'USE_CURL': {'default': lambda c: c['USE_CURL'] and (c['SAVE_FAVICON'] or c['SAVE_TITLE'] or c['SAVE_ARCHIVE_DOT_ORG'])},
'CURL_VERSION': {'default': lambda c: bin_version(c['CURL_BINARY']) if c['USE_CURL'] else None},
@@ -315,12 +443,13 @@ DYNAMIC_CONFIG_SCHEMA: ConfigDefaultDict = {
'USE_SINGLEFILE': {'default': lambda c: c['USE_SINGLEFILE'] and c['SAVE_SINGLEFILE']},
'SINGLEFILE_VERSION': {'default': lambda c: bin_version(c['SINGLEFILE_BINARY']) if c['USE_SINGLEFILE'] else None},
'SINGLEFILE_ARGS': {'default': lambda c: c['SINGLEFILE_ARGS'] or []},
'USE_READABILITY': {'default': lambda c: c['USE_READABILITY'] and c['SAVE_READABILITY']},
'READABILITY_VERSION': {'default': lambda c: bin_version(c['READABILITY_BINARY']) if c['USE_READABILITY'] else None},
'USE_MERCURY': {'default': lambda c: c['USE_MERCURY'] and c['SAVE_MERCURY']},
'MERCURY_VERSION': {'default': lambda c: '1.0.0' if shutil.which(str(bin_path(c['MERCURY_BINARY']))) else None}, # mercury is unversioned
'MERCURY_VERSION': {'default': lambda c: '1.0.0' if shutil.which(str(bin_path(c['MERCURY_BINARY']))) else None}, # mercury doesnt expose version info until this is merged https://github.com/postlight/parser/pull/750
'USE_GIT': {'default': lambda c: c['USE_GIT'] and c['SAVE_GIT']},
'GIT_VERSION': {'default': lambda c: bin_version(c['GIT_BINARY']) if c['USE_GIT'] else None},
@@ -331,17 +460,17 @@ DYNAMIC_CONFIG_SCHEMA: ConfigDefaultDict = {
'SAVE_MEDIA': {'default': lambda c: c['USE_YOUTUBEDL'] and c['SAVE_MEDIA']},
'YOUTUBEDL_ARGS': {'default': lambda c: c['YOUTUBEDL_ARGS'] or []},
'USE_CHROME': {'default': lambda c: c['USE_CHROME'] and (c['SAVE_PDF'] or c['SAVE_SCREENSHOT'] or c['SAVE_DOM'] or c['SAVE_SINGLEFILE'])},
'CHROME_BINARY': {'default': lambda c: c['CHROME_BINARY'] if c['CHROME_BINARY'] else find_chrome_binary()},
'CHROME_BINARY': {'default': lambda c: c['CHROME_BINARY'] or find_chrome_binary()},
'USE_CHROME': {'default': lambda c: c['USE_CHROME'] and c['CHROME_BINARY'] and (c['SAVE_PDF'] or c['SAVE_SCREENSHOT'] or c['SAVE_DOM'] or c['SAVE_SINGLEFILE'])},
'CHROME_VERSION': {'default': lambda c: bin_version(c['CHROME_BINARY']) if c['USE_CHROME'] else None},
'SAVE_PDF': {'default': lambda c: c['USE_CHROME'] and c['SAVE_PDF']},
'SAVE_SCREENSHOT': {'default': lambda c: c['USE_CHROME'] and c['SAVE_SCREENSHOT']},
'SAVE_DOM': {'default': lambda c: c['USE_CHROME'] and c['SAVE_DOM']},
'SAVE_SINGLEFILE': {'default': lambda c: c['USE_CHROME'] and c['SAVE_SINGLEFILE'] and c['USE_NODE']},
'SAVE_READABILITY': {'default': lambda c: c['USE_READABILITY'] and c['USE_NODE']},
'SAVE_MERCURY': {'default': lambda c: c['USE_MERCURY'] and c['USE_NODE']},
'USE_NODE': {'default': lambda c: c['USE_NODE'] and (c['SAVE_READABILITY'] or c['SAVE_SINGLEFILE'] or c['SAVE_MERCURY'])},
'NODE_VERSION': {'default': lambda c: bin_version(c['NODE_BINARY']) if c['USE_NODE'] else None},
@@ -350,10 +479,11 @@ DYNAMIC_CONFIG_SCHEMA: ConfigDefaultDict = {
'EXTERNAL_LOCATIONS': {'default': lambda c: get_external_locations(c)},
'DATA_LOCATIONS': {'default': lambda c: get_data_locations(c)},
'CHROME_OPTIONS': {'default': lambda c: get_chrome_info(c)},
'SAVE_ALLOWLIST_PTN': {'default': lambda c: c['SAVE_ALLOWLIST'] and {re.compile(k, ALLOWDENYLIST_REGEX_FLAGS): v for k, v in c['SAVE_ALLOWLIST'].items()}},
'SAVE_DENYLIST_PTN': {'default': lambda c: c['SAVE_DENYLIST'] and {re.compile(k, ALLOWDENYLIST_REGEX_FLAGS): v for k, v in c['SAVE_DENYLIST'].items()}},
}
################################### Helpers ####################################
@@ -391,7 +521,7 @@ def load_config_val(key: str,
elif val.lower() in ('false', 'no', '0'):
return False
else:
raise ValueError(f'Invalid configuration option {key}={val} (expected a boolean: True/False)')
raise ValueError(f'Invalid configuration option {key}={val} (expected a boolean: True/False)')
elif type is str:
if val.lower() in ('true', 'false', 'yes', 'no', '1', '0'):
@@ -416,7 +546,7 @@ def load_config_file(out_dir: str=None) -> Optional[Dict[str, str]]:
config_path = Path(out_dir) / CONFIG_FILENAME
if config_path.exists():
config_file = ConfigParser()
config_file.optionxform = str
config_file.optionxform = str
config_file.read(config_path)
# flatten into one namespace
config_file_vars = {
@@ -440,7 +570,7 @@ def write_config_file(config: Dict[str, str], out_dir: str=None) -> ConfigDict:
#
# You can add options here manually in INI format, or automatically by running:
# archivebox config --set KEY=VALUE
#
#
# If you modify this file manually, make sure to update your archive after by running:
# archivebox init
#
@@ -451,7 +581,7 @@ def write_config_file(config: Dict[str, str], out_dir: str=None) -> ConfigDict:
out_dir = out_dir or Path(os.getenv('OUTPUT_DIR', '.')).resolve()
config_path = Path(out_dir) / CONFIG_FILENAME
if not config_path.exists():
atomic_write(config_path, CONFIG_HEADER)
@@ -459,7 +589,7 @@ def write_config_file(config: Dict[str, str], out_dir: str=None) -> ConfigDict:
config_file.optionxform = str
config_file.read(config_path)
with open(config_path, 'r') as old:
with open(config_path, 'r', encoding='utf-8') as old:
atomic_write(f'{config_path}.bak', old.read())
find_section = lambda key: [name for name, opts in CONFIG_SCHEMA.items() if key in opts][0]
@@ -480,41 +610,42 @@ def write_config_file(config: Dict[str, str], out_dir: str=None) -> ConfigDict:
if (not existing_secret_key) or ('not a valid secret' in existing_secret_key):
from django.utils.crypto import get_random_string
chars = 'abcdefghijklmnopqrstuvwxyz0123456789-_+!.'
chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
random_secret_key = get_random_string(50, chars)
if 'SERVER_CONFIG' in config_file:
config_file['SERVER_CONFIG']['SECRET_KEY'] = random_secret_key
else:
config_file['SERVER_CONFIG'] = {'SECRET_KEY': random_secret_key}
with open(config_path, 'w+') as new:
with open(config_path, 'w+', encoding='utf-8') as new:
config_file.write(new)
try:
# validate the config by attempting to re-parse it
CONFIG = load_all_config()
return {
key.upper(): CONFIG.get(key.upper())
for key in config.keys()
}
except:
except BaseException: # lgtm [py/catch-base-exception]
# something went horribly wrong, rever to the previous version
with open(f'{config_path}.bak', 'r') as old:
with open(f'{config_path}.bak', 'r', encoding='utf-8') as old:
atomic_write(config_path, old.read())
raise
if Path(f'{config_path}.bak').exists():
os.remove(f'{config_path}.bak')
return {}
return {
key.upper(): CONFIG.get(key.upper())
for key in config.keys()
}
def load_config(defaults: ConfigDefaultDict,
config: Optional[ConfigDict]=None,
out_dir: Optional[str]=None,
env_vars: Optional[os._Environ]=None,
config_file_vars: Optional[Dict[str, str]]=None) -> ConfigDict:
env_vars = env_vars or os.environ
config_file_vars = config_file_vars or load_config_file(out_dir=out_dir)
@@ -542,9 +673,9 @@ def load_config(defaults: ConfigDefaultDict,
stderr(' For config documentation and examples see:')
stderr(' https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration')
stderr()
raise
# raise
raise SystemExit(2)
return extended_config
# def write_config(config: ConfigDict):
@@ -593,7 +724,10 @@ def bin_version(binary: Optional[str]) -> Optional[str]:
return None
try:
version_str = run([abspath, "--version"], stdout=PIPE).stdout.strip().decode()
bin_env = os.environ | {'LANG': 'C'}
version_str = run([abspath, "--version"], stdout=PIPE, env=bin_env).stdout.strip().decode()
if not version_str:
version_str = run([abspath, "--version"], stdout=PIPE).stdout.strip().decode()
# take first 3 columns of first line of version info
return ' '.join(version_str.split('\n')[0].strip().split()[:3])
except OSError:
@@ -627,7 +761,7 @@ def bin_hash(binary: Optional[str]) -> Optional[str]:
with io.open(abs_path, mode='rb') as f:
for chunk in iter(lambda: f.read(io.DEFAULT_BUFFER_SIZE), b''):
file_hash.update(chunk)
return f'md5:{file_hash.hexdigest()}'
def find_chrome_binary() -> Optional[str]:
@@ -652,7 +786,7 @@ def find_chrome_binary() -> Optional[str]:
full_path_exists = shutil.which(name)
if full_path_exists:
return name
return None
def find_chrome_data_dir() -> Optional[str]:
@@ -703,6 +837,11 @@ def get_code_locations(config: ConfigDict) -> SimpleConfigValueDict:
'enabled': True,
'is_valid': (config['TEMPLATES_DIR'] / 'static').exists(),
},
'CUSTOM_TEMPLATES_DIR': {
'path': config['CUSTOM_TEMPLATES_DIR'] and Path(config['CUSTOM_TEMPLATES_DIR']).resolve(),
'enabled': bool(config['CUSTOM_TEMPLATES_DIR']),
'is_valid': config['CUSTOM_TEMPLATES_DIR'] and Path(config['CUSTOM_TEMPLATES_DIR']).exists(),
},
# 'NODE_MODULES_DIR': {
# 'path': ,
# 'enabled': ,
@@ -731,6 +870,7 @@ def get_data_locations(config: ConfigDict) -> ConfigValue:
'path': config['OUTPUT_DIR'].resolve(),
'enabled': True,
'is_valid': (config['OUTPUT_DIR'] / SQL_INDEX_FILENAME).exists(),
'is_mount': os.path.ismount(config['OUTPUT_DIR'].resolve()),
},
'SOURCES_DIR': {
'path': config['SOURCES_DIR'].resolve(),
@@ -746,6 +886,7 @@ def get_data_locations(config: ConfigDict) -> ConfigValue:
'path': config['ARCHIVE_DIR'].resolve(),
'enabled': True,
'is_valid': config['ARCHIVE_DIR'].exists(),
'is_mount': os.path.ismount(config['ARCHIVE_DIR'].resolve()),
},
'CONFIG_FILE': {
'path': config['CONFIG_FILE'].resolve(),
@@ -756,18 +897,12 @@ def get_data_locations(config: ConfigDict) -> ConfigValue:
'path': (config['OUTPUT_DIR'] / SQL_INDEX_FILENAME).resolve(),
'enabled': True,
'is_valid': (config['OUTPUT_DIR'] / SQL_INDEX_FILENAME).exists(),
'is_mount': os.path.ismount((config['OUTPUT_DIR'] / SQL_INDEX_FILENAME).resolve()),
},
}
def get_dependency_info(config: ConfigDict) -> ConfigValue:
return {
'ARCHIVEBOX_BINARY': {
'path': bin_path(config['ARCHIVEBOX_BINARY']),
'version': config['VERSION'],
'hash': bin_hash(config['ARCHIVEBOX_BINARY']),
'enabled': True,
'is_valid': True,
},
'PYTHON_BINARY': {
'path': bin_path(config['PYTHON_BINARY']),
'version': config['PYTHON_VERSION'],
@@ -775,6 +910,13 @@ def get_dependency_info(config: ConfigDict) -> ConfigValue:
'enabled': True,
'is_valid': bool(config['PYTHON_VERSION']),
},
'SQLITE_BINARY': {
'path': bin_path(config['SQLITE_BINARY']),
'version': config['SQLITE_VERSION'],
'hash': bin_hash(config['SQLITE_BINARY']),
'enabled': True,
'is_valid': bool(config['SQLITE_VERSION']),
},
'DJANGO_BINARY': {
'path': bin_path(config['DJANGO_BINARY']),
'version': config['DJANGO_VERSION'],
@@ -782,6 +924,14 @@ def get_dependency_info(config: ConfigDict) -> ConfigValue:
'enabled': True,
'is_valid': bool(config['DJANGO_VERSION']),
},
'ARCHIVEBOX_BINARY': {
'path': bin_path(config['ARCHIVEBOX_BINARY']),
'version': config['VERSION'],
'hash': bin_hash(config['ARCHIVEBOX_BINARY']),
'enabled': True,
'is_valid': True,
},
'CURL_BINARY': {
'path': bin_path(config['CURL_BINARY']),
'version': config['CURL_VERSION'],
@@ -867,7 +1017,8 @@ def get_chrome_info(config: ConfigDict) -> ConfigValue:
'TIMEOUT': config['TIMEOUT'],
'RESOLUTION': config['RESOLUTION'],
'CHECK_SSL_VALIDITY': config['CHECK_SSL_VALIDITY'],
'CHROME_BINARY': config['CHROME_BINARY'],
'CHROME_BINARY': bin_path(config['CHROME_BINARY']),
'CHROME_TIMEOUT': config['CHROME_TIMEOUT'],
'CHROME_HEADLESS': config['CHROME_HEADLESS'],
'CHROME_SANDBOX': config['CHROME_SANDBOX'],
'CHROME_USER_AGENT': config['CHROME_USER_AGENT'],
@@ -908,13 +1059,22 @@ globals().update(CONFIG)
# Set timezone to UTC and umask to OUTPUT_PERMISSIONS
os.environ["TZ"] = 'UTC'
os.umask(0o777 - int(OUTPUT_PERMISSIONS, base=8)) # noqa: F821
assert TIMEZONE == 'UTC', 'The server timezone should always be set to UTC' # we may allow this to change later
os.environ["TZ"] = TIMEZONE
os.umask(0o777 - int(DIR_OUTPUT_PERMISSIONS, base=8)) # noqa: F821
# add ./node_modules/.bin to $PATH so we can use node scripts in extractors
NODE_BIN_PATH = str((Path(CONFIG["OUTPUT_DIR"]).absolute() / 'node_modules' / '.bin'))
sys.path.append(NODE_BIN_PATH)
# OPTIONAL: also look around the host system for node modules to use
# avoid enabling this unless absolutely needed,
# having overlapping potential sources of libs is a big source of bugs/confusing to users
# DEV_NODE_BIN_PATH = str((Path(CONFIG["PACKAGE_DIR"]).absolute() / '..' / 'node_modules' / '.bin'))
# sys.path.append(DEV_NODE_BIN_PATH)
# USER_NODE_BIN_PATH = str(Path('~/.node_modules/.bin').resolve())
# sys.path.append(USER_NODE_BIN_PATH)
# disable stderr "you really shouldnt disable ssl" warnings with library config
if not CONFIG['CHECK_SSL_VALIDITY']:
import urllib3
@@ -922,6 +1082,13 @@ if not CONFIG['CHECK_SSL_VALIDITY']:
requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# get SQLite database version, compile options, and runtime options
# TODO: make this a less hacky proper assertion checker helper function in somewhere like setup_django
#cursor = sqlite3.connect(':memory:').cursor()
#DYNAMIC_CONFIG_SCHEMA['SQLITE_VERSION'] = lambda c: cursor.execute("SELECT sqlite_version();").fetchone()[0]
#DYNAMIC_CONFIG_SCHEMA['SQLITE_JOURNAL_MODE'] = lambda c: cursor.execute('PRAGMA journal_mode;').fetchone()[0]
#DYNAMIC_CONFIG_SCHEMA['SQLITE_OPTIONS'] = lambda c: [option[0] for option in cursor.execute('PRAGMA compile_options;').fetchall()]
#cursor.close()
########################### Config Validity Checkers ###########################
@@ -940,6 +1107,11 @@ def check_system_config(config: ConfigDict=CONFIG) -> None:
stderr(' See https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#python for help upgrading your Python installation.')
raise SystemExit(2)
if int(CONFIG['DJANGO_VERSION'].split('.')[0]) < 3:
stderr(f'[X] Django version is not new enough: {config["DJANGO_VERSION"]} (>3.0 is required)', color='red')
stderr(' Upgrade django using pip or your system package manager: pip3 install --upgrade django')
raise SystemExit(2)
if config['PYTHON_ENCODING'] not in ('UTF-8', 'UTF8'):
stderr(f'[X] Your system is running python3 scripts with a bad locale setting: {config["PYTHON_ENCODING"]} (it should be UTF-8).', color='red')
stderr(' To fix it, add the line "export PYTHONIOENCODING=UTF-8" to your ~/.bashrc file (without quotes)')
@@ -980,9 +1152,9 @@ def check_dependencies(config: ConfigDict=CONFIG, show_help: bool=True) -> None:
info['version'] or 'unable to detect version',
)
)
if dependency in ('SINGLEFILE_BINARY', 'READABILITY_BINARY', 'MERCURY_BINARY'):
hint(('npm install --prefix . "git+https://github.com/ArchiveBox/ArchiveBox.git"',
f'or archivebox config --set SAVE_{dependency.rsplit("_", 1)[0]}=False to silence this warning',
if dependency in ('YOUTUBEDL_BINARY', 'CHROME_BINARY', 'SINGLEFILE_BINARY', 'READABILITY_BINARY', 'MERCURY_BINARY'):
hint(('To install all packages automatically run: archivebox setup',
f'or to disable it and silence this warning: archivebox config --set SAVE_{dependency.rsplit("_", 1)[0]}=False',
''), prefix=' ')
stderr('')
@@ -1012,13 +1184,14 @@ def check_dependencies(config: ConfigDict=CONFIG, show_help: bool=True) -> None:
stderr(' If you want to disable media archiving entirely, set SAVE_MEDIA=False instead:')
stderr(' https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#save_media')
stderr()
def check_data_folder(out_dir: Union[str, Path, None]=None, config: ConfigDict=CONFIG) -> None:
output_dir = out_dir or config['OUTPUT_DIR']
assert isinstance(output_dir, (str, Path))
sql_index_exists = (Path(output_dir) / SQL_INDEX_FILENAME).exists()
if not sql_index_exists:
archive_dir_exists = (Path(output_dir) / ARCHIVE_DIR_NAME).exists()
if not archive_dir_exists:
stderr('[X] No archivebox index found in the current directory.', color='red')
stderr(f' {output_dir}', color='lightyellow')
stderr()
@@ -1030,55 +1203,104 @@ def check_data_folder(out_dir: Union[str, Path, None]=None, config: ConfigDict=C
stderr(' archivebox init')
raise SystemExit(2)
def check_migrations(out_dir: Union[str, Path, None]=None, config: ConfigDict=CONFIG):
output_dir = out_dir or config['OUTPUT_DIR']
from .index.sql import list_migrations
pending_migrations = [name for status, name in list_migrations() if not status]
if (not sql_index_exists) or pending_migrations:
if sql_index_exists:
pending_operation = f'apply the {len(pending_migrations)} pending migrations'
else:
pending_operation = 'generate the new SQL main index'
if pending_migrations:
stderr('[X] This collection was created with an older version of ArchiveBox and must be upgraded first.', color='lightyellow')
stderr(f' {output_dir}')
stderr()
stderr(f' To upgrade it to the latest version and {pending_operation} run:')
stderr(f' To upgrade it to the latest version and apply the {len(pending_migrations)} pending migrations, run:')
stderr(' archivebox init')
raise SystemExit(3)
sources_dir = Path(output_dir) / SOURCES_DIR_NAME
if not sources_dir.exists():
sources_dir.mkdir()
(Path(output_dir) / SOURCES_DIR_NAME).mkdir(exist_ok=True)
(Path(output_dir) / LOGS_DIR_NAME).mkdir(exist_ok=True)
def setup_django(out_dir: Path=None, check_db=False, config: ConfigDict=CONFIG, in_memory_db=False) -> None:
check_system_config()
output_dir = out_dir or Path(config['OUTPUT_DIR'])
assert isinstance(output_dir, Path) and isinstance(config['PACKAGE_DIR'], Path)
try:
import django
from django.core.management import call_command
sys.path.append(str(config['PACKAGE_DIR']))
os.environ.setdefault('OUTPUT_DIR', str(output_dir))
assert (config['PACKAGE_DIR'] / 'core' / 'settings.py').exists(), 'settings.py was not found at archivebox/core/settings.py'
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
# Check to make sure JSON extension is available in our Sqlite3 instance
try:
cursor = sqlite3.connect(':memory:').cursor()
cursor.execute('SELECT JSON(\'{"a": "b"}\')')
except sqlite3.OperationalError as exc:
stderr(f'[X] Your SQLite3 version is missing the required JSON1 extension: {exc}', color='red')
hint([
'Upgrade your Python version or install the extension manually:',
'https://code.djangoproject.com/wiki/JSON1Extension'
])
if in_memory_db:
# Put the db in memory and run migrations in case any command requires it
from django.core.management import call_command
# some commands (e.g. oneshot) dont store a long-lived sqlite3 db file on disk.
# in those cases we create a temporary in-memory db and run the migrations
# immediately to get a usable in-memory-database at startup
os.environ.setdefault("ARCHIVEBOX_DATABASE_NAME", ":memory:")
django.setup()
call_command("migrate", interactive=False, verbosity=0)
else:
# Otherwise use default sqlite3 file-based database and initialize django
# without running migrations automatically (user runs them manually by calling init)
django.setup()
from django.conf import settings
# log startup message to the error log
with open(settings.ERROR_LOG, "a", encoding='utf-8') as f:
command = ' '.join(sys.argv)
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d__%H:%M:%S')
f.write(f"\n> {command}; ts={ts} version={config['VERSION']} docker={config['IN_DOCKER']} is_tty={config['IS_TTY']}\n")
if check_db:
# Enable WAL mode in sqlite3
from django.db import connection
with connection.cursor() as cursor:
# Set Journal mode to WAL to allow for multiple writers
current_mode = cursor.execute("PRAGMA journal_mode")
if current_mode != 'wal':
cursor.execute("PRAGMA journal_mode=wal;")
# Set max blocking delay for concurrent writes and write sync mode
# https://litestream.io/tips/#busy-timeout
cursor.execute("PRAGMA busy_timeout = 5000;")
cursor.execute("PRAGMA synchronous = NORMAL;")
# Create cache table in DB if needed
try:
from django.core.cache import cache
cache.get('test', None)
except django.db.utils.OperationalError:
call_command("createcachetable", verbosity=0)
# if archivebox gets imported multiple times, we have to close
# the sqlite3 whenever we init from scratch to avoid multiple threads
# sharing the same connection by accident
from django.db import connections
for conn in connections.all():
conn.close_if_unusable_or_obsolete()
sql_index_path = Path(output_dir) / SQL_INDEX_FILENAME
assert sql_index_path.exists(), (
f'No database file {SQL_INDEX_FILENAME} found in OUTPUT_DIR: {config["OUTPUT_DIR"]}')
f'No database file {SQL_INDEX_FILENAME} found in: {config["OUTPUT_DIR"]} (Are you in an ArchiveBox collection directory?)')
except KeyboardInterrupt:
raise SystemExit(2)
+3 -1
View File
@@ -41,7 +41,7 @@ class ConfigDict(BaseConfig, total=False):
MEDIA_TIMEOUT: int
OUTPUT_PERMISSIONS: str
RESTRICT_FILE_NAMES: str
URL_BLACKLIST: str
URL_DENYLIST: str
SECRET_KEY: Optional[str]
BIND_ADDR: str
@@ -74,6 +74,7 @@ class ConfigDict(BaseConfig, total=False):
CHROME_USER_AGENT: str
COOKIES_FILE: Union[str, Path, None]
CHROME_USER_DATA_DIR: Union[str, Path, None]
CHROME_TIMEOUT: int
CHROME_HEADLESS: bool
CHROME_SANDBOX: bool
@@ -98,6 +99,7 @@ class ConfigDict(BaseConfig, total=False):
WGET_ARGS: List[str]
CURL_ARGS: List[str]
GIT_ARGS: List[str]
TAG_SEPARATOR_PATTERN: str
ConfigDefaultValueGetter = Callable[[ConfigDict], ConfigValue]
+234 -77
View File
@@ -1,7 +1,9 @@
__package__ = 'archivebox.core'
from io import StringIO
from pathlib import Path
from contextlib import redirect_stdout
from datetime import datetime, timezone
from django.contrib import admin
from django.urls import path
@@ -13,15 +15,15 @@ from django import forms
from ..util import htmldecode, urldecode, ansi_to_html
from core.models import Snapshot, Tag
from core.forms import AddLinkForm, TagField
from core.models import Snapshot, ArchiveResult, Tag
from core.forms import AddLinkForm
from core.mixins import SearchResultsAdminMixin
from index.html import snapshot_icons
from logging_util import printable_filesize
from main import add, remove
from config import OUTPUT_DIR
from config import OUTPUT_DIR, SNAPSHOTS_PER_PAGE
from extractors import archive_links
# Admin URLs
@@ -36,95 +38,126 @@ from extractors import archive_links
# TODO: https://stackoverflow.com/questions/40760880/add-custom-button-to-django-admin-panel
def update_snapshots(modeladmin, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], out_dir=OUTPUT_DIR)
update_snapshots.short_description = "Archive"
def update_titles(modeladmin, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], overwrite=True, methods=('title','favicon'), out_dir=OUTPUT_DIR)
update_titles.short_description = "Pull title"
class ArchiveResultInline(admin.TabularInline):
model = ArchiveResult
def overwrite_snapshots(modeladmin, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], overwrite=True, out_dir=OUTPUT_DIR)
overwrite_snapshots.short_description = "Re-archive (overwrite)"
class TagInline(admin.TabularInline):
model = Snapshot.tags.through
def verify_snapshots(modeladmin, request, queryset):
for snapshot in queryset:
print(snapshot.timestamp, snapshot.url, snapshot.is_archived, snapshot.archive_size, len(snapshot.history))
from django.contrib.admin.helpers import ActionForm
from django.contrib.admin.widgets import AutocompleteSelectMultiple
verify_snapshots.short_description = "Check"
class AutocompleteTags:
model = Tag
search_fields = ['name']
def delete_snapshots(modeladmin, request, queryset):
remove(snapshots=queryset, yes=True, delete=True, out_dir=OUTPUT_DIR)
delete_snapshots.short_description = "Delete"
class AutocompleteTagsAdminStub:
name = 'admin'
class SnapshotAdminForm(forms.ModelForm):
tags = TagField(required=False)
class SnapshotActionForm(ActionForm):
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
widget=AutocompleteSelectMultiple(
AutocompleteTags(),
AutocompleteTagsAdminStub(),
),
)
class Meta:
model = Snapshot
fields = "__all__"
def save(self, commit=True):
# Based on: https://stackoverflow.com/a/49933068/3509554
# Get the unsave instance
instance = forms.ModelForm.save(self, False)
tags = self.cleaned_data.pop("tags")
#update save_m2m
def new_save_m2m():
instance.save_tags(tags)
# Do we need to save all changes now?
self.save_m2m = new_save_m2m
if commit:
instance.save()
return instance
# TODO: allow selecting actions for specific extractors? is this useful?
# EXTRACTOR_CHOICES = [
# (name, name.title())
# for name, _, _ in get_default_archive_methods()
# ]
# extractor = forms.ChoiceField(
# choices=EXTRACTOR_CHOICES,
# required=False,
# widget=forms.MultileChoiceField(attrs={'class': "form-control"})
# )
class SnapshotAdmin(SearchResultsAdminMixin, admin.ModelAdmin):
list_display = ('added', 'title_str', 'url_str', 'files', 'size')
sort_fields = ('title_str', 'url_str', 'added')
readonly_fields = ('id', 'url', 'timestamp', 'num_outputs', 'is_archived', 'url_hash', 'added', 'updated')
search_fields = ['url__icontains', 'timestamp', 'title', 'tags__name']
fields = (*readonly_fields, 'title', 'tags')
list_filter = ('added', 'updated', 'tags')
list_display = ('added', 'title_str', 'files', 'size', 'url_str')
sort_fields = ('title_str', 'url_str', 'added', 'files')
readonly_fields = ('info', 'bookmarked', 'added', 'updated')
search_fields = ('id', 'url', 'timestamp', 'title', 'tags__name')
fields = ('timestamp', 'url', 'title', 'tags', *readonly_fields)
list_filter = ('added', 'updated', 'tags', 'archiveresult__status')
ordering = ['-added']
actions = [delete_snapshots, overwrite_snapshots, update_snapshots, update_titles, verify_snapshots]
actions_template = 'admin/actions_as_select.html'
form = SnapshotAdminForm
list_per_page = 40
actions = ['add_tags', 'remove_tags', 'update_titles', 'update_snapshots', 'resnapshot_snapshot', 'overwrite_snapshots', 'delete_snapshots']
autocomplete_fields = ['tags']
inlines = [ArchiveResultInline]
list_per_page = SNAPSHOTS_PER_PAGE
action_form = SnapshotActionForm
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('grid/', self.admin_site.admin_view(self.grid_view),name='grid')
path('grid/', self.admin_site.admin_view(self.grid_view), name='grid')
]
return custom_urls + urls
def get_queryset(self, request):
self.request = request
return super().get_queryset(request).prefetch_related('tags')
def tag_list(self, obj):
return ', '.join(obj.tags.values_list('name', flat=True))
def id_str(self, obj):
# TODO: figure out a different way to do this, you cant nest forms so this doenst work
# def action(self, obj):
# # csrfmiddlewaretoken: Wa8UcQ4fD3FJibzxqHN3IYrrjLo4VguWynmbzzcPYoebfVUnDovon7GEMYFRgsh0
# # action: update_snapshots
# # select_across: 0
# # _selected_action: 76d29b26-2a88-439e-877c-a7cca1b72bb3
# return format_html(
# '''
# <form action="/admin/core/snapshot/" method="post" onsubmit="e => e.stopPropagation()">
# <input type="hidden" name="csrfmiddlewaretoken" value="{}">
# <input type="hidden" name="_selected_action" value="{}">
# <button name="update_snapshots">Check</button>
# <button name="update_titles">Pull title + favicon</button>
# <button name="update_snapshots">Update</button>
# <button name="overwrite_snapshots">Re-Archive (overwrite)</button>
# <button name="delete_snapshots">Permanently delete</button>
# </form>
# ''',
# csrf.get_token(self.request),
# obj.id,
# )
def info(self, obj):
return format_html(
'<code style="font-size: 10px">{}</code>',
obj.url_hash[:8],
'''
UUID: <code style="font-size: 10px; user-select: all">{}</code> &nbsp; &nbsp;
Timestamp: <code style="font-size: 10px; user-select: all">{}</code> &nbsp; &nbsp;
URL Hash: <code style="font-size: 10px; user-select: all">{}</code><br/>
Archived: {} ({} files {}) &nbsp; &nbsp;
Favicon: <img src="{}" style="height: 20px"/> &nbsp; &nbsp;
Status code: {} &nbsp; &nbsp;
Server: {} &nbsp; &nbsp;
Content type: {} &nbsp; &nbsp;
Extension: {} &nbsp; &nbsp;
<br/><br/>
<a href="/archive/{}">View Snapshot index ➡️</a> &nbsp; &nbsp;
<a href="/admin/core/snapshot/?id__exact={}">View actions ⚙️</a>
''',
obj.id,
obj.timestamp,
obj.url_hash,
'✅' if obj.is_archived else '❌',
obj.num_outputs,
self.size(obj),
f'/archive/{obj.timestamp}/favicon.ico',
obj.status_code or '?',
obj.headers and obj.headers.get('Server') or '?',
obj.headers and obj.headers.get('Content-Type') or '?',
obj.extension or '?',
obj.timestamp,
obj.id,
)
def title_str(self, obj):
@@ -151,8 +184,11 @@ class SnapshotAdmin(SearchResultsAdminMixin, admin.ModelAdmin):
def files(self, obj):
return snapshot_icons(obj)
files.admin_order_field = 'updated'
files.short_description = 'Files Saved'
def size(self, obj):
archive_size = obj.archive_size
archive_size = (Path(obj.link_dir) / 'index.html').exists() and obj.archive_size
if archive_size:
size_txt = printable_filesize(archive_size)
if archive_size > 52428800:
@@ -165,14 +201,16 @@ class SnapshotAdmin(SearchResultsAdminMixin, admin.ModelAdmin):
size_txt,
)
size.admin_order_field = 'archiveresult__count'
def url_str(self, obj):
return format_html(
'<a href="{}"><code>{}</code></a>',
'<a href="{}"><code style="user-select: all;">{}</code></a>',
obj.url,
obj.url,
obj.url.split('://www.', 1)[-1].split('://', 1)[-1][:64],
)
def grid_view(self, request):
def grid_view(self, request, extra_context=None):
# cl = self.get_changelist_instance(request)
@@ -183,35 +221,153 @@ class SnapshotAdmin(SearchResultsAdminMixin, admin.ModelAdmin):
# Monkey patch here plus core_tags.py
self.change_list_template = 'private_index_grid.html'
self.list_per_page = 20
self.list_per_page = SNAPSHOTS_PER_PAGE
self.list_max_show_all = self.list_per_page
# Call monkey patched view
rendered_response = self.changelist_view(request)
rendered_response = self.changelist_view(request, extra_context=extra_context)
# Restore values
self.change_list_template = saved_change_list_template
self.change_list_template = saved_change_list_template
self.list_per_page = saved_list_per_page
self.list_max_show_all = saved_list_max_show_all
return rendered_response
# for debugging, uncomment this to print all requests:
# def changelist_view(self, request, extra_context=None):
# print('[*] Got request', request.method, request.POST)
# return super().changelist_view(request, extra_context=None)
def update_snapshots(self, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], out_dir=OUTPUT_DIR)
update_snapshots.short_description = "Pull"
def update_titles(self, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], overwrite=True, methods=('title','favicon'), out_dir=OUTPUT_DIR)
update_titles.short_description = "⬇️ Title"
def resnapshot_snapshot(self, request, queryset):
for snapshot in queryset:
timestamp = datetime.now(timezone.utc).isoformat('T', 'seconds')
new_url = snapshot.url.split('#')[0] + f'#{timestamp}'
add(new_url, tag=snapshot.tags_str())
resnapshot_snapshot.short_description = "Re-Snapshot"
def overwrite_snapshots(self, request, queryset):
archive_links([
snapshot.as_link()
for snapshot in queryset
], overwrite=True, out_dir=OUTPUT_DIR)
overwrite_snapshots.short_description = "Reset"
def delete_snapshots(self, request, queryset):
remove(snapshots=queryset, yes=True, delete=True, out_dir=OUTPUT_DIR)
delete_snapshots.short_description = "Delete"
def add_tags(self, request, queryset):
tags = request.POST.getlist('tags')
print('[+] Adding tags', tags, 'to Snapshots', queryset)
for obj in queryset:
obj.tags.add(*tags)
add_tags.short_description = "+"
def remove_tags(self, request, queryset):
tags = request.POST.getlist('tags')
print('[-] Removing tags', tags, 'to Snapshots', queryset)
for obj in queryset:
obj.tags.remove(*tags)
remove_tags.short_description = "–"
id_str.short_description = 'ID'
title_str.short_description = 'Title'
url_str.short_description = 'Original URL'
id_str.admin_order_field = 'id'
title_str.admin_order_field = 'title'
url_str.admin_order_field = 'url'
class TagAdmin(admin.ModelAdmin):
list_display = ('slug', 'name', 'id')
list_display = ('slug', 'name', 'num_snapshots', 'snapshots', 'id')
sort_fields = ('id', 'name', 'slug')
readonly_fields = ('id',)
readonly_fields = ('id', 'num_snapshots', 'snapshots')
search_fields = ('id', 'name', 'slug')
fields = (*readonly_fields, 'name', 'slug')
actions = ['delete_selected']
ordering = ['-id']
def num_snapshots(self, obj):
return format_html(
'<a href="/admin/core/snapshot/?tags__id__exact={}">{} total</a>',
obj.id,
obj.snapshot_set.count(),
)
def snapshots(self, obj):
total_count = obj.snapshot_set.count()
return mark_safe('<br/>'.join(
format_html(
'{} <code><a href="/admin/core/snapshot/{}/change"><b>[{}]</b></a> {}</code>',
snap.updated.strftime('%Y-%m-%d %H:%M') if snap.updated else 'pending...',
snap.id,
snap.timestamp,
snap.url,
)
for snap in obj.snapshot_set.order_by('-updated')[:10]
) + (f'<br/><a href="/admin/core/snapshot/?tags__id__exact={obj.id}">and {total_count-10} more...<a>' if obj.snapshot_set.count() > 10 else ''))
class ArchiveResultAdmin(admin.ModelAdmin):
list_display = ('id', 'start_ts', 'extractor', 'snapshot_str', 'tags_str', 'cmd_str', 'status', 'output_str')
sort_fields = ('start_ts', 'extractor', 'status')
readonly_fields = ('id', 'uuid', 'snapshot_str', 'tags_str')
search_fields = ('id', 'uuid', 'snapshot__url', 'extractor', 'output', 'cmd_version', 'cmd', 'snapshot__timestamp')
fields = (*readonly_fields, 'snapshot', 'extractor', 'status', 'start_ts', 'end_ts', 'output', 'pwd', 'cmd', 'cmd_version')
autocomplete_fields = ['snapshot']
list_filter = ('status', 'extractor', 'start_ts', 'cmd_version')
ordering = ['-start_ts']
list_per_page = SNAPSHOTS_PER_PAGE
def snapshot_str(self, obj):
return format_html(
'<a href="/archive/{}/index.html"><b><code>[{}]</code></b></a><br/>'
'<small>{}</small>',
obj.snapshot.timestamp,
obj.snapshot.timestamp,
obj.snapshot.url[:128],
)
def tags_str(self, obj):
return obj.snapshot.tags_str()
def cmd_str(self, obj):
return format_html(
'<pre>{}</pre>',
' '.join(obj.cmd) if isinstance(obj.cmd, list) else str(obj.cmd),
)
def output_str(self, obj):
return format_html(
'<a href="/archive/{}/{}" class="output-link">↗️</a><pre>{}</pre>',
obj.snapshot.timestamp,
obj.output if (obj.status == 'succeeded') and obj.extractor not in ('title', 'archive_org') else 'index.html',
obj.output,
)
tags_str.short_description = 'tags'
snapshot_str.short_description = 'snapshot'
class ArchiveBoxAdmin(admin.AdminSite):
site_header = 'ArchiveBox'
@@ -266,4 +422,5 @@ admin.site = ArchiveBoxAdmin()
admin.site.register(get_user_model())
admin.site.register(Snapshot, SnapshotAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(ArchiveResult, ArchiveResultAdmin)
admin.site.disable_action('delete_selected')
+1
View File
@@ -3,3 +3,4 @@ from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
default_auto_field = 'django.db.models.UUIDField'
+10 -3
View File
@@ -3,9 +3,14 @@ __package__ = 'archivebox.core'
from django import forms
from ..util import URL_REGEX
from ..parsers import PARSERS
from ..vendor.taggit_utils import edit_string_for_tags, parse_tags
CHOICES = (
PARSER_CHOICES = [
(parser_key, parser[0])
for parser_key, parser in PARSERS.items()
]
DEPTH_CHOICES = (
('0', 'depth = 0 (archive just these URLs)'),
('1', 'depth = 1 (archive these URLs and all URLs one hop away)'),
)
@@ -20,7 +25,9 @@ ARCHIVE_METHODS = [
class AddLinkForm(forms.Form):
url = forms.RegexField(label="URLs (one per line)", regex=URL_REGEX, min_length='6', strip=True, widget=forms.Textarea, required=True)
depth = forms.ChoiceField(label="Archive depth", choices=CHOICES, widget=forms.RadioSelect, initial='0')
parser = forms.ChoiceField(label="URLs format", choices=[('auto', 'Auto-detect parser'), *PARSER_CHOICES], initial='auto')
tag = forms.CharField(label="Tags (comma separated tag1,tag2,tag3)", strip=True, required=False)
depth = forms.ChoiceField(label="Archive depth", choices=DEPTH_CHOICES, initial='0', widget=forms.RadioSelect(attrs={"class": "depth-selection"}))
archive_methods = forms.MultipleChoiceField(
label="Archive methods (select at least 1, otherwise all will be used by default)",
required=False,
@@ -34,7 +41,7 @@ class AddLinkForm(forms.Form):
# label="Exclude patterns",
# min_length='1',
# required=False,
# initial=URL_BLACKLIST,
# initial=URL_DENYLIST,
# )
# timeout = forms.IntegerField(
# initial=TIMEOUT,
+60
View File
@@ -0,0 +1,60 @@
__package__ = 'archivebox.core'
import ipaddress
from django.utils import timezone
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.core.exceptions import ImproperlyConfigured
from ..config import PUBLIC_SNAPSHOTS, REVERSE_PROXY_USER_HEADER, REVERSE_PROXY_WHITELIST
def detect_timezone(request, activate: bool=True):
gmt_offset = (request.COOKIES.get('GMT_OFFSET') or '').strip()
tz = None
if gmt_offset.replace('-', '').isdigit():
tz = timezone.get_fixed_timezone(int(gmt_offset))
if activate:
timezone.activate(tz)
# print('GMT_OFFSET', gmt_offset, tz)
return tz
def TimezoneMiddleware(get_response):
def middleware(request):
detect_timezone(request, activate=True)
return get_response(request)
return middleware
def CacheControlMiddleware(get_response):
def middleware(request):
response = get_response(request)
if '/archive/' in request.path or '/static/' in request.path:
policy = 'public' if PUBLIC_SNAPSHOTS else 'private'
response['Cache-Control'] = f'{policy}, max-age=60, stale-while-revalidate=300'
# print('Set Cache-Control header to', response['Cache-Control'])
return response
return middleware
class ReverseProxyAuthMiddleware(RemoteUserMiddleware):
header = 'HTTP_{normalized}'.format(normalized=REVERSE_PROXY_USER_HEADER.replace('-', '_').upper())
def process_request(self, request):
if REVERSE_PROXY_WHITELIST == '':
return
ip = request.META.get('REMOTE_ADDR')
for cidr in REVERSE_PROXY_WHITELIST.split(','):
try:
network = ipaddress.ip_network(cidr)
except ValueError:
raise ImproperlyConfigured(
"The REVERSE_PROXY_WHITELIST config paramater is in invalid format, or "
"contains invalid CIDR. Correct format is a coma-separated list of IPv4/IPv6 CIDRs.")
if ipaddress.ip_address(ip) in network:
return super().process_request(request)
@@ -17,7 +17,7 @@ def forwards_func(apps, schema_editor):
tag_set.discard("")
for tag in tag_set:
to_add, _ = TagModel.objects.get_or_create(name=tag, slug=slugify(tag))
to_add, _ = TagModel.objects.get_or_create(name=tag, defaults={'slug': slugify(tag)})
snapshot.tags.add(to_add)
@@ -36,8 +36,25 @@ def forwards_func(apps, schema_editor):
for extractor in history:
for result in history[extractor]:
ArchiveResult.objects.create(extractor=extractor, snapshot=snapshot, cmd=result["cmd"], cmd_version=result["cmd_version"] or 'unknown',
start_ts=result["start_ts"], end_ts=result["end_ts"], status=result["status"], pwd=result["pwd"], output=result["output"])
try:
ArchiveResult.objects.create(
extractor=extractor,
snapshot=snapshot,
pwd=result["pwd"],
cmd=result.get("cmd") or [],
cmd_version=result.get("cmd_version") or 'unknown',
start_ts=result["start_ts"],
end_ts=result["end_ts"],
status=result["status"],
output=result.get("output") or 'null',
)
except Exception as e:
print(
' ! Skipping import due to missing/invalid index.json:',
out_dir,
e,
'(open an issue with this index.json for help)',
)
def verify_json_index_integrity(snapshot):
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-16 10:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0008_auto_20210105_1421'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='updated',
field=models.DateTimeField(auto_now=True, db_index=True, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-16 10:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_auto_20210216_1038'),
]
operations = [
migrations.AlterField(
model_name='archiveresult',
name='start_ts',
field=models.DateTimeField(db_index=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 3.1.3 on 2021-02-16 13:31
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('core', '0010_auto_20210216_1055'),
]
operations = [
migrations.AddField(
model_name='archiveresult',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, editable=False),
),
migrations.AlterField(
model_name='archiveresult',
name='extractor',
field=models.CharField(choices=[('title', 'title'), ('favicon', 'favicon'), ('headers', 'headers'), ('singlefile', 'singlefile'), ('pdf', 'pdf'), ('screenshot', 'screenshot'), ('dom', 'dom'), ('wget', 'wget'), ('readability', 'readability'), ('mercury', 'mercury'), ('git', 'git'), ('media', 'media'), ('archive_org', 'archive_org')], max_length=32),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2021-02-16 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_auto_20210216_1331'),
]
operations = [
migrations.AlterField(
model_name='archiveresult',
name='cmd_version',
field=models.CharField(blank=True, default=None, max_length=128, null=True),
),
migrations.AlterField(
model_name='archiveresult',
name='output',
field=models.CharField(max_length=1024),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-18 07:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_auto_20210216_1425'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='title',
field=models.CharField(blank=True, db_index=True, max_length=256, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-18 07:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0013_auto_20210218_0729'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='title',
field=models.CharField(blank=True, db_index=True, max_length=1024, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-18 07:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_auto_20210218_0729'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='title',
field=models.CharField(blank=True, db_index=True, max_length=512, null=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-18 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_auto_20210218_0730'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='tags',
field=models.ManyToManyField(blank=True, to='core.Tag'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-02-19 02:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_auto_20210218_1204'),
]
operations = [
migrations.AlterField(
model_name='tag',
name='slug',
field=models.SlugField(blank=True, max_length=100, unique=True, verbose_name='slug'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.1.3 on 2021-03-27 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0017_auto_20210219_0211'),
]
operations = [
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='tag',
name='slug',
field=models.SlugField(blank=True, max_length=100, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.3 on 2021-04-01 06:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_auto_20210327_0952'),
]
operations = [
migrations.AlterField(
model_name='snapshot',
name='url',
field=models.URLField(db_index=True, unique=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 3.1.8 on 2021-04-10 10:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_auto_20210401_0654'),
]
operations = [
migrations.AlterField(
model_name='archiveresult',
name='id',
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='tag',
name='id',
field=models.AutoField(primary_key=True, serialize=False, verbose_name='ID'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.1.14 on 2022-09-14 09:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_auto_20210410_1031'),
]
operations = [
migrations.AlterField(
model_name='archiveresult',
name='extractor',
field=models.CharField(choices=[('favicon', 'favicon'), ('headers', 'headers'), ('singlefile', 'singlefile'), ('pdf', 'pdf'), ('screenshot', 'screenshot'), ('dom', 'dom'), ('wget', 'wget'), ('title', 'title'), ('readability', 'readability'), ('mercury', 'mercury'), ('git', 'git'), ('media', 'media'), ('archive_org', 'archive_org')], max_length=32),
),
]
+9 -11
View File
@@ -2,22 +2,20 @@ from django.contrib import messages
from archivebox.search import query_search_index
class SearchResultsAdminMixin(object):
def get_search_results(self, request, queryset, search_term):
''' Enhances the search queryset with results from the search backend.
'''
qs, use_distinct = \
super(SearchResultsAdminMixin, self).get_search_results(
request, queryset, search_term)
class SearchResultsAdminMixin:
def get_search_results(self, request, queryset, search_term: str):
"""Enhances the search queryset with results from the search backend"""
qs, use_distinct = super().get_search_results(request, queryset, search_term)
search_term = search_term.strip()
if not search_term:
return qs, use_distinct
try:
qsearch = query_search_index(search_term)
qs = qs | qsearch
except Exception as err:
print(f'[!] Error while using search backend: {err.__class__.__name__} {err}')
messages.add_message(request, messages.WARNING, f'Error from the search backend, only showing results from default admin search fields - Error: {err}')
else:
qs = queryset & qsearch
finally:
return qs, use_distinct
return qs, use_distinct
+134 -42
View File
@@ -1,14 +1,25 @@
__package__ = 'archivebox.core'
import uuid
from django.db import models, transaction
import uuid
import json
from pathlib import Path
from typing import Optional, List
from django.db import models
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.core.cache import cache
from django.urls import reverse
from django.db.models import Case, When, Value, IntegerField
from django.contrib.auth.models import User # noqa
from ..util import parse_date
from ..config import ARCHIVE_DIR, ARCHIVE_DIR_NAME
from ..system import get_dir_size
from ..util import parse_date, base_url, hashurl
from ..index.schema import Link
from ..index.html import snapshot_icons
from ..extractors import get_default_archive_methods, ARCHIVE_METHODS_INDEXING_PRECEDENCE
EXTRACTORS = [(extractor[0], extractor[0]) for extractor in get_default_archive_methods()]
@@ -29,8 +40,13 @@ class Tag(models.Model):
"""
Based on django-taggit model
"""
name = models.CharField(verbose_name="name", unique=True, blank=False, max_length=100)
slug = models.SlugField(verbose_name="slug", unique=True, max_length=100)
id = models.AutoField(primary_key=True, serialize=False, verbose_name='ID')
name = models.CharField(unique=True, blank=False, max_length=100)
# slug is autoset on save from name, never set it manually
slug = models.SlugField(unique=True, blank=True, max_length=100)
class Meta:
verbose_name = "Tag"
@@ -49,20 +65,21 @@ class Tag(models.Model):
if self._state.adding and not self.slug:
self.slug = self.slugify(self.name)
with transaction.atomic():
slugs = set(
type(self)
._default_manager.filter(slug__startswith=self.slug)
.values_list("slug", flat=True)
)
# if name is different but slug conficts with another tags slug, append a counter
# with transaction.atomic():
slugs = set(
type(self)
._default_manager.filter(slug__startswith=self.slug)
.values_list("slug", flat=True)
)
i = None
while True:
slug = self.slugify(self.name, i)
if slug not in slugs:
self.slug = slug
return super().save(*args, **kwargs)
i = 1 if i is None else i+1
i = None
while True:
slug = self.slugify(self.name, i)
if slug not in slugs:
self.slug = slug
return super().save(*args, **kwargs)
i = 1 if i is None else i+1
else:
return super().save(*args, **kwargs)
@@ -70,14 +87,14 @@ class Tag(models.Model):
class Snapshot(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
url = models.URLField(unique=True)
url = models.URLField(unique=True, db_index=True)
timestamp = models.CharField(max_length=32, unique=True, db_index=True)
title = models.CharField(max_length=128, null=True, blank=True, db_index=True)
title = models.CharField(max_length=512, null=True, blank=True, db_index=True)
added = models.DateTimeField(auto_now_add=True, db_index=True)
updated = models.DateTimeField(null=True, blank=True, db_index=True)
tags = models.ManyToManyField(Tag)
updated = models.DateTimeField(auto_now=True, blank=True, null=True, db_index=True)
tags = models.ManyToManyField(Tag, blank=True)
keys = ('url', 'timestamp', 'title', 'tags', 'updated')
@@ -109,13 +126,32 @@ class Snapshot(models.Model):
from ..index import load_link_details
return load_link_details(self.as_link())
def tags_str(self) -> str:
return ','.join(self.tags.order_by('name').values_list('name', flat=True))
def tags_str(self, nocache=True) -> str:
cache_key = f'{self.id}-{(self.updated or self.added).timestamp()}-tags'
calc_tags_str = lambda: ','.join(self.tags.order_by('name').values_list('name', flat=True))
if nocache:
tags_str = calc_tags_str()
cache.set(cache_key, tags_str)
return tags_str
return cache.get_or_set(cache_key, calc_tags_str)
def icons(self) -> str:
return snapshot_icons(self)
@cached_property
def extension(self) -> str:
from ..util import extension
return extension(self.url)
@cached_property
def bookmarked(self):
return parse_date(self.timestamp)
@cached_property
def bookmarked_date(self):
# TODO: remove this
return self.bookmarked
@cached_property
def is_archived(self):
return self.as_link().is_archived
@@ -126,42 +162,95 @@ class Snapshot(models.Model):
@cached_property
def url_hash(self):
return self.as_link().url_hash
return hashurl(self.url)
@cached_property
def base_url(self):
return self.as_link().base_url
return base_url(self.url)
@cached_property
def link_dir(self):
return self.as_link().link_dir
return str(ARCHIVE_DIR / self.timestamp)
@cached_property
def archive_path(self):
return self.as_link().archive_path
return '{}/{}'.format(ARCHIVE_DIR_NAME, self.timestamp)
@cached_property
def archive_size(self):
return self.as_link().archive_size
cache_key = f'{str(self.id)[:12]}-{(self.updated or self.added).timestamp()}-size'
def calc_dir_size():
try:
return get_dir_size(self.link_dir)[0]
except Exception:
return 0
return cache.get_or_set(cache_key, calc_dir_size)
@cached_property
def history(self):
def thumbnail_url(self) -> Optional[str]:
result = self.archiveresult_set.filter(
extractor='screenshot',
status='succeeded'
).only('output').last()
if result:
return reverse('Snapshot', args=[f'{str(self.timestamp)}/{result.output}'])
return None
@cached_property
def headers(self) -> Optional[dict]:
try:
return json.loads((Path(self.link_dir) / 'headers.json').read_text(encoding='utf-8').strip())
except Exception:
pass
return None
@cached_property
def status_code(self) -> Optional[str]:
return self.headers and self.headers.get('Status-Code')
@cached_property
def history(self) -> dict:
# TODO: use ArchiveResult for this instead of json
return self.as_link_with_details().history
@cached_property
def latest_title(self):
if ('title' in self.history
and self.history['title']
and (self.history['title'][-1].status == 'succeeded')
and self.history['title'][-1].output.strip()):
return self.history['title'][-1].output.strip()
def latest_title(self) -> Optional[str]:
if self.title:
return self.title # whoopdedoo that was easy
try:
# take longest successful title from ArchiveResult db history
return sorted(
self.archiveresult_set\
.filter(extractor='title', status='succeeded', output__isnull=False)\
.values_list('output', flat=True),
key=lambda r: len(r),
)[-1]
except IndexError:
pass
try:
# take longest successful title from Link json index file history
return sorted(
(
result.output.strip()
for result in self.history['title']
if result.status == 'succeeded' and result.output.strip()
),
key=lambda r: len(r),
)[-1]
except (KeyError, IndexError):
pass
return None
def save_tags(self, tags=()):
def save_tags(self, tags: List[str]=()) -> None:
tags_id = []
for tag in tags:
tags_id.append(Tag.objects.get_or_create(name=tag)[0].id)
if tag.strip():
tags_id.append(Tag.objects.get_or_create(name=tag)[0].id)
self.tags.clear()
self.tags.add(*tags_id)
@@ -178,15 +267,18 @@ class ArchiveResultManager(models.Manager):
class ArchiveResult(models.Model):
id = models.AutoField(primary_key=True, serialize=False, verbose_name='ID')
uuid = models.UUIDField(default=uuid.uuid4, editable=False)
snapshot = models.ForeignKey(Snapshot, on_delete=models.CASCADE)
extractor = models.CharField(choices=EXTRACTORS, max_length=32)
cmd = JSONField()
pwd = models.CharField(max_length=256)
cmd_version = models.CharField(max_length=32, default=None, null=True, blank=True)
output = models.CharField(max_length=512)
start_ts = models.DateTimeField()
cmd_version = models.CharField(max_length=128, default=None, null=True, blank=True)
output = models.CharField(max_length=1024)
start_ts = models.DateTimeField(db_index=True)
end_ts = models.DateTimeField()
status = models.CharField(max_length=16, choices=STATUS_CHOICES)
extractor = models.CharField(choices=EXTRACTORS, max_length=32)
objects = ArchiveResultManager()
+214 -9
View File
@@ -2,20 +2,36 @@ __package__ = 'archivebox.core'
import os
import sys
import re
import logging
import tempfile
from pathlib import Path
from django.utils.crypto import get_random_string
from ..config import ( # noqa: F401
from ..config import (
DEBUG,
SECRET_KEY,
ALLOWED_HOSTS,
PACKAGE_DIR,
TEMPLATES_DIR_NAME,
CUSTOM_TEMPLATES_DIR,
SQL_INDEX_FILENAME,
OUTPUT_DIR,
)
LOGS_DIR,
TIMEZONE,
LDAP,
LDAP_SERVER_URI,
LDAP_BIND_DN,
LDAP_BIND_PASSWORD,
LDAP_USER_BASE,
LDAP_USER_FILTER,
LDAP_USERNAME_ATTR,
LDAP_FIRSTNAME_ATTR,
LDAP_LASTNAME_ATTR,
LDAP_EMAIL_ATTR,
)
IS_MIGRATING = 'makemigrations' in sys.argv[:3] or 'migrate' in sys.argv[:3]
IS_TESTING = 'test' in sys.argv[:3] or 'PYTEST_CURRENT_TEST' in os.environ
@@ -29,7 +45,8 @@ WSGI_APPLICATION = 'core.wsgi.application'
ROOT_URLCONF = 'core.urls'
LOGIN_URL = '/accounts/login/'
LOGOUT_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = os.environ.get('LOGOUT_REDIRECT_URL', '/')
PASSWORD_RESET_URL = '/accounts/password_reset/'
APPEND_SLASH = True
@@ -49,19 +66,110 @@ INSTALLED_APPS = [
]
# For usage with https://www.jetadmin.io/integrations/django
# INSTALLED_APPS += ['jet_django']
# JET_PROJECT = 'archivebox'
# JET_TOKEN = 'some-api-token-here'
MIDDLEWARE = [
'core.middleware.TimezoneMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'core.middleware.ReverseProxyAuthMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'core.middleware.CacheControlMiddleware',
]
################################################################################
### Authentication Settings
################################################################################
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.RemoteUserBackend',
'django.contrib.auth.backends.ModelBackend',
]
if LDAP:
try:
import ldap
from django_auth_ldap.config import LDAPSearch
global AUTH_LDAP_SERVER_URI
global AUTH_LDAP_BIND_DN
global AUTH_LDAP_BIND_PASSWORD
global AUTH_LDAP_USER_SEARCH
global AUTH_LDAP_USER_ATTR_MAP
AUTH_LDAP_SERVER_URI = LDAP_SERVER_URI
AUTH_LDAP_BIND_DN = LDAP_BIND_DN
AUTH_LDAP_BIND_PASSWORD = LDAP_BIND_PASSWORD
assert AUTH_LDAP_SERVER_URI and LDAP_USERNAME_ATTR and LDAP_USER_FILTER, 'LDAP_* config options must all be set if LDAP=True'
AUTH_LDAP_USER_SEARCH = LDAPSearch(
LDAP_USER_BASE,
ldap.SCOPE_SUBTREE,
'(&(' + LDAP_USERNAME_ATTR + '=%(user)s)' + LDAP_USER_FILTER + ')',
)
AUTH_LDAP_USER_ATTR_MAP = {
'username': LDAP_USERNAME_ATTR,
'first_name': LDAP_FIRSTNAME_ATTR,
'last_name': LDAP_LASTNAME_ATTR,
'email': LDAP_EMAIL_ATTR,
}
AUTHENTICATION_BACKENDS = [
'django_auth_ldap.backend.LDAPBackend',
]
except ModuleNotFoundError:
sys.stderr.write('[X] Error: Found LDAP=True config but LDAP packages not installed. You may need to run: pip install archivebox[ldap]\n\n')
# dont hard exit here. in case the user is just running "archivebox version" or "archivebox help", we still want those to work despite broken ldap
# sys.exit(1)
################################################################################
### Debug Settings
################################################################################
# only enable debug toolbar when in DEBUG mode with --nothreading (it doesnt work in multithreaded mode)
DEBUG_TOOLBAR = DEBUG and ('--nothreading' in sys.argv) and ('--reload' not in sys.argv)
if DEBUG_TOOLBAR:
try:
import debug_toolbar # noqa
DEBUG_TOOLBAR = True
except ImportError:
DEBUG_TOOLBAR = False
if DEBUG_TOOLBAR:
INSTALLED_APPS = [*INSTALLED_APPS, 'debug_toolbar']
INTERNAL_IPS = ['0.0.0.0', '127.0.0.1', '*']
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
"RENDER_PANELS": True,
}
DEBUG_TOOLBAR_PANELS = [
'debug_toolbar.panels.history.HistoryPanel',
'debug_toolbar.panels.versions.VersionsPanel',
'debug_toolbar.panels.timer.TimerPanel',
'debug_toolbar.panels.settings.SettingsPanel',
'debug_toolbar.panels.headers.HeadersPanel',
'debug_toolbar.panels.request.RequestPanel',
'debug_toolbar.panels.sql.SQLPanel',
'debug_toolbar.panels.staticfiles.StaticFilesPanel',
# 'debug_toolbar.panels.templates.TemplatesPanel',
'debug_toolbar.panels.cache.CachePanel',
'debug_toolbar.panels.signals.SignalsPanel',
'debug_toolbar.panels.logging.LoggingPanel',
'debug_toolbar.panels.redirects.RedirectsPanel',
'debug_toolbar.panels.profiling.ProfilingPanel',
'djdt_flamegraph.FlamegraphPanel',
]
MIDDLEWARE = [*MIDDLEWARE, 'debug_toolbar.middleware.DebugToolbarMiddleware']
################################################################################
### Staticfile and Template Settings
@@ -70,10 +178,12 @@ AUTHENTICATION_BACKENDS = [
STATIC_URL = '/static/'
STATICFILES_DIRS = [
*([str(CUSTOM_TEMPLATES_DIR / 'static')] if CUSTOM_TEMPLATES_DIR else []),
str(Path(PACKAGE_DIR) / TEMPLATES_DIR_NAME / 'static'),
]
TEMPLATE_DIRS = [
*([str(CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_DIR else []),
str(Path(PACKAGE_DIR) / TEMPLATES_DIR_NAME / 'core'),
str(Path(PACKAGE_DIR) / TEMPLATES_DIR_NAME / 'admin'),
str(Path(PACKAGE_DIR) / TEMPLATES_DIR_NAME),
@@ -107,6 +217,23 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': DATABASE_NAME,
'OPTIONS': {
'timeout': 60,
'check_same_thread': False,
},
'TIME_ZONE': TIMEZONE,
# DB setup is sometimes modified at runtime by setup_django() in config.py
}
}
CACHE_BACKEND = 'django.core.cache.backends.locmem.LocMemCache'
# CACHE_BACKEND = 'django.core.cache.backends.db.DatabaseCache'
# CACHE_BACKEND = 'django.core.cache.backends.dummy.DummyCache'
CACHES = {
'default': {
'BACKEND': CACHE_BACKEND,
'LOCATION': 'django_cache_default',
}
}
@@ -117,12 +244,13 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
### Security Settings
################################################################################
SECRET_KEY = SECRET_KEY or get_random_string(50, 'abcdefghijklmnopqrstuvwxyz0123456789-_+!.')
SECRET_KEY = SECRET_KEY or get_random_string(50, 'abcdefghijklmnopqrstuvwxyz0123456789_')
ALLOWED_HOSTS = ALLOWED_HOSTS.split(',')
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin'
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
@@ -131,6 +259,8 @@ SESSION_COOKIE_AGE = 1209600 # 2 weeks
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_SAVE_EVERY_REQUEST = True
SESSION_ENGINE = "django.contrib.sessions.backends.db"
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
@@ -156,10 +286,85 @@ if IS_SHELL:
################################################################################
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = False
USE_L10N = False
USE_TZ = False
USE_I18N = True
USE_L10N = True
USE_TZ = True
DATETIME_FORMAT = 'Y-m-d g:iA'
SHORT_DATETIME_FORMAT = 'Y-m-d h:iA'
TIME_ZONE = TIMEZONE # django convention is TIME_ZONE, archivebox config uses TIMEZONE, they are equivalent
from django.conf.locale.en import formats as en_formats
en_formats.DATETIME_FORMAT = DATETIME_FORMAT
en_formats.SHORT_DATETIME_FORMAT = SHORT_DATETIME_FORMAT
################################################################################
### Logging Settings
################################################################################
IGNORABLE_404_URLS = [
re.compile(r'apple-touch-icon.*\.png$'),
re.compile(r'favicon\.ico$'),
re.compile(r'robots\.txt$'),
re.compile(r'.*\.(css|js)\.map$'),
]
class NoisyRequestsFilter(logging.Filter):
def filter(self, record):
logline = record.getMessage()
# ignore harmless 404s for the patterns in IGNORABLE_404_URLS
for ignorable_url_pattern in IGNORABLE_404_URLS:
ignorable_log_pattern = re.compile(f'^"GET /.*/?{ignorable_url_pattern.pattern[:-1]} HTTP/.*" (200|30.|404) .+$', re.I | re.M)
if ignorable_log_pattern.match(logline):
return 0
# ignore staticfile requests that 200 or 30*
ignoreable_200_log_pattern = re.compile(r'"GET /static/.* HTTP/.*" (200|30.) .+', re.I | re.M)
if ignoreable_200_log_pattern.match(logline):
return 0
return 1
if LOGS_DIR.exists():
ERROR_LOG = (LOGS_DIR / 'errors.log')
else:
# historically too many edge cases here around creating log dir w/ correct permissions early on
# if there's an issue on startup, we trash the log and let user figure it out via stdout/stderr
ERROR_LOG = tempfile.NamedTemporaryFile().name
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'class': 'logging.StreamHandler',
},
'logfile': {
'level': 'ERROR',
'class': 'logging.handlers.RotatingFileHandler',
'filename': ERROR_LOG,
'maxBytes': 1024 * 1024 * 25, # 25 MB
'backupCount': 10,
},
},
'filters': {
'noisyrequestsfilter': {
'()': NoisyRequestsFilter,
}
},
'loggers': {
'django': {
'handlers': ['console', 'logfile'],
'level': 'INFO',
'filters': ['noisyrequestsfilter'],
},
'django.server': {
'handlers': ['console', 'logfile'],
'level': 'INFO',
'filters': ['noisyrequestsfilter'],
}
},
}
+3 -10
View File
@@ -1,22 +1,15 @@
from django import template
from django.urls import reverse
from django.contrib.admin.templatetags.base import InclusionAdminNode
from django.templatetags.static import static
from typing import Union
from core.models import ArchiveResult
register = template.Library()
@register.simple_tag
def snapshot_image(snapshot):
result = ArchiveResult.objects.filter(snapshot=snapshot, extractor='screenshot', status='succeeded').first()
if result:
return reverse('Snapshot', args=[f'{str(snapshot.timestamp)}/{result.output}'])
return static('archive.png')
@register.filter(name='split')
def split(value, separator: str=','):
return (value or '').split(separator)
@register.filter
def file_size(num_bytes: Union[int, float]) -> str:
+46 -32
View File
@@ -2,10 +2,11 @@ from django.contrib import admin
from django.urls import path, include
from django.views import static
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.conf import settings
from django.views.generic.base import RedirectView
from core.views import HomepageView, SnapshotView, PublicIndexView, AddView
from core.views import HomepageView, SnapshotView, PublicIndexView, AddView, HealthCheckView
# print('DEBUG', settings.DEBUG)
@@ -13,8 +14,8 @@ from core.views import HomepageView, SnapshotView, PublicIndexView, AddView
urlpatterns = [
path('public/', PublicIndexView.as_view(), name='public-index'),
path('robots.txt', static.serve, {'document_root': settings.OUTPUT_DIR, 'path': 'robots.txt'}),
path('favicon.ico', static.serve, {'document_root': settings.OUTPUT_DIR, 'path': 'favicon.ico'}),
path('robots.txt', static.serve, {'document_root': settings.STATICFILES_DIRS[0], 'path': 'robots.txt'}),
path('favicon.ico', static.serve, {'document_root': settings.STATICFILES_DIRS[0], 'path': 'favicon.ico'}),
path('docs/', RedirectView.as_view(url='https://github.com/ArchiveBox/ArchiveBox/wiki'), name='Docs'),
@@ -23,47 +24,60 @@ urlpatterns = [
path('admin/core/snapshot/add/', RedirectView.as_view(url='/add/')),
path('add/', AddView.as_view(), name='add'),
path('accounts/login/', RedirectView.as_view(url='/admin/login/')),
path('accounts/logout/', RedirectView.as_view(url='/admin/logout/')),
path('accounts/', include('django.contrib.auth.urls')),
path('admin/', admin.site.urls),
path('health/', HealthCheckView.as_view(), name='healthcheck'),
path('error/', lambda _: 1/0),
# path('jet_api/', include('jet_django.urls')), Enable to use https://www.jetadmin.io/integrations/django
path('index.html', RedirectView.as_view(url='/')),
path('index.json', static.serve, {'document_root': settings.OUTPUT_DIR, 'path': 'index.json'}),
path('', HomepageView.as_view(), name='Home'),
]
urlpatterns += staticfiles_urlpatterns()
# # Proposed UI URLs spec
# path('', HomepageView)
# path('/add', AddView)
# path('/public', PublicIndexView)
# path('/snapshot/:slug', SnapshotView)
# path('/admin', admin.site.urls)
# path('/accounts', django.contrib.auth.urls)
if settings.DEBUG_TOOLBAR:
import debug_toolbar
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
# # Prposed REST API spec
# # :slugs can be uuid, short_uuid, or any of the unique index_fields
# path('api/v1/'),
# path('api/v1/core/' [GET])
# path('api/v1/core/snapshot/', [GET, POST, PUT]),
# path('api/v1/core/snapshot/:slug', [GET, PATCH, DELETE]),
# path('api/v1/core/archiveresult', [GET, POST, PUT]),
# path('api/v1/core/archiveresult/:slug', [GET, PATCH, DELETE]),
# path('api/v1/core/tag/', [GET, POST, PUT]),
# path('api/v1/core/tag/:slug', [GET, PATCH, DELETE]),
# path('api/v1/cli/', [GET])
# path('api/v1/cli/{add,list,config,...}', [POST]), # pass query as kwargs directly to `run_subcommand` and return stdout, stderr, exitcode
# # Proposed FUTURE URLs spec
# path('', HomepageView)
# path('/add', AddView)
# path('/public', PublicIndexView)
# path('/snapshot/:slug', SnapshotView)
# path('api/v1/extractors/', [GET])
# path('api/v1/extractors/:extractor/', [GET]),
# path('api/v1/extractors/:extractor/:func', [GET, POST]), # pass query as args directly to chosen function
# path('/admin', admin.site.urls)
# path('/accounts', django.contrib.auth.urls)
# future, just an idea:
# path('api/v1/scheduler/', [GET])
# path('api/v1/scheduler/task/', [GET, POST, PUT]),
# path('api/v1/scheduler/task/:slug', [GET, PATCH, DELETE]),
# # Prposed REST API spec
# # :slugs can be uuid, short_uuid, or any of the unique index_fields
# path('api/v1/'),
# path('api/v1/core/' [GET])
# path('api/v1/core/snapshot/', [GET, POST, PUT]),
# path('api/v1/core/snapshot/:slug', [GET, PATCH, DELETE]),
# path('api/v1/core/archiveresult', [GET, POST, PUT]),
# path('api/v1/core/archiveresult/:slug', [GET, PATCH, DELETE]),
# path('api/v1/core/tag/', [GET, POST, PUT]),
# path('api/v1/core/tag/:slug', [GET, PATCH, DELETE]),
# path('api/v1/cli/', [GET])
# path('api/v1/cli/{add,list,config,...}', [POST]), # pass query as kwargs directly to `run_subcommand` and return stdout, stderr, exitcode
# path('api/v1/extractors/', [GET])
# path('api/v1/extractors/:extractor/', [GET]),
# path('api/v1/extractors/:extractor/:func', [GET, POST]), # pass query as args directly to chosen function
# future, just an idea:
# path('api/v1/scheduler/', [GET])
# path('api/v1/scheduler/task/', [GET, POST, PUT]),
# path('api/v1/scheduler/task/:slug', [GET, PATCH, DELETE]),
+187 -49
View File
@@ -4,13 +4,15 @@ from io import StringIO
from contextlib import redirect_stdout
from django.shortcuts import render, redirect
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.utils.html import format_html, mark_safe
from django.views import View, static
from django.views.generic.list import ListView
from django.views.generic import FormView
from django.db.models import Q
from django.contrib.auth.mixins import UserPassesTestMixin
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from core.models import Snapshot
from core.forms import AddLinkForm
@@ -22,10 +24,11 @@ from ..config import (
PUBLIC_ADD_VIEW,
VERSION,
FOOTER_INFO,
SNAPSHOTS_PER_PAGE,
)
from main import add
from ..main import add
from ..util import base_url, ansi_to_html
from ..index.html import snapshot_icons
from ..search import query_search_index
class HomepageView(View):
@@ -35,7 +38,7 @@ class HomepageView(View):
if PUBLIC_INDEX:
return redirect('/public')
return redirect(f'/admin/login/?next={request.path}')
@@ -43,10 +46,6 @@ class SnapshotView(View):
# render static html index from filesystem archive/<timestamp>/index.html
def get(self, request, path):
# missing trailing slash -> redirect to index
if '/' not in path:
return redirect(f'{path}/index.html')
if not request.user.is_authenticated and not PUBLIC_SNAPSHOTS:
return redirect(f'/admin/login/?next={request.path}')
@@ -55,47 +54,164 @@ class SnapshotView(View):
except (IndexError, ValueError):
slug, archivefile = path.split('/', 1)[0], 'index.html'
all_pages = list(Snapshot.objects.all())
# slug is a timestamp
by_ts = {page.timestamp: page for page in all_pages}
try:
# print('SERVING STATICFILE', by_ts[slug].link_dir, request.path, path)
response = static.serve(request, archivefile, document_root=by_ts[slug].link_dir, show_indexes=True)
response["Link"] = f'<{by_ts[slug].url}>; rel="canonical"'
return response
except KeyError:
pass
if slug.replace('.','').isdigit():
# slug is a hash
by_hash = {page.url_hash: page for page in all_pages}
try:
timestamp = by_hash[slug].timestamp
return redirect(f'/archive/{timestamp}/{archivefile}')
except KeyError:
pass
# 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))
response = static.serve(request, archivefile, document_root=snapshot.link_dir, show_indexes=True)
response["Link"] = f'<{snapshot.url}>; rel="canonical"'
return response
except Snapshot.DoesNotExist:
if Snapshot.objects.filter(timestamp__startswith=slug).exists():
raise Snapshot.MultipleObjectsReturned
else:
raise
except Snapshot.DoesNotExist:
# Snapshot does not exist
return HttpResponse(
format_html(
(
'<center><br/><br/><br/>'
'No Snapshot directories match the given timestamp or UUID: <code>{}</code><br/><br/>'
'You can <a href="/add/" target="_top">add a new Snapshot</a>, or return to the <a href="/" target="_top">Main Index</a>'
'</center>'
),
slug,
path,
),
content_type="text/html",
status=404,
)
except Snapshot.MultipleObjectsReturned:
snapshot_hrefs = mark_safe('<br/>').join(
format_html(
'{} <a href="/archive/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
snap.added.strftime('%Y-%m-%d %H:%M:%S'),
snap.timestamp,
snap.timestamp,
snap.url,
snap.title or '',
)
for snap in Snapshot.objects.filter(timestamp__startswith=slug).only('url', 'timestamp', 'title', 'added').order_by('-added')
)
return HttpResponse(
format_html(
(
'Multiple Snapshots match the given timestamp/UUID <code>{}</code><br/><pre>'
),
slug,
) + snapshot_hrefs + format_html(
(
'</pre><br/>'
'Choose a Snapshot to proceed or go back to the <a href="/" target="_top">Main Index</a>'
)
),
content_type="text/html",
status=404,
)
except Http404:
# Snapshot dir exists but file within does not e.g. 124235.324234/screenshot.png
return HttpResponse(
format_html(
(
'<center><br/><br/><br/>'
f'Snapshot <a href="/archive/{snapshot.timestamp}/index.html" target="_top"><b><code>[{snapshot.timestamp}]</code></b></a> exists in DB, but resource <b><code>{snapshot.timestamp}/'
'{}'
f'</code></b> does not exist in <a href="/archive/{snapshot.timestamp}/" target="_top">snapshot dir</a> yet.<br/><br/>'
'Maybe this resource type is not availabe for this Snapshot,<br/>or the archiving process has not completed yet?<br/>'
f'<pre><code># run this cmd to finish archiving this Snapshot<br/>archivebox update -t timestamp {snapshot.timestamp}</code></pre><br/><br/>'
'<div class="text-align: left; width: 100%; max-width: 400px">'
'<i><b>Next steps:</i></b><br/>'
f'- list all the <a href="/archive/{snapshot.timestamp}/" target="_top">Snapshot files <code>.*</code></a><br/>'
f'- view the <a href="/archive/{snapshot.timestamp}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>'
f'- go to the <a href="/admin/core/snapshot/{snapshot.id}/change/" target="_top">Snapshot admin</a> to edit<br/>'
f'- go to the <a href="/admin/core/snapshot/?id__startswith={snapshot.id}" target="_top">Snapshot actions</a> to re-archive<br/>'
'- or return to <a href="/" target="_top">the main index...</a></div>'
'</center>'
),
archivefile,
),
content_type="text/html",
status=404,
)
# slug is a URL
by_url = {page.base_url: page for page in all_pages}
try:
# TODO: add multiple snapshot support by showing index of all snapshots
# for given url instead of redirecting to timestamp index
timestamp = by_url[base_url(path)].timestamp
return redirect(f'/archive/{timestamp}/index.html')
except KeyError:
pass
try:
# try exact match on full url first
snapshot = Snapshot.objects.get(
Q(url='http://' + path) | Q(url='https://' + path) | Q(id__startswith=path)
)
except Snapshot.DoesNotExist:
# fall back to match on exact base_url
try:
snapshot = Snapshot.objects.get(
Q(url='http://' + base_url(path)) | Q(url='https://' + base_url(path))
)
except Snapshot.DoesNotExist:
# fall back to matching base_url as prefix
snapshot = Snapshot.objects.get(
Q(url__startswith='http://' + base_url(path)) | Q(url__startswith='https://' + base_url(path))
)
return redirect(f'/archive/{snapshot.timestamp}/index.html')
except Snapshot.DoesNotExist:
return HttpResponse(
format_html(
(
'<center><br/><br/><br/>'
'No Snapshots match the given url: <code>{}</code><br/><br/><br/>'
'Return to the <a href="/" target="_top">Main Index</a>, or:<br/><br/>'
'+ <i><a href="/add/?url={}" target="_top">Add a new Snapshot for <code>{}</code></a><br/><br/></i>'
'</center>'
),
base_url(path),
path if '://' in path else f'https://{path}',
path,
),
content_type="text/html",
status=404,
)
except Snapshot.MultipleObjectsReturned:
snapshot_hrefs = mark_safe('<br/>').join(
format_html(
'{} <a href="/archive/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
snap.added.strftime('%Y-%m-%d %H:%M:%S'),
snap.timestamp,
snap.timestamp,
snap.url,
snap.title or '',
)
for snap in Snapshot.objects.filter(
Q(url__startswith='http://' + base_url(path)) | Q(url__startswith='https://' + base_url(path))
).only('url', 'timestamp', 'title', 'added').order_by('-added')
)
return HttpResponse(
format_html(
(
'Multiple Snapshots match the given URL <code>{}</code><br/><pre>'
),
base_url(path),
) + snapshot_hrefs + format_html(
(
'</pre><br/>'
'Choose a Snapshot to proceed or go back to the <a href="/" target="_top">Main Index</a>'
)
),
content_type="text/html",
status=404,
)
return HttpResponse(
'No archived link matches the given timestamp or hash.',
content_type="text/plain",
status=404,
)
class PublicIndexView(ListView):
template_name = 'public_index.html'
model = Snapshot
paginate_by = 100
ordering = ['title']
paginate_by = SNAPSHOTS_PER_PAGE
ordering = ['-added']
def get_context_data(self, **kwargs):
return {
@@ -104,13 +220,15 @@ class PublicIndexView(ListView):
'FOOTER_INFO': FOOTER_INFO,
}
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
query = self.request.GET.get('q')
if query:
if query and query.strip():
qs = qs.filter(Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query) | Q(tags__name__icontains=query))
for snapshot in qs:
snapshot.icons = snapshot_icons(snapshot)
try:
qs = qs | query_search_index(query)
except Exception as err:
print(f'[!] Error while using search backend: {err.__class__.__name__} {err}')
return qs
def get(self, *args, **kwargs):
@@ -120,7 +238,7 @@ class PublicIndexView(ListView):
else:
return redirect(f'/admin/login/?next={self.request.path}')
@method_decorator(csrf_exempt, name='dispatch')
class AddView(UserPassesTestMixin, FormView):
template_name = "add.html"
form_class = AddLinkForm
@@ -130,9 +248,9 @@ class AddView(UserPassesTestMixin, FormView):
if self.request.method == 'GET':
url = self.request.GET.get('url', None)
if url:
return {'url': url}
else:
return super().get_initial()
return {'url': url if '://' in url else f'https://{url}'}
return super().get_initial()
def test_func(self):
return PUBLIC_ADD_VIEW or self.request.user.is_authenticated
@@ -145,16 +263,21 @@ class AddView(UserPassesTestMixin, FormView):
'absolute_add_path': self.request.build_absolute_uri(self.request.path),
'VERSION': VERSION,
'FOOTER_INFO': FOOTER_INFO,
'stdout': '',
}
def form_valid(self, form):
url = form.cleaned_data["url"]
print(f'[+] Adding URL: {url}')
parser = form.cleaned_data["parser"]
tag = form.cleaned_data["tag"]
depth = 0 if form.cleaned_data["depth"] == "0" else 1
extractors = ','.join(form.cleaned_data["archive_methods"])
input_kwargs = {
"urls": url,
"tag": tag,
"depth": depth,
"parser": parser,
"update_all": False,
"out_dir": OUTPUT_DIR,
}
@@ -172,3 +295,18 @@ class AddView(UserPassesTestMixin, FormView):
"form": AddLinkForm()
})
return render(template_name=self.template_name, request=self.request, context=context)
class HealthCheckView(View):
"""
A Django view that renders plain text "OK" for service discovery tools
"""
def get(self, request):
"""
Handle a GET request
"""
return HttpResponse(
'OK',
content_type='text/plain',
status=200
)
+3 -3
View File
@@ -7,10 +7,10 @@ For more information on this file, see
https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
"""
import os
from archivebox.config import setup_django
setup_django(in_memory_db=False, check_db=True)
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.settings')
application = get_wsgi_application()
+70 -18
View File
@@ -1,13 +1,19 @@
__package__ = 'archivebox.extractors'
import os
import sys
from pathlib import Path
from typing import Optional, List, Iterable, Union
from datetime import datetime
from typing import Callable, Optional, List, Iterable, Union
from datetime import datetime, timezone
from django.db.models import QuerySet
from ..index.schema import Link
from ..config import (
SAVE_ALLOWLIST_PTN,
SAVE_DENYLIST_PTN,
)
from ..core.settings import ERROR_LOG
from ..index.schema import ArchiveResult, Link
from ..index.sql import write_link_to_sql_index
from ..index import (
load_link_details,
@@ -40,31 +46,52 @@ from .archive_org import should_save_archive_dot_org, save_archive_dot_org
from .headers import should_save_headers, save_headers
def get_default_archive_methods():
ShouldSaveFunction = Callable[[Link, Optional[Path], Optional[bool]], bool]
SaveFunction = Callable[[Link, Optional[Path], int], ArchiveResult]
ArchiveMethodEntry = tuple[str, ShouldSaveFunction, SaveFunction]
def get_default_archive_methods() -> List[ArchiveMethodEntry]:
return [
('title', should_save_title, save_title),
('favicon', should_save_favicon, save_favicon),
('wget', should_save_wget, save_wget),
('headers', should_save_headers, save_headers),
('singlefile', should_save_singlefile, save_singlefile),
('pdf', should_save_pdf, save_pdf),
('screenshot', should_save_screenshot, save_screenshot),
('dom', should_save_dom, save_dom),
('readability', should_save_readability, save_readability), #keep readability below wget and singlefile, as it depends on them
('wget', should_save_wget, save_wget),
('title', should_save_title, save_title), # keep title and readability below wget and singlefile, as it depends on them
('readability', should_save_readability, save_readability),
('mercury', should_save_mercury, save_mercury),
('git', should_save_git, save_git),
('media', should_save_media, save_media),
('headers', should_save_headers, save_headers),
('archive_org', should_save_archive_dot_org, save_archive_dot_org),
]
@enforce_types
def get_archive_methods_for_link(link: Link) -> Iterable[ArchiveMethodEntry]:
DEFAULT_METHODS = get_default_archive_methods()
allowed_methods = {
m for pat, methods in
SAVE_ALLOWLIST_PTN.items()
if pat.search(link.url)
for m in methods
} or { m[0] for m in DEFAULT_METHODS }
denied_methods = {
m for pat, methods in
SAVE_DENYLIST_PTN.items()
if pat.search(link.url)
for m in methods
}
allowed_methods -= denied_methods
return (m for m in DEFAULT_METHODS if m[0] in allowed_methods)
ARCHIVE_METHODS_INDEXING_PRECEDENCE = [('readability', 1), ('singlefile', 2), ('dom', 3), ('wget', 4)]
@enforce_types
def ignore_methods(to_ignore: List[str]):
def ignore_methods(to_ignore: List[str]) -> Iterable[str]:
ARCHIVE_METHODS = get_default_archive_methods()
methods = filter(lambda x: x[0] not in to_ignore, ARCHIVE_METHODS)
methods = map(lambda x: x[0], methods)
return list(methods)
return [x[0] for x in ARCHIVE_METHODS if x[0] not in to_ignore]
@enforce_types
def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[str]]=None, out_dir: Optional[Path]=None) -> Link:
@@ -77,11 +104,11 @@ def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[s
except Snapshot.DoesNotExist:
snapshot = write_link_to_sql_index(link)
ARCHIVE_METHODS = get_default_archive_methods()
active_methods = get_archive_methods_for_link(link)
if methods:
ARCHIVE_METHODS = [
method for method in ARCHIVE_METHODS
active_methods = [
method for method in active_methods
if method[0] in methods
]
@@ -94,10 +121,11 @@ def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[s
link = load_link_details(link, out_dir=out_dir)
write_link_details(link, out_dir=out_dir, skip_sql_index=False)
log_link_archiving_started(link, out_dir, is_new)
link = link.overwrite(updated=datetime.now())
link = link.overwrite(updated=datetime.now(timezone.utc))
stats = {'skipped': 0, 'succeeded': 0, 'failed': 0}
start_ts = datetime.now(timezone.utc)
for method_name, should_run, method_function in ARCHIVE_METHODS:
for method_name, should_run, method_function in active_methods:
try:
if method_name not in link.history:
link.history[method_name] = []
@@ -115,14 +143,38 @@ def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[s
ArchiveResult.objects.create(snapshot=snapshot, extractor=method_name, cmd=result.cmd, cmd_version=result.cmd_version,
output=result.output, pwd=result.pwd, start_ts=result.start_ts, end_ts=result.end_ts, status=result.status)
# bump the updated time on the main Snapshot here, this is critical
# to be able to cache summaries of the ArchiveResults for a given
# snapshot without having to load all the results from the DB each time.
# (we use {Snapshot.id}-{Snapshot.updated} as the cache key and assume
# ArchiveResults are unchanged as long as the updated timestamp is unchanged)
snapshot.save()
else:
# print('{black} X {}{reset}'.format(method_name, **ANSI))
stats['skipped'] += 1
except Exception as e:
# Disabled until https://github.com/ArchiveBox/ArchiveBox/issues/984
# and https://github.com/ArchiveBox/ArchiveBox/issues/1014
# are fixed.
"""
raise Exception('Exception in archive_methods.save_{}(Link(url={}))'.format(
method_name,
link.url,
)) from e
"""
# Instead, use the kludgy workaround from
# https://github.com/ArchiveBox/ArchiveBox/issues/984#issuecomment-1150541627
with open(ERROR_LOG, "a", encoding='utf-8') as f:
command = ' '.join(sys.argv)
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d__%H:%M:%S')
f.write(("\n" + 'Exception in archive_methods.save_{}(Link(url={})) command={}; ts={}'.format(
method_name,
link.url,
command,
ts
) + "\n"))
#f.write(f"\n> {command}; ts={ts} version={config['VERSION']} docker={config['IN_DOCKER']} is_tty={config['IS_TTY']}\n")
# print(' ', stats)
@@ -135,7 +187,7 @@ def archive_link(link: Link, overwrite: bool=False, methods: Optional[Iterable[s
write_link_details(link, out_dir=out_dir, skip_sql_index=False)
log_link_archiving_finished(link, link.link_dir, is_new, stats)
log_link_archiving_finished(link, out_dir, is_new, stats, start_ts)
except KeyboardInterrupt:
try:
+1 -1
View File
@@ -31,7 +31,7 @@ def should_save_archive_dot_org(link: Link, out_dir: Optional[Path]=None, overwr
out_dir = out_dir or Path(link.link_dir)
if not overwrite and (out_dir / 'archive.org.txt').exists():
# if open(path, 'r').read().strip() != 'None':
# if open(path, 'r', encoding='utf-8').read().strip() != 'None':
return False
return SAVE_ARCHIVE_DOT_ORG
+5 -2
View File
@@ -9,6 +9,7 @@ from ..util import (
enforce_types,
is_static_file,
chrome_args,
chrome_cleanup,
)
from ..config import (
TIMEOUT,
@@ -26,7 +27,8 @@ def should_save_dom(link: Link, out_dir: Optional[Path]=None, overwrite: Optiona
out_dir = out_dir or Path(link.link_dir)
if not overwrite and (out_dir / 'output.html').exists():
return False
if (out_dir / 'output.html').stat().st_size > 1:
return False
return SAVE_DOM
@@ -38,7 +40,7 @@ def save_dom(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) ->
output: ArchiveOutput = 'output.html'
output_path = out_dir / output
cmd = [
*chrome_args(TIMEOUT=timeout),
*chrome_args(),
'--dump-dom',
link.url
]
@@ -56,6 +58,7 @@ def save_dom(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) ->
except Exception as err:
status = 'failed'
output = err
chrome_cleanup()
finally:
timer.end()
+2 -1
View File
@@ -10,6 +10,7 @@ from ..util import enforce_types, domain
from ..config import (
TIMEOUT,
SAVE_FAVICON,
FAVICON_PROVIDER,
CURL_BINARY,
CURL_ARGS,
CURL_VERSION,
@@ -40,7 +41,7 @@ def save_favicon(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT)
'--output', str(output),
*(['--user-agent', '{}'.format(CURL_USER_AGENT)] if CURL_USER_AGENT else []),
*([] if CHECK_SSL_VALIDITY else ['--insecure']),
'https://www.google.com/s2/favicons?domain={}'.format(domain(link.url)),
FAVICON_PROVIDER.format(domain(link.url)),
]
status = 'failed'
timer = TimedProgress(timeout, prefix=' ')
+26 -2
View File
@@ -33,7 +33,7 @@ def should_save_media(link: Link, out_dir: Optional[Path]=None, overwrite: Optio
@enforce_types
def save_media(link: Link, out_dir: Optional[Path]=None, timeout: int=MEDIA_TIMEOUT) -> ArchiveResult:
"""Download playlists or individual video, audio, and subtitles using youtube-dl"""
"""Download playlists or individual video, audio, and subtitles using youtube-dl or yt-dlp"""
out_dir = out_dir or Path(link.link_dir)
output: ArchiveOutput = 'media'
@@ -43,6 +43,7 @@ def save_media(link: Link, out_dir: Optional[Path]=None, timeout: int=MEDIA_TIME
YOUTUBEDL_BINARY,
*YOUTUBEDL_ARGS,
*([] if CHECK_SSL_VALIDITY else ['--no-check-certificate']),
# TODO: add --cookies-from-browser={CHROME_USER_DATA_DIR}
link.url,
]
status = 'succeeded'
@@ -60,7 +61,7 @@ def save_media(link: Link, out_dir: Optional[Path]=None, timeout: int=MEDIA_TIME
pass
else:
hints = (
'Got youtube-dl response code: {}.'.format(result.returncode),
'Got youtube-dl (or yt-dlp) response code: {}.'.format(result.returncode),
*result.stderr.decode().split('\n'),
)
raise ArchiveError('Failed to save media', hints)
@@ -70,11 +71,34 @@ def save_media(link: Link, out_dir: Optional[Path]=None, timeout: int=MEDIA_TIME
finally:
timer.end()
# add video description and subtitles to full-text index
# Let's try a few different
index_texts = [
# errors:
# * 'strict' to raise a ValueError exception if there is an
# encoding error. The default value of None has the same effect.
# * 'ignore' ignores errors. Note that ignoring encoding errors
# can lead to data loss.
# * 'xmlcharrefreplace' is only supported when writing to a
# file. Characters not supported by the encoding are replaced with
# the appropriate XML character reference &#nnn;.
# There are a few more options described in https://docs.python.org/3/library/functions.html#open
text_file.read_text(encoding='utf-8', errors='xmlcharrefreplace').strip()
for text_file in (
*output_path.glob('*.description'),
*output_path.glob('*.srt'),
*output_path.glob('*.vtt'),
*output_path.glob('*.lrc'),
*output_path.glob('*.lrc'),
)
]
return ArchiveResult(
cmd=cmd,
pwd=str(out_dir),
cmd_version=YOUTUBEDL_VERSION,
output=output,
status=status,
index_texts=index_texts,
**timer.stats,
)
+11 -3
View File
@@ -54,11 +54,13 @@ def save_mercury(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT)
out_dir = Path(out_dir or link.link_dir)
output_folder = out_dir.absolute() / "mercury"
output = str(output_folder)
output = "mercury"
status = 'succeeded'
timer = TimedProgress(timeout, prefix=' ')
try:
output_folder.mkdir(exist_ok=True)
# Get plain text version of article
cmd = [
DEPENDENCIES['MERCURY_BINARY']['path'],
@@ -71,6 +73,11 @@ def save_mercury(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT)
except json.JSONDecodeError:
raise ShellError(cmd, result)
if article_text.get('failed'):
raise ArchiveError('Mercury was not able to get article text from the URL')
atomic_write(str(output_folder / "content.txt"), article_text["content"])
# Get HTML version of article
cmd = [
DEPENDENCIES['MERCURY_BINARY']['path'],
@@ -82,9 +89,10 @@ def save_mercury(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT)
except json.JSONDecodeError:
raise ShellError(cmd, result)
output_folder.mkdir(exist_ok=True)
if article_text.get('failed'):
raise ArchiveError('Mercury was not able to get article HTML from the URL')
atomic_write(str(output_folder / "content.html"), article_json.pop("content"))
atomic_write(str(output_folder / "content.txt"), article_text["content"])
atomic_write(str(output_folder / "article.json"), article_json)
# Check for common failure cases
+3 -1
View File
@@ -9,6 +9,7 @@ from ..util import (
enforce_types,
is_static_file,
chrome_args,
chrome_cleanup,
)
from ..config import (
TIMEOUT,
@@ -37,7 +38,7 @@ def save_pdf(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) ->
out_dir = out_dir or Path(link.link_dir)
output: ArchiveOutput = 'output.pdf'
cmd = [
*chrome_args(TIMEOUT=timeout),
*chrome_args(),
'--print-to-pdf',
link.url,
]
@@ -54,6 +55,7 @@ def save_pdf(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) ->
except Exception as err:
status = 'failed'
output = err
chrome_cleanup()
finally:
timer.end()
+16 -28
View File
@@ -10,9 +10,7 @@ from ..index.schema import Link, ArchiveResult, ArchiveError
from ..system import run, atomic_write
from ..util import (
enforce_types,
download_url,
is_static_file,
)
from ..config import (
TIMEOUT,
@@ -22,28 +20,8 @@ from ..config import (
READABILITY_VERSION,
)
from ..logging_util import TimedProgress
from .title import get_html
@enforce_types
def get_html(link: Link, path: Path) -> str:
"""
Try to find wget, singlefile and then dom files.
If none is found, download the url again.
"""
canonical = link.canonical_outputs()
abs_path = path.absolute()
sources = [canonical["singlefile_path"], canonical["wget_path"], canonical["dom_path"]]
document = None
for source in sources:
try:
with open(abs_path / source, "r") as f:
document = f.read()
break
except (FileNotFoundError, TypeError):
continue
if document is None:
return download_url(link.url)
else:
return document
@enforce_types
def should_save_readability(link: Link, out_dir: Optional[str]=None, overwrite: Optional[bool]=False) -> bool:
@@ -63,7 +41,7 @@ def save_readability(link: Link, out_dir: Optional[str]=None, timeout: int=TIMEO
out_dir = Path(out_dir or link.link_dir)
output_folder = out_dir.absolute() / "readability"
output = str(output_folder)
output = "readability"
# Readability Docs: https://github.com/mozilla/readability
@@ -81,13 +59,22 @@ def save_readability(link: Link, out_dir: Optional[str]=None, timeout: int=TIMEO
temp_doc.write(document.encode("utf-8"))
temp_doc.close()
if not document or len(document) < 10:
raise ArchiveError('Readability could not find HTML to parse for article text')
cmd = [
DEPENDENCIES['READABILITY_BINARY']['path'],
temp_doc.name
temp_doc.name,
link.url,
]
result = run(cmd, cwd=out_dir, timeout=timeout)
result_json = json.loads(result.stdout)
try:
result_json = json.loads(result.stdout)
assert result_json and 'content' in result_json, 'Readability output is not valid JSON'
except json.JSONDecodeError:
raise ArchiveError('Readability was not able to archive the page', result.stdout + result.stderr)
output_folder.mkdir(exist_ok=True)
readability_content = result_json.pop("textContent")
atomic_write(str(output_folder / "content.html"), result_json.pop("content"))
@@ -98,7 +85,7 @@ def save_readability(link: Link, out_dir: Optional[str]=None, timeout: int=TIMEO
# "Downloaded: 76 files, 4.0M in 1.6s (2.52 MB/s)"
output_tail = [
line.strip()
for line in (result.stdout + result.stderr).decode().rsplit('\n', 3)[-3:]
for line in (result.stdout + result.stderr).decode().rsplit('\n', 5)[-5:]
if line.strip()
]
hints = (
@@ -112,6 +99,7 @@ def save_readability(link: Link, out_dir: Optional[str]=None, timeout: int=TIMEO
except (Exception, OSError) as err:
status = 'failed'
output = err
cmd = [cmd[0], './{singlefile,dom}.html']
finally:
timer.end()
@@ -121,6 +109,6 @@ def save_readability(link: Link, out_dir: Optional[str]=None, timeout: int=TIMEO
cmd_version=READABILITY_VERSION,
output=output,
status=status,
index_texts= [readability_content] if readability_content else [],
index_texts=[readability_content] if readability_content else [],
**timer.stats,
)
+3 -1
View File
@@ -9,6 +9,7 @@ from ..util import (
enforce_types,
is_static_file,
chrome_args,
chrome_cleanup,
)
from ..config import (
TIMEOUT,
@@ -37,7 +38,7 @@ def save_screenshot(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEO
out_dir = out_dir or Path(link.link_dir)
output: ArchiveOutput = 'screenshot.png'
cmd = [
*chrome_args(TIMEOUT=timeout),
*chrome_args(),
'--screenshot',
link.url,
]
@@ -54,6 +55,7 @@ def save_screenshot(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEO
except Exception as err:
status = 'failed'
output = err
chrome_cleanup()
finally:
timer.end()
+25 -3
View File
@@ -17,6 +17,7 @@ from ..config import (
SAVE_SINGLEFILE,
DEPENDENCIES,
SINGLEFILE_VERSION,
SINGLEFILE_ARGS,
CHROME_BINARY,
)
from ..logging_util import TimedProgress
@@ -41,14 +42,35 @@ def save_singlefile(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEO
out_dir = out_dir or Path(link.link_dir)
output = "singlefile.html"
browser_args = chrome_args(TIMEOUT=0)
browser_args = chrome_args(CHROME_TIMEOUT=0)
# SingleFile CLI Docs: https://github.com/gildas-lormeau/SingleFile/tree/master/cli
browser_args = '--browser-args={}'.format(json.dumps(browser_args[1:]))
cmd = [
DEPENDENCIES['SINGLEFILE_BINARY']['path'],
options = [
*SINGLEFILE_ARGS,
'--browser-executable-path={}'.format(CHROME_BINARY),
browser_args,
]
# Deduplicate options (single-file doesn't like when you use the same option two times)
#
# NOTE: Options names that come first clobber conflicting names that come later
# My logic is SINGLEFILE_ARGS is the option that affects the singlefile command with most
# specificity, therefore the user sets it with a lot intent, therefore it should take precedence
# kind of like the ergonomic principle of lexical scope in programming languages.
seen_option_names = []
def test_seen(argument):
option_name = argument.split("=")[0]
if option_name in seen_option_names:
return False
else:
seen_option_names.append(option_name)
return True
deduped_options = list(filter(test_seen, options))
cmd = [
DEPENDENCIES['SINGLEFILE_BINARY']['path'],
*deduped_options,
link.url,
output,
]
+23 -2
View File
@@ -26,7 +26,7 @@ from ..logging_util import TimedProgress
HTML_TITLE_REGEX = re.compile(
r'<title.*?>' # start matching text after <title> tag
r'(.[^<>]+)', # get everything up to these symbols
r'([^<>]+)', # get everything up to these symbols
re.IGNORECASE | re.MULTILINE | re.DOTALL | re.UNICODE,
)
@@ -58,6 +58,27 @@ class TitleParser(HTMLParser):
if tag.lower() == "title":
self.inside_title_tag = False
@enforce_types
def get_html(link: Link, path: Path, timeout: int=TIMEOUT) -> str:
"""
Try to find wget, singlefile and then dom files.
If none is found, download the url again.
"""
canonical = link.canonical_outputs()
abs_path = path.absolute()
sources = [canonical["singlefile_path"], canonical["wget_path"], canonical["dom_path"]]
document = None
for source in sources:
try:
with open(abs_path / source, "r", encoding="utf-8") as f:
document = f.read()
break
except (FileNotFoundError, TypeError):
continue
if document is None:
return download_url(link.url, timeout=timeout)
else:
return document
@enforce_types
def should_save_title(link: Link, out_dir: Optional[str]=None, overwrite: Optional[bool]=False) -> bool:
@@ -90,7 +111,7 @@ def save_title(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) -
status = 'succeeded'
timer = TimedProgress(timeout, prefix=' ')
try:
html = download_url(link.url, timeout=timeout)
html = get_html(link, out_dir, timeout=timeout)
try:
# try using relatively strict html parser first
parser = TitleParser()
+2 -2
View File
@@ -4,7 +4,7 @@ import re
from pathlib import Path
from typing import Optional
from datetime import datetime
from datetime import datetime, timezone
from ..index.schema import Link, ArchiveResult, ArchiveOutput, ArchiveError
from ..system import run, chmod_file
@@ -51,7 +51,7 @@ def save_wget(link: Link, out_dir: Optional[Path]=None, timeout: int=TIMEOUT) ->
if SAVE_WARC:
warc_dir = out_dir / "warc"
warc_dir.mkdir(exist_ok=True)
warc_path = warc_dir / str(int(datetime.now().timestamp()))
warc_path = warc_dir / str(int(datetime.now(timezone.utc).timestamp()))
# WGET CLI Docs: https://www.gnu.org/software/wget/manual/wget.html
output: ArchiveOutput = None
+8 -4
View File
@@ -22,7 +22,8 @@ from ..config import (
JSON_INDEX_FILENAME,
OUTPUT_DIR,
TIMEOUT,
URL_BLACKLIST_PTN,
URL_DENYLIST_PTN,
URL_ALLOWLIST_PTN,
stderr,
OUTPUT_PERMISSIONS
)
@@ -141,7 +142,9 @@ def archivable_links(links: Iterable[Link]) -> Iterable[Link]:
continue
if scheme(link.url) not in ('http', 'https', 'ftp'):
continue
if URL_BLACKLIST_PTN and URL_BLACKLIST_PTN.search(link.url):
if URL_DENYLIST_PTN and URL_DENYLIST_PTN.search(link.url):
continue
if URL_ALLOWLIST_PTN and (not URL_ALLOWLIST_PTN.search(link.url)):
continue
yield link
@@ -265,14 +268,14 @@ def load_main_index_meta(out_dir: Path=OUTPUT_DIR) -> Optional[dict]:
@enforce_types
def parse_links_from_source(source_path: str, root_url: Optional[str]=None) -> Tuple[List[Link], List[Link]]:
def parse_links_from_source(source_path: str, root_url: Optional[str]=None, parser: str="auto") -> Tuple[List[Link], List[Link]]:
from ..parsers import parse_links
new_links: List[Link] = []
# parse and validate the import file
raw_links, parser_name = parse_links(source_path, root_url=root_url)
raw_links, parser_name = parse_links(source_path, root_url=root_url, parser=parser)
new_links = validate_links(raw_links)
if parser_name:
@@ -356,6 +359,7 @@ LINK_FILTERS = {
'regex': lambda pattern: Q(url__iregex=pattern),
'domain': lambda pattern: Q(url__istartswith=f"http://{pattern}") | Q(url__istartswith=f"https://{pattern}") | Q(url__istartswith=f"ftp://{pattern}"),
'tag': lambda pattern: Q(tags__name=pattern),
'timestamp': lambda pattern: Q(timestamp=pattern),
}
@enforce_types
+79 -70
View File
@@ -1,18 +1,19 @@
__package__ = 'archivebox.index'
from datetime import datetime
from typing import List, Optional, Iterator, Mapping
from pathlib import Path
from datetime import datetime, timezone
from collections import defaultdict
from typing import List, Optional, Iterator, Mapping
from django.utils.html import format_html, mark_safe
from collections import defaultdict
from django.core.cache import cache
from .schema import Link
from ..system import atomic_write
from ..logging_util import printable_filesize
from ..util import (
enforce_types,
ts_to_date,
ts_to_date_str,
urlencode,
htmlencode,
urldecode,
@@ -20,10 +21,10 @@ from ..util import (
from ..config import (
OUTPUT_DIR,
VERSION,
GIT_SHA,
FOOTER_INFO,
HTML_INDEX_FILENAME,
SAVE_ARCHIVE_DOT_ORG,
PREVIEW_ORIGINALS,
)
MAIN_INDEX_TEMPLATE = 'static_index.html'
@@ -60,10 +61,10 @@ def main_index_template(links: List[Link], template: str=MAIN_INDEX_TEMPLATE) ->
return render_django_template(template, {
'version': VERSION,
'git_sha': GIT_SHA,
'git_sha': VERSION, # not used anymore, but kept for backwards compatibility
'num_links': str(len(links)),
'date_updated': datetime.now().strftime('%Y-%m-%d'),
'time_updated': datetime.now().strftime('%Y-%m-%d %H:%M'),
'date_updated': datetime.now(timezone.utc).strftime('%Y-%m-%d'),
'time_updated': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M'),
'links': [link._asdict(extended=True) for link in links],
'FOOTER_INFO': FOOTER_INFO,
})
@@ -103,8 +104,9 @@ def link_details_template(link: Link) -> str:
'size': printable_filesize(link.archive_size) if link.archive_size else 'pending',
'status': 'archived' if link.is_archived else 'not yet archived',
'status_color': 'success' if link.is_archived else 'danger',
'oldest_archive_date': ts_to_date(link.oldest_archive_date),
'oldest_archive_date': ts_to_date_str(link.oldest_archive_date),
'SAVE_ARCHIVE_DOT_ORG': SAVE_ARCHIVE_DOT_ORG,
'PREVIEW_ORIGINALS': PREVIEW_ORIGINALS,
})
@enforce_types
@@ -116,71 +118,78 @@ def render_django_template(template: str, context: Mapping[str, str]) -> str:
def snapshot_icons(snapshot) -> str:
from core.models import EXTRACTORS
cache_key = f'{snapshot.id}-{(snapshot.updated or snapshot.added).timestamp()}-snapshot-icons'
def calc_snapshot_icons():
from core.models import EXTRACTORS
# start = datetime.now(timezone.utc)
# start = datetime.now()
archive_results = snapshot.archiveresult_set.filter(status="succeeded", output__isnull=False)
link = snapshot.as_link()
path = link.archive_path
canon = link.canonical_outputs()
output = ""
output_template = '<a href="/{}/{}" class="exists-{}" title="{}">{}</a> &nbsp;'
icons = {
"singlefile": "❶",
"wget": "🆆",
"dom": "🅷",
"pdf": "📄",
"screenshot": "💻",
"media": "📼",
"git": "🅶",
"archive_org": "🏛",
"readability": "🆁",
"mercury": "🅼",
"warc": "📦"
}
exclude = ["favicon", "title", "headers", "archive_org"]
# Missing specific entry for WARC
archive_results = snapshot.archiveresult_set.filter(status="succeeded")
link = snapshot.as_link()
path = link.archive_path
canon = link.canonical_outputs()
output = ""
output_template = '<a href="/{}/{}" class="exists-{}" title="{}">{}</a> &nbsp;'
icons = {
"singlefile": "❶",
"wget": "🆆",
"dom": "🅷",
"pdf": "📄",
"screenshot": "💻",
"media": "📼",
"git": "🅶",
"archive_org": "🏛",
"readability": "🆁",
"mercury": "🅼",
"warc": "📦"
}
exclude = ["favicon", "title", "headers", "archive_org"]
# Missing specific entry for WARC
extractor_outputs = defaultdict(lambda: None)
for extractor, _ in EXTRACTORS:
for result in archive_results:
if result.extractor == extractor and result:
extractor_outputs[extractor] = result
extractor_outputs = defaultdict(lambda: None)
for extractor, _ in EXTRACTORS:
for result in archive_results:
if result.extractor == extractor and result:
extractor_outputs[extractor] = result
for extractor, _ in EXTRACTORS:
if extractor not in exclude:
existing = extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# Check filesystsem to see if anything is actually present (too slow, needs optimization/caching)
# if existing:
# existing = (Path(path) / existing)
# if existing.is_file():
# existing = True
# elif existing.is_dir():
# existing = any(existing.glob('*.*'))
output += format_html(output_template, path, canon[f"{extractor}_path"], str(bool(existing)),
extractor, icons.get(extractor, "?"))
if extractor == "wget":
# warc isn't technically it's own extractor, so we have to add it after wget
# get from db (faster but less thurthful)
exists = extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# get from filesystem (slower but more accurate)
# exists = list((Path(path) / canon["warc_path"]).glob("*.warc.gz"))
output += format_html(output_template, path, canon["warc_path"], str(bool(exists)), "warc", icons.get("warc", "?"))
for extractor, _ in EXTRACTORS:
if extractor not in exclude:
existing = extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# Check filesystsem to see if anything is actually present (too slow, needs optimization/caching)
# if existing:
# existing = (Path(path) / existing)
# if existing.is_file():
# existing = True
# elif existing.is_dir():
# existing = any(existing.glob('*.*'))
output += format_html(output_template, path, canon[f"{extractor}_path"], str(bool(existing)),
extractor, icons.get(extractor, "?"))
if extractor == "wget":
# warc isn't technically it's own extractor, so we have to add it after wget
# get from db (faster but less thurthful)
exists = extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# get from filesystem (slower but more accurate)
# exists = list((Path(path) / canon["warc_path"]).glob("*.warc.gz"))
output += format_html(output_template, 'warc/', canon["warc_path"], str(bool(exists)), "warc", icons.get("warc", "?"))
if extractor == "archive_org":
# The check for archive_org is different, so it has to be handled separately
if extractor == "archive_org":
# The check for archive_org is different, so it has to be handled separately
# get from db (faster)
exists = extractor in extractor_outputs and extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# get from filesystem (slower)
# target_path = Path(path) / "archive.org.txt"
# exists = target_path.exists()
output += '<a href="{}" class="exists-{}" title="{}">{}</a> '.format(canon["archive_org_path"], str(exists),
"archive_org", icons.get("archive_org", "?"))
# get from db (faster)
exists = extractor_outputs[extractor] and extractor_outputs[extractor].status == 'succeeded' and extractor_outputs[extractor].output
# get from filesystem (slower)
# target_path = Path(path) / "archive.org.txt"
# exists = target_path.exists()
output += '<a href="{}" class="exists-{}" title="{}">{}</a> '.format(canon["archive_org_path"], str(exists),
"archive_org", icons.get("archive_org", "?"))
result = format_html('<span class="files-icons" style="font-size: 1.1em; opacity: 0.8; min-width: 240px; display: inline-block">{}<span>', mark_safe(output))
# end = datetime.now(timezone.utc)
# print(((end - start).total_seconds()*1000) // 1, 'ms')
return result
result = format_html('<span class="files-icons" style="font-size: 1.1em; opacity: 0.8; min-width: 240px; display: inline-block">{}<span>', mark_safe(output))
# end = datetime.now()
# print(((end - start).total_seconds()*1000) // 1, 'ms')
return result
return cache.get_or_set(cache_key, calc_snapshot_icons)
# return calc_snapshot_icons()
+15 -5
View File
@@ -5,7 +5,7 @@ import sys
import json as pyjson
from pathlib import Path
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional, Iterator, Any, Union
from .schema import Link
@@ -15,7 +15,6 @@ from ..config import (
VERSION,
OUTPUT_DIR,
FOOTER_INFO,
GIT_SHA,
DEPENDENCIES,
JSON_INDEX_FILENAME,
ARCHIVE_DIR_NAME,
@@ -30,7 +29,7 @@ MAIN_INDEX_HEADER = {
'meta': {
'project': 'ArchiveBox',
'version': VERSION,
'git_sha': GIT_SHA,
'git_sha': VERSION, # not used anymore, but kept for backwards compatibility
'website': 'https://ArchiveBox.io',
'docs': 'https://github.com/ArchiveBox/ArchiveBox/wiki',
'source': 'https://github.com/ArchiveBox/ArchiveBox',
@@ -45,7 +44,7 @@ def generate_json_index_from_links(links: List[Link], with_headers: bool):
output = {
**MAIN_INDEX_HEADER,
'num_links': len(links),
'updated': datetime.now(),
'updated': datetime.now(timezone.utc),
'last_run_cmd': sys.argv,
'links': links,
}
@@ -61,7 +60,18 @@ def parse_json_main_index(out_dir: Path=OUTPUT_DIR) -> Iterator[Link]:
index_path = Path(out_dir) / JSON_INDEX_FILENAME
if index_path.exists():
with open(index_path, 'r', encoding='utf-8') as f:
links = pyjson.load(f)['links']
try:
links = pyjson.load(f)['links']
if links:
Link.from_json(links[0])
except Exception as err:
print(" {lightyellow}! Found an index.json in the project root but couldn't load links from it: {} {}".format(
err.__class__.__name__,
err,
**ANSI,
))
return ()
for link_json in links:
try:
yield Link.from_json(link_json)
+22 -16
View File
@@ -10,16 +10,17 @@ __package__ = 'archivebox.index'
from pathlib import Path
from datetime import datetime, timedelta
from datetime import datetime, timezone, timedelta
from typing import List, Dict, Any, Optional, Union
from dataclasses import dataclass, asdict, field, fields
from django.utils.functional import cached_property
from ..system import get_dir_size
from ..config import OUTPUT_DIR, ARCHIVE_DIR_NAME
from ..util import ts_to_date_str, parse_date
from ..config import OUTPUT_DIR, ARCHIVE_DIR_NAME, FAVICON_PROVIDER
class ArchiveError(Exception):
def __init__(self, message, hints=None):
@@ -55,11 +56,11 @@ class ArchiveResult:
assert isinstance(self.end_ts, datetime)
assert isinstance(self.cmd, list)
assert all(isinstance(arg, str) and arg for arg in self.cmd)
assert self.pwd is None or isinstance(self.pwd, str) and self.pwd
assert self.cmd_version is None or isinstance(self.cmd_version, str) and self.cmd_version
# TODO: replace emptystrings in these three with None / remove them from the DB
assert self.pwd is None or isinstance(self.pwd, str)
assert self.cmd_version is None or isinstance(self.cmd_version, str)
assert self.output is None or isinstance(self.output, (str, Exception))
if isinstance(self.output, str):
assert self.output
@classmethod
def guess_ts(_cls, dict_info):
@@ -133,7 +134,6 @@ class Link:
updated: Optional[datetime] = None
schema: str = 'Link'
def __str__(self) -> str:
return f'[{self.timestamp}] {self.url} "{self.title}"'
@@ -190,6 +190,7 @@ class Link:
}
if extended:
info.update({
'snapshot_id': self.snapshot_id,
'link_dir': self.link_dir,
'archive_path': self.archive_path,
@@ -201,6 +202,9 @@ class Link:
'basename': self.basename,
'extension': self.extension,
'is_static': self.is_static,
'tags_str': (self.tags or '').strip(','), # only used to render static index in index/html.py, remove if no longer needed there
'icons': None, # only used to render static index in index/html.py, remove if no longer needed there
'bookmarked_date': self.bookmarked_date,
'updated_date': self.updated_date,
@@ -255,6 +259,11 @@ class Link:
return to_csv(self, cols=cols or self.field_names(), separator=separator, ljust=ljust)
@cached_property
def snapshot_id(self):
from core.models import Snapshot
return str(Snapshot.objects.only('id').get(url=self.url).id)
@classmethod
def field_names(cls):
return [f.name for f in fields(cls)]
@@ -316,13 +325,11 @@ class Link:
### Pretty Printing Helpers
@property
def bookmarked_date(self) -> Optional[str]:
from ..util import ts_to_date
max_ts = (datetime.now() + timedelta(days=30)).timestamp()
max_ts = (datetime.now(timezone.utc) + timedelta(days=30)).timestamp()
if self.timestamp and self.timestamp.replace('.', '').isdigit():
if 0 < float(self.timestamp) < max_ts:
return ts_to_date(datetime.fromtimestamp(float(self.timestamp)))
return ts_to_date_str(datetime.fromtimestamp(float(self.timestamp)))
else:
return str(self.timestamp)
return None
@@ -330,13 +337,12 @@ class Link:
@property
def updated_date(self) -> Optional[str]:
from ..util import ts_to_date
return ts_to_date(self.updated) if self.updated else None
return ts_to_date_str(self.updated) if self.updated else None
@property
def archive_dates(self) -> List[datetime]:
return [
result.start_ts
parse_date(result.start_ts)
for method in self.history.keys()
for result in self.history[method]
]
@@ -417,7 +423,7 @@ class Link:
canonical = {
'index_path': 'index.html',
'favicon_path': 'favicon.ico',
'google_favicon_path': 'https://www.google.com/s2/favicons?domain={}'.format(self.domain),
'google_favicon_path': FAVICON_PROVIDER.format(self.domain),
'wget_path': wget_output_path(self),
'warc_path': 'warc/',
'singlefile_path': 'singlefile.html',
+72 -26
View File
@@ -1,5 +1,7 @@
__package__ = 'archivebox.index'
import re
from io import StringIO
from pathlib import Path
from typing import List, Tuple, Iterator
@@ -7,8 +9,11 @@ from django.db.models import QuerySet
from django.db import transaction
from .schema import Link
from ..util import enforce_types
from ..config import OUTPUT_DIR
from ..util import enforce_types, parse_date
from ..config import (
OUTPUT_DIR,
TAG_SEPARATOR_PATTERN,
)
### Main Links Index
@@ -23,17 +28,21 @@ def parse_sql_main_index(out_dir: Path=OUTPUT_DIR) -> Iterator[Link]:
)
@enforce_types
def remove_from_sql_main_index(snapshots: QuerySet, out_dir: Path=OUTPUT_DIR) -> None:
with transaction.atomic():
snapshots.delete()
def remove_from_sql_main_index(snapshots: QuerySet, atomic: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
if atomic:
with transaction.atomic():
return snapshots.delete()
return snapshots.delete()
@enforce_types
def write_link_to_sql_index(link: Link):
from core.models import Snapshot
from core.models import Snapshot, ArchiveResult
info = {k: v for k, v in link._asdict().items() if k in Snapshot.keys}
tags = info.pop("tags")
if tags is None:
tags = []
tag_list = list(dict.fromkeys(
tag.strip() for tag in re.split(TAG_SEPARATOR_PATTERN, link.tags or '')
))
info.pop('tags')
try:
info["timestamp"] = Snapshot.objects.get(url=link.url).timestamp
@@ -41,36 +50,73 @@ def write_link_to_sql_index(link: Link):
while Snapshot.objects.filter(timestamp=info["timestamp"]).exists():
info["timestamp"] = str(float(info["timestamp"]) + 1.0)
snapshot, _ = Snapshot.objects.update_or_create(url=link.url, defaults=info)
snapshot.save_tags(tags)
snapshot, _ = Snapshot.objects.update_or_create(url=link.url, defaults=info)
snapshot.save_tags(tag_list)
for extractor, entries in link.history.items():
for entry in entries:
if isinstance(entry, dict):
result, _ = ArchiveResult.objects.get_or_create(
snapshot_id=snapshot.id,
extractor=extractor,
start_ts=parse_date(entry['start_ts']),
defaults={
'end_ts': parse_date(entry['end_ts']),
'cmd': entry['cmd'],
'output': entry['output'],
'cmd_version': entry.get('cmd_version') or 'unknown',
'pwd': entry['pwd'],
'status': entry['status'],
}
)
else:
result, _ = ArchiveResult.objects.update_or_create(
snapshot_id=snapshot.id,
extractor=extractor,
start_ts=parse_date(entry.start_ts),
defaults={
'end_ts': parse_date(entry.end_ts),
'cmd': entry.cmd,
'output': entry.output,
'cmd_version': entry.cmd_version or 'unknown',
'pwd': entry.pwd,
'status': entry.status,
}
)
return snapshot
@enforce_types
def write_sql_main_index(links: List[Link], out_dir: Path=OUTPUT_DIR) -> None:
with transaction.atomic():
for link in links:
write_link_to_sql_index(link)
for link in links:
# with transaction.atomic():
# write_link_to_sql_index(link)
write_link_to_sql_index(link)
@enforce_types
def write_sql_link_details(link: Link, out_dir: Path=OUTPUT_DIR) -> None:
from core.models import Snapshot
with transaction.atomic():
try:
snap = Snapshot.objects.get(url=link.url)
except Snapshot.DoesNotExist:
snap = write_link_to_sql_index(link)
snap.title = link.title
# with transaction.atomic():
# try:
# snap = Snapshot.objects.get(url=link.url)
# except Snapshot.DoesNotExist:
# snap = write_link_to_sql_index(link)
# snap.title = link.title
try:
snap = Snapshot.objects.get(url=link.url)
except Snapshot.DoesNotExist:
snap = write_link_to_sql_index(link)
snap.title = link.title
tag_set = (
set(tag.strip() for tag in (link.tags or '').split(','))
)
tag_list = list(tag_set) or []
tag_list = list(dict.fromkeys(
tag.strip() for tag in re.split(TAG_SEPARATOR_PATTERN, link.tags or '')
))
snap.save()
snap.save_tags(tag_list)
snap.save()
snap.save_tags(tag_list)
+135 -49
View File
@@ -3,26 +3,30 @@ __package__ = 'archivebox'
import re
import os
import sys
import stat
import time
import argparse
from math import log
from multiprocessing import Process
from pathlib import Path
from datetime import datetime
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Optional, List, Dict, Union, IO, TYPE_CHECKING
from typing import Any, Optional, List, Dict, Union, IO, TYPE_CHECKING
if TYPE_CHECKING:
from .index.schema import Link, ArchiveResult
from .system import get_dir_size
from .util import enforce_types
from .config import (
ConfigDict,
OUTPUT_DIR,
PYTHON_ENCODING,
VERSION,
ANSI,
IS_TTY,
IN_DOCKER,
TERM_WIDTH,
SHOW_PROGRESS,
SOURCES_DIR_NAME,
@@ -50,6 +54,37 @@ class RuntimeStats:
_LAST_RUN_STATS = RuntimeStats()
def debug_dict_summary(obj: Dict[Any, Any]) -> None:
stderr(' '.join(f'{key}={str(val).ljust(6)}' for key, val in obj.items()))
def get_fd_info(fd) -> Dict[str, Any]:
NAME = fd.name[1:-1]
FILENO = fd.fileno()
MODE = os.fstat(FILENO).st_mode
IS_TTY = hasattr(fd, 'isatty') and fd.isatty()
IS_PIPE = stat.S_ISFIFO(MODE)
IS_FILE = stat.S_ISREG(MODE)
IS_TERMINAL = not (IS_PIPE or IS_FILE)
IS_LINE_BUFFERED = fd.line_buffering
IS_READABLE = fd.readable()
return {
'NAME': NAME, 'FILENO': FILENO, 'MODE': MODE,
'IS_TTY': IS_TTY, 'IS_PIPE': IS_PIPE, 'IS_FILE': IS_FILE,
'IS_TERMINAL': IS_TERMINAL, 'IS_LINE_BUFFERED': IS_LINE_BUFFERED,
'IS_READABLE': IS_READABLE,
}
# # Log debug information about stdin, stdout, and stderr
# sys.stdout.write('[>&1] this is python stdout\n')
# sys.stderr.write('[>&2] this is python stderr\n')
# debug_dict_summary(get_fd_info(sys.stdin))
# debug_dict_summary(get_fd_info(sys.stdout))
# debug_dict_summary(get_fd_info(sys.stderr))
class SmartFormatter(argparse.HelpFormatter):
"""Patched formatter that prints newlines in argparse help strings"""
@@ -62,22 +97,40 @@ class SmartFormatter(argparse.HelpFormatter):
def reject_stdin(caller: str, stdin: Optional[IO]=sys.stdin) -> None:
"""Tell the user they passed stdin to a command that doesn't accept it"""
if stdin and not stdin.isatty():
stdin_raw_text = stdin.read().strip()
if stdin_raw_text:
stderr(f'[X] The "{caller}" command does not accept stdin.', color='red')
if not stdin:
return None
if IN_DOCKER:
# when TTY is disabled in docker we cant tell if stdin is being piped in or not
# if we try to read stdin when its not piped we will hang indefinitely waiting for it
return None
if not stdin.isatty():
# stderr('READING STDIN TO REJECT...')
stdin_raw_text = stdin.read()
if stdin_raw_text.strip():
# stderr('GOT STDIN!', len(stdin_str))
stderr(f'[!] The "{caller}" command does not accept stdin (ignoring).', color='red')
stderr(f' Run archivebox "{caller} --help" to see usage and examples.')
stderr()
raise SystemExit(1)
# raise SystemExit(1)
return None
def accept_stdin(stdin: Optional[IO]=sys.stdin) -> Optional[str]:
"""accept any standard input and return it as a string or None"""
if not stdin:
return None
elif stdin and not stdin.isatty():
stdin_str = stdin.read().strip()
return stdin_str or None
if not stdin.isatty():
# stderr('READING STDIN TO ACCEPT...')
stdin_str = stdin.read()
if stdin_str:
# stderr('GOT STDIN...', len(stdin_str))
return stdin_str
return None
@@ -85,17 +138,19 @@ class TimedProgress:
"""Show a progress bar and measure elapsed time until .end() is called"""
def __init__(self, seconds, prefix=''):
self.SHOW_PROGRESS = SHOW_PROGRESS
if self.SHOW_PROGRESS:
self.p = Process(target=progress_bar, args=(seconds, prefix))
self.p.start()
self.stats = {'start_ts': datetime.now(), 'end_ts': None}
self.stats = {'start_ts': datetime.now(timezone.utc), 'end_ts': None}
def end(self):
"""immediately end progress, clear the progressbar line, and save end_ts"""
end_ts = datetime.now()
end_ts = datetime.now(timezone.utc)
self.stats['end_ts'] = end_ts
if self.SHOW_PROGRESS:
@@ -104,7 +159,10 @@ class TimedProgress:
# kill the progress bar subprocess
try:
self.p.close() # must be closed *before* its terminnated
except:
except (KeyboardInterrupt, SystemExit):
print()
raise
except BaseException: # lgtm [py/catch-base-exception]
pass
self.p.terminate()
self.p.join()
@@ -170,14 +228,12 @@ def progress_bar(seconds: int, prefix: str='') -> None:
# sys.stdout.flush()
except (KeyboardInterrupt, BrokenPipeError):
print()
pass
def log_cli_command(subcommand: str, subcommand_args: List[str], stdin: Optional[str], pwd: str):
from .config import VERSION, ANSI
cmd = ' '.join(('archivebox', subcommand, *subcommand_args))
stderr('{black}[i] [{now}] ArchiveBox v{VERSION}: {cmd}{reset}'.format(
now=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
now=datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
VERSION=VERSION,
cmd=cmd,
**ANSI,
@@ -189,7 +245,7 @@ def log_cli_command(subcommand: str, subcommand_args: List[str], stdin: Optional
def log_importing_started(urls: Union[str, List[str]], depth: int, index_only: bool):
_LAST_RUN_STATS.parse_start_ts = datetime.now()
_LAST_RUN_STATS.parse_start_ts = datetime.now(timezone.utc)
print('{green}[+] [{}] Adding {} links to index (crawl depth={}){}...{reset}'.format(
_LAST_RUN_STATS.parse_start_ts.strftime('%Y-%m-%d %H:%M:%S'),
len(urls) if isinstance(urls, list) else len(urls.split('\n')),
@@ -202,7 +258,7 @@ def log_source_saved(source_file: str):
print(' > Saved verbatim input to {}/{}'.format(SOURCES_DIR_NAME, source_file.rsplit('/', 1)[-1]))
def log_parsing_finished(num_parsed: int, parser_name: str):
_LAST_RUN_STATS.parse_end_ts = datetime.now()
_LAST_RUN_STATS.parse_end_ts = datetime.now(timezone.utc)
print(' > Parsed {} URLs from input ({})'.format(num_parsed, parser_name))
def log_deduping_finished(num_new_links: int):
@@ -216,7 +272,7 @@ def log_crawl_started(new_links):
### Indexing Stage
def log_indexing_process_started(num_links: int):
start_ts = datetime.now()
start_ts = datetime.now(timezone.utc)
_LAST_RUN_STATS.index_start_ts = start_ts
print()
print('{black}[*] [{}] Writing {} links to main index...{reset}'.format(
@@ -227,23 +283,24 @@ def log_indexing_process_started(num_links: int):
def log_indexing_process_finished():
end_ts = datetime.now()
end_ts = datetime.now(timezone.utc)
_LAST_RUN_STATS.index_end_ts = end_ts
def log_indexing_started(out_path: str):
if IS_TTY:
sys.stdout.write(f' > {out_path}')
sys.stdout.write(f' > ./{Path(out_path).relative_to(OUTPUT_DIR)}')
def log_indexing_finished(out_path: str):
print(f'\r √ {out_path}')
print(f'\r √ ./{Path(out_path).relative_to(OUTPUT_DIR)}')
### Archiving Stage
def log_archiving_started(num_links: int, resume: Optional[float]=None):
start_ts = datetime.now()
start_ts = datetime.now(timezone.utc)
_LAST_RUN_STATS.archiving_start_ts = start_ts
print()
if resume:
@@ -261,7 +318,8 @@ def log_archiving_started(num_links: int, resume: Optional[float]=None):
))
def log_archiving_paused(num_links: int, idx: int, timestamp: str):
end_ts = datetime.now()
end_ts = datetime.now(timezone.utc)
_LAST_RUN_STATS.archiving_end_ts = end_ts
print()
print('\n{lightyellow}[X] [{now}] Downloading paused on link {timestamp} ({idx}/{total}){reset}'.format(
@@ -272,13 +330,14 @@ def log_archiving_paused(num_links: int, idx: int, timestamp: str):
total=num_links,
))
print()
print(' {lightred}Hint:{reset} To view your archive index, run:'.format(**ANSI))
print(' archivebox server # then visit http://127.0.0.1:8000')
print(' Continue archiving where you left off by running:')
print(' archivebox update --resume={}'.format(timestamp))
def log_archiving_finished(num_links: int):
end_ts = datetime.now()
from core.models import Snapshot
end_ts = datetime.now(timezone.utc)
_LAST_RUN_STATS.archiving_end_ts = end_ts
assert _LAST_RUN_STATS.archiving_start_ts is not None
seconds = end_ts.timestamp() - _LAST_RUN_STATS.archiving_start_ts.timestamp()
@@ -298,12 +357,15 @@ def log_archiving_finished(num_links: int):
print(' - {} links skipped'.format(_LAST_RUN_STATS.skipped))
print(' - {} links updated'.format(_LAST_RUN_STATS.succeeded + _LAST_RUN_STATS.failed))
print(' - {} links had errors'.format(_LAST_RUN_STATS.failed))
print()
print(' {lightred}Hint:{reset} To manage your archive in a Web UI, run:'.format(**ANSI))
print(' archivebox server 0.0.0.0:8000')
if Snapshot.objects.count() < 50:
print()
print(' {lightred}Hint:{reset} To manage your archive in a Web UI, run:'.format(**ANSI))
print(' archivebox server 0.0.0.0:8000')
def log_link_archiving_started(link: "Link", link_dir: str, is_new: bool):
# [*] [2019-03-22 13:46:45] "Log Structured Merge Trees - ben stopford"
# http://www.benstopford.com/2015/02/14/log-structured-merge-trees/
# > output/archive/1478739709
@@ -311,7 +373,7 @@ def log_link_archiving_started(link: "Link", link_dir: str, is_new: bool):
print('\n[{symbol_color}{symbol}{reset}] [{symbol_color}{now}{reset}] "{title}"'.format(
symbol_color=ANSI['green' if is_new else 'black'],
symbol='+' if is_new else '√',
now=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
now=datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'),
title=link.title or link.base_url,
**ANSI,
))
@@ -321,7 +383,7 @@ def log_link_archiving_started(link: "Link", link_dir: str, is_new: bool):
pretty_path(link_dir),
))
def log_link_archiving_finished(link: "Link", link_dir: str, is_new: bool, stats: dict):
def log_link_archiving_finished(link: "Link", link_dir: str, is_new: bool, stats: dict, start_ts: datetime):
total = sum(stats.values())
if stats['failed'] > 0 :
@@ -331,6 +393,11 @@ def log_link_archiving_finished(link: "Link", link_dir: str, is_new: bool, stats
else:
_LAST_RUN_STATS.succeeded += 1
size = get_dir_size(link_dir)
end_ts = datetime.now(timezone.utc)
duration = str(end_ts - start_ts).split('.')[0]
print(' {black}{} files ({}) in {}s {reset}'.format(size[2], printable_filesize(size[0]), duration, **ANSI))
def log_archive_method_started(method: str):
print(' > {}'.format(method))
@@ -365,10 +432,16 @@ def log_archive_method_finished(result: "ArchiveResult"):
# Prettify error output hints string and limit to five lines
hints = getattr(result.output, 'hints', None) or ()
if hints:
hints = hints if isinstance(hints, (list, tuple)) else hints.split('\n')
if isinstance(hints, (list, tuple, type(_ for _ in ()))):
hints = [hint.decode() for hint in hints if isinstance(hint, bytes)]
else:
if isinstance(hints, bytes):
hints = hints.decode()
hints = hints.split('\n')
hints = (
' {}{}{}'.format(ANSI['lightyellow'], line.strip(), ANSI['reset'])
for line in hints[:5] if line.strip()
for line in list(hints)[:5] if line.strip()
)
@@ -460,11 +533,27 @@ def log_shell_welcome_msg():
### Helpers
@enforce_types
def pretty_path(path: Union[Path, str]) -> str:
def pretty_path(path: Union[Path, str], pwd: Union[Path, str]=OUTPUT_DIR) -> str:
"""convert paths like .../ArchiveBox/archivebox/../output/abc into output/abc"""
pwd = Path('.').resolve()
# parent = os.path.abspath(os.path.join(pwd, os.path.pardir))
return str(path).replace(str(pwd) + '/', './')
pwd = str(Path(pwd)) # .resolve()
path = str(path)
if not path:
return path
# replace long absolute paths with ./ relative ones to save on terminal output width
if path.startswith(pwd) and (pwd != '/'):
path = path.replace(pwd, '.', 1)
# quote paths containing spaces
if ' ' in path:
path = f'"{path}"'
# if path is just a plain dot, replace it back with the absolute path for clarity
if path == '.':
path = pwd
return path
@enforce_types
@@ -499,12 +588,13 @@ def printable_config(config: ConfigDict, prefix: str='') -> str:
def printable_folder_status(name: str, folder: Dict) -> str:
if folder['enabled']:
if folder['is_valid']:
color, symbol, note = 'green', '√', 'valid'
color, symbol, note, num_files = 'green', '√', 'valid', ''
else:
color, symbol, note, num_files = 'red', 'X', 'invalid', '?'
else:
color, symbol, note, num_files = 'lightyellow', '-', 'disabled', '-'
if folder['path']:
if Path(folder['path']).exists():
num_files = (
@@ -514,14 +604,12 @@ def printable_folder_status(name: str, folder: Dict) -> str:
)
else:
num_files = 'missing'
if folder.get('is_mount'):
# add symbol @ next to filecount if path is a remote filesystem mount
num_files = f'{num_files} @' if num_files else '@'
path = str(folder['path']).replace(str(OUTPUT_DIR), '.') if folder['path'] else ''
if path and ' ' in path:
path = f'"{path}"'
# if path is just a plain dot, replace it back with the full path for clarity
if path == '.':
path = str(OUTPUT_DIR)
path = pretty_path(folder['path'])
return ' '.join((
ANSI[color],
@@ -552,9 +640,7 @@ def printable_dependency_version(name: str, dependency: Dict) -> str:
else:
color, symbol, note, version = 'lightyellow', '-', 'disabled', '-'
path = str(dependency["path"]).replace(str(OUTPUT_DIR), '.') if dependency["path"] else ''
if path and ' ' in path:
path = f'"{path}"'
path = pretty_path(dependency['path'])
return ' '.join((
ANSI[color],
Regular → Executable
+368 -149
View File
@@ -4,8 +4,9 @@ import os
import sys
import shutil
import platform
from django.utils import timezone
from pathlib import Path
from datetime import date
from datetime import date, datetime
from typing import Dict, List, Optional, Iterable, IO, Union
from crontab import CronTab, CronSlices
@@ -27,6 +28,7 @@ from .parsers import (
from .index.schema import Link
from .util import enforce_types # type: ignore
from .system import get_dir_size, dedupe_cron_jobs, CRON_COMMENT
from .system import run as run_shell
from .index import (
load_main_index,
parse_links_from_source,
@@ -67,35 +69,54 @@ from .config import (
ConfigDict,
ANSI,
IS_TTY,
DEBUG,
IN_DOCKER,
IN_QEMU,
PUID,
PGID,
USER,
TIMEZONE,
ENFORCE_ATOMIC_WRITES,
OUTPUT_PERMISSIONS,
PYTHON_BINARY,
ARCHIVEBOX_BINARY,
ONLY_NEW,
OUTPUT_DIR,
SOURCES_DIR,
ARCHIVE_DIR,
LOGS_DIR,
PACKAGE_DIR,
CONFIG_FILE,
ARCHIVE_DIR_NAME,
SOURCES_DIR_NAME,
LOGS_DIR_NAME,
JSON_INDEX_FILENAME,
HTML_INDEX_FILENAME,
SQL_INDEX_FILENAME,
ROBOTS_TXT_FILENAME,
FAVICON_FILENAME,
ALLOWED_IN_OUTPUT_DIR,
SEARCH_BACKEND_ENGINE,
check_dependencies,
check_data_folder,
write_config_file,
VERSION,
COMMIT_HASH,
CODE_LOCATIONS,
EXTERNAL_LOCATIONS,
DATA_LOCATIONS,
DEPENDENCIES,
CHROME_BINARY,
CHROME_VERSION,
YOUTUBEDL_BINARY,
YOUTUBEDL_VERSION,
SINGLEFILE_VERSION,
READABILITY_VERSION,
MERCURY_VERSION,
NODE_VERSION,
load_all_config,
CONFIG,
USER_CONFIG,
ADMIN_USERNAME,
ADMIN_PASSWORD,
get_real_name,
setup_django,
)
from .logging_util import (
TERM_WIDTH,
@@ -115,25 +136,7 @@ from .logging_util import (
from .search import flush_search_index, index_links
ALLOWED_IN_OUTPUT_DIR = {
'lost+found',
'.DS_Store',
'.venv',
'venv',
'virtualenv',
'.virtualenv',
'node_modules',
'package-lock.json',
'static',
ARCHIVE_DIR_NAME,
SOURCES_DIR_NAME,
LOGS_DIR_NAME,
SQL_INDEX_FILENAME,
JSON_INDEX_FILENAME,
HTML_INDEX_FILENAME,
ROBOTS_TXT_FILENAME,
FAVICON_FILENAME,
}
@enforce_types
def help(out_dir: Path=OUTPUT_DIR) -> None:
@@ -210,34 +213,62 @@ def help(out_dir: Path=OUTPUT_DIR) -> None:
def version(quiet: bool=False,
out_dir: Path=OUTPUT_DIR) -> None:
"""Print the ArchiveBox version and dependency information"""
if quiet:
print(VERSION)
else:
print('ArchiveBox v{}'.format(VERSION))
print(VERSION)
if not quiet:
# 0.6.3
# ArchiveBox v0.6.3 Cpython Linux Linux-4.19.121-linuxkit-x86_64-with-glibc2.28 x86_64 (in Docker) (in TTY)
# DEBUG=False IN_DOCKER=True IN_QEMU=False IS_TTY=True TZ=UTC FS_ATOMIC=True FS_REMOTE=False FS_PERMS=644 FS_USER=501:20 SEARCH_BACKEND=ripgrep
p = platform.uname()
print(sys.implementation.name.title(), p.system, platform.platform(), p.machine, '(in Docker)' if IN_DOCKER else '(not in Docker)')
print(
'ArchiveBox v{}'.format(VERSION),
*((COMMIT_HASH[:7],) if COMMIT_HASH else ()),
sys.implementation.name.title(),
p.system,
platform.platform(),
p.machine,
)
OUTPUT_IS_REMOTE_FS = DATA_LOCATIONS['OUTPUT_DIR']['is_mount'] or DATA_LOCATIONS['ARCHIVE_DIR']['is_mount']
print(
f'DEBUG={DEBUG}',
f'IN_DOCKER={IN_DOCKER}',
f'IN_QEMU={IN_QEMU}',
f'IS_TTY={IS_TTY}',
f'TZ={TIMEZONE}',
#f'DB=django.db.backends.sqlite3 (({CONFIG["SQLITE_JOURNAL_MODE"]})', # add this if we have more useful info to show eventually
f'FS_ATOMIC={ENFORCE_ATOMIC_WRITES}',
f'FS_REMOTE={OUTPUT_IS_REMOTE_FS}',
f'FS_USER={PUID}:{PGID}',
f'FS_PERMS={OUTPUT_PERMISSIONS}',
f'SEARCH_BACKEND={SEARCH_BACKEND_ENGINE}',
)
print()
print('{white}[i] Dependency versions:{reset}'.format(**ANSI))
for name, dependency in DEPENDENCIES.items():
print(printable_dependency_version(name, dependency))
# add a newline between core dependencies and extractor dependencies for easier reading
if name == 'ARCHIVEBOX_BINARY':
print()
print()
print('{white}[i] Source-code locations:{reset}'.format(**ANSI))
for name, folder in CODE_LOCATIONS.items():
print(printable_folder_status(name, folder))
for name, path in CODE_LOCATIONS.items():
print(printable_folder_status(name, path))
print()
print('{white}[i] Secrets locations:{reset}'.format(**ANSI))
for name, folder in EXTERNAL_LOCATIONS.items():
print(printable_folder_status(name, folder))
for name, path in EXTERNAL_LOCATIONS.items():
print(printable_folder_status(name, path))
print()
if DATA_LOCATIONS['OUTPUT_DIR']['is_valid']:
print('{white}[i] Data locations:{reset}'.format(**ANSI))
for name, folder in DATA_LOCATIONS.items():
print(printable_folder_status(name, folder))
for name, path in DATA_LOCATIONS.items():
print(printable_folder_status(name, path))
else:
print()
print('{white}[i] Data locations:{reset}'.format(**ANSI))
@@ -261,26 +292,27 @@ def run(subcommand: str,
@enforce_types
def init(force: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
def init(force: bool=False, quick: bool=False, setup: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
"""Initialize a new ArchiveBox collection in the current directory"""
from core.models import Snapshot
Path(out_dir).mkdir(exist_ok=True)
out_dir.mkdir(exist_ok=True)
is_empty = not len(set(os.listdir(out_dir)) - ALLOWED_IN_OUTPUT_DIR)
if (Path(out_dir) / JSON_INDEX_FILENAME).exists():
if (out_dir / JSON_INDEX_FILENAME).exists():
stderr("[!] This folder contains a JSON index. It is deprecated, and will no longer be kept up to date automatically.", color="lightyellow")
stderr(" You can run `archivebox list --json --with-headers > index.json` to manually generate it.", color="lightyellow")
stderr(" You can run `archivebox list --json --with-headers > static_index.json` to manually generate it.", color="lightyellow")
existing_index = (Path(out_dir) / SQL_INDEX_FILENAME).exists()
existing_index = (out_dir / SQL_INDEX_FILENAME).exists()
if is_empty and not existing_index:
print('{green}[+] Initializing a new ArchiveBox collection in this folder...{reset}'.format(**ANSI))
print(f' {out_dir}')
print('{green}------------------------------------------------------------------{reset}'.format(**ANSI))
print('{green}[+] Initializing a new ArchiveBox v{} collection...{reset}'.format(VERSION, **ANSI))
print('{green}----------------------------------------------------------------------{reset}'.format(**ANSI))
elif existing_index:
print('{green}[*] Updating existing ArchiveBox collection in this folder...{reset}'.format(**ANSI))
print(f' {out_dir}')
print('{green}------------------------------------------------------------------{reset}'.format(**ANSI))
# TODO: properly detect and print the existing version in current index as well
print('{green}[^] Verifying and updating existing ArchiveBox collection to v{}...{reset}'.format(VERSION, **ANSI))
print('{green}----------------------------------------------------------------------{reset}'.format(**ANSI))
else:
if force:
stderr('[!] This folder appears to already have files in it, but no index.sqlite3 is present.', color='lightyellow')
@@ -301,30 +333,25 @@ def init(force: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
else:
print('\n{green}[+] Building archive folder structure...{reset}'.format(**ANSI))
print(f' + ./{ARCHIVE_DIR.relative_to(OUTPUT_DIR)}, ./{SOURCES_DIR.relative_to(OUTPUT_DIR)}, ./{LOGS_DIR.relative_to(OUTPUT_DIR)}...')
Path(SOURCES_DIR).mkdir(exist_ok=True)
print(f' √ {SOURCES_DIR}')
Path(ARCHIVE_DIR).mkdir(exist_ok=True)
print(f' √ {ARCHIVE_DIR}')
Path(LOGS_DIR).mkdir(exist_ok=True)
print(f' √ {LOGS_DIR}')
print(f' + ./{CONFIG_FILE.relative_to(OUTPUT_DIR)}...')
write_config_file({}, out_dir=out_dir)
print(f' √ {CONFIG_FILE}')
if (Path(out_dir) / SQL_INDEX_FILENAME).exists():
print('\n{green}[*] Verifying main SQL index and running migrations...{reset}'.format(**ANSI))
if (out_dir / SQL_INDEX_FILENAME).exists():
print('\n{green}[*] Verifying main SQL index and running any migrations needed...{reset}'.format(**ANSI))
else:
print('\n{green}[+] Building main SQL index and running migrations...{reset}'.format(**ANSI))
print('\n{green}[+] Building main SQL index and running initial migrations...{reset}'.format(**ANSI))
DATABASE_FILE = Path(out_dir) / SQL_INDEX_FILENAME
print(f' √ {DATABASE_FILE}')
print()
DATABASE_FILE = out_dir / SQL_INDEX_FILENAME
for migration_line in apply_migrations(out_dir):
print(f' {migration_line}')
assert DATABASE_FILE.exists()
print()
print(f' √ ./{DATABASE_FILE.relative_to(OUTPUT_DIR)}')
# from django.contrib.auth.models import User
# if IS_TTY and not User.objects.filter(is_superuser=True).exists():
@@ -332,7 +359,7 @@ def init(force: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
# call_command("createsuperuser", interactive=True)
print()
print('{green}[*] Collecting links from any existing indexes and archive folders...{reset}'.format(**ANSI))
print('{green}[*] Checking links from indexes and archive folders (safe to Ctrl+C)...{reset}'.format(**ANSI))
all_links = Snapshot.objects.none()
pending_links: Dict[str, Link] = {}
@@ -341,73 +368,95 @@ def init(force: bool=False, out_dir: Path=OUTPUT_DIR) -> None:
all_links = load_main_index(out_dir=out_dir, warn=False)
print(' √ Loaded {} links from existing main index.'.format(all_links.count()))
# Links in data folders that dont match their timestamp
fixed, cant_fix = fix_invalid_folder_locations(out_dir=out_dir)
if fixed:
print(' {lightyellow}√ Fixed {} data directory locations that didn\'t match their link timestamps.{reset}'.format(len(fixed), **ANSI))
if cant_fix:
print(' {lightyellow}! Could not fix {} data directory locations due to conflicts with existing folders.{reset}'.format(len(cant_fix), **ANSI))
if quick:
print(' > Skipping full snapshot directory check (quick mode)')
else:
try:
# Links in data folders that dont match their timestamp
fixed, cant_fix = fix_invalid_folder_locations(out_dir=out_dir)
if fixed:
print(' {lightyellow}√ Fixed {} data directory locations that didn\'t match their link timestamps.{reset}'.format(len(fixed), **ANSI))
if cant_fix:
print(' {lightyellow}! Could not fix {} data directory locations due to conflicts with existing folders.{reset}'.format(len(cant_fix), **ANSI))
# Links in JSON index but not in main index
orphaned_json_links = {
link.url: link
for link in parse_json_main_index(out_dir)
if not all_links.filter(url=link.url).exists()
}
if orphaned_json_links:
pending_links.update(orphaned_json_links)
print(' {lightyellow}√ Added {} orphaned links from existing JSON index...{reset}'.format(len(orphaned_json_links), **ANSI))
# Links in JSON index but not in main index
orphaned_json_links = {
link.url: link
for link in parse_json_main_index(out_dir)
if not all_links.filter(url=link.url).exists()
}
if orphaned_json_links:
pending_links.update(orphaned_json_links)
print(' {lightyellow}√ Added {} orphaned links from existing JSON index...{reset}'.format(len(orphaned_json_links), **ANSI))
# Links in data dir indexes but not in main index
orphaned_data_dir_links = {
link.url: link
for link in parse_json_links_details(out_dir)
if not all_links.filter(url=link.url).exists()
}
if orphaned_data_dir_links:
pending_links.update(orphaned_data_dir_links)
print(' {lightyellow}√ Added {} orphaned links from existing archive directories.{reset}'.format(len(orphaned_data_dir_links), **ANSI))
# Links in data dir indexes but not in main index
orphaned_data_dir_links = {
link.url: link
for link in parse_json_links_details(out_dir)
if not all_links.filter(url=link.url).exists()
}
if orphaned_data_dir_links:
pending_links.update(orphaned_data_dir_links)
print(' {lightyellow}√ Added {} orphaned links from existing archive directories.{reset}'.format(len(orphaned_data_dir_links), **ANSI))
# Links in invalid/duplicate data dirs
invalid_folders = {
folder: link
for folder, link in get_invalid_folders(all_links, out_dir=out_dir).items()
}
if invalid_folders:
print(' {lightyellow}! Skipped adding {} invalid link data directories.{reset}'.format(len(invalid_folders), **ANSI))
print(' X ' + '\n X '.join(f'{folder} {link}' for folder, link in invalid_folders.items()))
print()
print(' {lightred}Hint:{reset} For more information about the link data directories that were skipped, run:'.format(**ANSI))
print(' archivebox status')
print(' archivebox list --status=invalid')
# Links in invalid/duplicate data dirs
invalid_folders = {
folder: link
for folder, link in get_invalid_folders(all_links, out_dir=out_dir).items()
}
if invalid_folders:
print(' {lightyellow}! Skipped adding {} invalid link data directories.{reset}'.format(len(invalid_folders), **ANSI))
print(' X ' + '\n X '.join(f'./{Path(folder).relative_to(OUTPUT_DIR)} {link}' for folder, link in invalid_folders.items()))
print()
print(' {lightred}Hint:{reset} For more information about the link data directories that were skipped, run:'.format(**ANSI))
print(' archivebox status')
print(' archivebox list --status=invalid')
except (KeyboardInterrupt, SystemExit):
stderr()
stderr('[x] Stopped checking archive directories due to Ctrl-C/SIGTERM', color='red')
stderr(' Your archive data is safe, but you should re-run `archivebox init` to finish the process later.')
stderr()
stderr(' {lightred}Hint:{reset} In the future you can run a quick init without checking dirs like so:'.format(**ANSI))
stderr(' archivebox init --quick')
raise SystemExit(1)
write_main_index(list(pending_links.values()), out_dir=out_dir)
write_main_index(list(pending_links.values()), out_dir=out_dir)
print('\n{green}----------------------------------------------------------------------{reset}'.format(**ANSI))
from django.contrib.auth.models import User
if (ADMIN_USERNAME and ADMIN_PASSWORD) and not User.objects.filter(username=ADMIN_USERNAME).exists():
print('{green}[+] Found ADMIN_USERNAME and ADMIN_PASSWORD configuration options, creating new admin user.{reset}'.format(**ANSI))
User.objects.create_superuser(username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
print('\n{green}------------------------------------------------------------------{reset}'.format(**ANSI))
if existing_index:
print('{green}[√] Done. Verified and updated the existing ArchiveBox collection.{reset}'.format(**ANSI))
else:
print('{green}[√] Done. A new ArchiveBox collection was initialized ({} links).{reset}'.format(len(all_links), **ANSI))
print()
print(' {lightred}Hint:{reset} To view your archive index, run:'.format(**ANSI))
print(' archivebox server # then visit http://127.0.0.1:8000')
print()
print(' To add new links, you can run:')
print(" archivebox add ~/some/path/or/url/to/list_of_links.txt")
print()
print(' For more usage and examples, run:')
print(' archivebox help')
print('{green}[√] Done. A new ArchiveBox collection was initialized ({} links).{reset}'.format(len(all_links) + len(pending_links), **ANSI))
json_index = Path(out_dir) / JSON_INDEX_FILENAME
html_index = Path(out_dir) / HTML_INDEX_FILENAME
json_index = out_dir / JSON_INDEX_FILENAME
html_index = out_dir / HTML_INDEX_FILENAME
index_name = f"{date.today()}_index_old"
if json_index.exists():
json_index.rename(f"{index_name}.json")
if html_index.exists():
html_index.rename(f"{index_name}.html")
if setup:
run_subcommand('setup', pwd=out_dir)
if Snapshot.objects.count() < 25: # hide the hints for experienced users
print()
print(' {lightred}Hint:{reset} To view your archive index, run:'.format(**ANSI))
print(' archivebox server # then visit http://127.0.0.1:8000')
print()
print(' To add new links, you can run:')
print(" archivebox add < ~/some/path/to/list_of_links.txt")
print()
print(' For more usage and examples, run:')
print(' archivebox help')
@enforce_types
def status(out_dir: Path=OUTPUT_DIR) -> None:
@@ -529,15 +578,21 @@ def oneshot(url: str, extractors: str="", out_dir: Path=OUTPUT_DIR):
@enforce_types
def add(urls: Union[str, List[str]],
tag: str='',
depth: int=0,
update_all: bool=not ONLY_NEW,
update: bool=not ONLY_NEW,
update_all: bool=False,
index_only: bool=False,
overwrite: bool=False,
# duplicate: bool=False, # TODO: reuse the logic from admin.py resnapshot to allow adding multiple snapshots by appending timestamp automatically
init: bool=False,
extractors: str="",
parser: str="auto",
out_dir: Path=OUTPUT_DIR) -> List[Link]:
"""Add a new URL or list of URLs to your archive"""
from core.models import Tag
assert depth in (0, 1), 'Depth must be 0 or 1 (depth >1 is not supported yet)'
extractors = extractors.split(",") if extractors else []
@@ -559,37 +614,73 @@ def add(urls: Union[str, List[str]],
# save verbatim args to sources
write_ahead_log = save_text_as_source('\n'.join(urls), filename='{ts}-import.txt', out_dir=out_dir)
new_links += parse_links_from_source(write_ahead_log, root_url=None)
new_links += parse_links_from_source(write_ahead_log, root_url=None, parser=parser)
# If we're going one level deeper, download each link and look for more links
new_links_depth = []
if new_links and depth == 1:
log_crawl_started(new_links)
for new_link in new_links:
downloaded_file = save_file_as_source(new_link.url, filename=f'{new_link.timestamp}-crawl-{new_link.domain}.txt', out_dir=out_dir)
new_links_depth += parse_links_from_source(downloaded_file, root_url=new_link.url)
try:
downloaded_file = save_file_as_source(new_link.url, filename=f'{new_link.timestamp}-crawl-{new_link.domain}.txt', out_dir=out_dir)
new_links_depth += parse_links_from_source(downloaded_file, root_url=new_link.url)
except Exception as err:
stderr('[!] Failed to get contents of URL {new_link.url}', err, color='red')
imported_links = list({link.url: link for link in (new_links + new_links_depth)}.values())
new_links = dedupe_links(all_links, imported_links)
write_main_index(links=new_links, out_dir=out_dir)
all_links = load_main_index(out_dir=out_dir)
if index_only:
return all_links
# mock archive all the links using the fake index_only extractor method in order to update their state
if overwrite:
archive_links(imported_links, overwrite=overwrite, methods=['index_only'], out_dir=out_dir)
else:
archive_links(new_links, overwrite=False, methods=['index_only'], out_dir=out_dir)
else:
# fully run the archive extractor methods for each link
archive_kwargs = {
"out_dir": out_dir,
}
if extractors:
archive_kwargs["methods"] = extractors
stderr()
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
if update:
stderr(f'[*] [{ts}] Archiving + updating {len(imported_links)}/{len(all_links)}', len(imported_links), 'URLs from added set...', color='green')
archive_links(imported_links, overwrite=overwrite, **archive_kwargs)
elif update_all:
stderr(f'[*] [{ts}] Archiving + updating {len(all_links)}/{len(all_links)}', len(all_links), 'URLs from entire library...', color='green')
archive_links(all_links, overwrite=overwrite, **archive_kwargs)
elif overwrite:
stderr(f'[*] [{ts}] Archiving + overwriting {len(imported_links)}/{len(all_links)}', len(imported_links), 'URLs from added set...', color='green')
archive_links(imported_links, overwrite=True, **archive_kwargs)
elif new_links:
stderr(f'[*] [{ts}] Archiving {len(new_links)}/{len(all_links)} URLs from added set...', color='green')
archive_links(new_links, overwrite=False, **archive_kwargs)
# add any tags to imported links
tags = [
Tag.objects.get_or_create(name=name.strip())[0]
for name in tag.split(',')
if name.strip()
]
if tags:
for link in imported_links:
snapshot = link.as_snapshot()
snapshot.tags.add(*tags)
snapshot.tags_str(nocache=True)
snapshot.save()
# print(f' √ Tagged {len(imported_links)} Snapshots with {len(tags)} tags {tags_str}')
# Run the archive methods for each link
archive_kwargs = {
"out_dir": out_dir,
}
if extractors:
archive_kwargs["methods"] = extractors
if update_all:
archive_links(all_links, overwrite=overwrite, **archive_kwargs)
elif overwrite:
archive_links(imported_links, overwrite=True, **archive_kwargs)
elif new_links:
archive_links(new_links, overwrite=False, **archive_kwargs)
return all_links
@@ -809,11 +900,15 @@ def list_links(snapshots: Optional[QuerySet]=None,
all_snapshots = load_main_index(out_dir=out_dir)
if after is not None:
all_snapshots = all_snapshots.filter(timestamp__lt=after)
all_snapshots = all_snapshots.filter(timestamp__gte=after)
if before is not None:
all_snapshots = all_snapshots.filter(timestamp__gt=before)
all_snapshots = all_snapshots.filter(timestamp__lt=before)
if filter_patterns:
all_snapshots = snapshot_filter(all_snapshots, filter_patterns, filter_type)
if not all_snapshots:
stderr('[!] No Snapshots matched your filters:', filter_patterns, f'({filter_type})', color='lightyellow')
return all_snapshots
@enforce_types
@@ -841,6 +936,120 @@ def list_folders(links: List[Link],
except KeyError:
raise ValueError('Status not recognized.')
@enforce_types
def setup(out_dir: Path=OUTPUT_DIR) -> None:
"""Automatically install all ArchiveBox dependencies and extras"""
if not (out_dir / ARCHIVE_DIR_NAME).exists():
run_subcommand('init', stdin=None, pwd=out_dir)
setup_django(out_dir=out_dir, check_db=True)
from core.models import User
if not User.objects.filter(is_superuser=True).exists():
stderr('\n[+] Creating new admin user for the Web UI...', color='green')
run_subcommand('manage', subcommand_args=['createsuperuser'], pwd=out_dir)
stderr('\n[+] Installing enabled ArchiveBox dependencies automatically...', color='green')
stderr('\n Installing YOUTUBEDL_BINARY automatically using pip...')
if YOUTUBEDL_VERSION:
print(f'{YOUTUBEDL_VERSION} is already installed', YOUTUBEDL_BINARY)
else:
try:
run_shell([
PYTHON_BINARY, '-m', 'pip',
'install',
'--upgrade',
'--no-cache-dir',
'--no-warn-script-location',
'youtube_dl',
], capture_output=False, cwd=out_dir)
pkg_path = run_shell([
PYTHON_BINARY, '-m', 'pip',
'show',
'youtube_dl',
], capture_output=True, text=True, cwd=out_dir).stdout.decode().split('Location: ')[-1].split('\n', 1)[0]
NEW_YOUTUBEDL_BINARY = Path(pkg_path) / 'youtube_dl' / '__main__.py'
os.chmod(NEW_YOUTUBEDL_BINARY, 0o777)
assert NEW_YOUTUBEDL_BINARY.exists(), f'youtube_dl must exist inside {pkg_path}'
config(f'YOUTUBEDL_BINARY={NEW_YOUTUBEDL_BINARY}', set=True, out_dir=out_dir)
except BaseException as e: # lgtm [py/catch-base-exception]
stderr(f'[X] Failed to install python packages: {e}', color='red')
raise SystemExit(1)
if platform.machine() == 'armv7l':
stderr('\n Skip the automatic installation of CHROME_BINARY because playwright is not available on armv7.')
else:
stderr('\n Installing CHROME_BINARY automatically using playwright...')
if CHROME_VERSION:
print(f'{CHROME_VERSION} is already installed', CHROME_BINARY)
else:
try:
run_shell([
PYTHON_BINARY, '-m', 'pip',
'install',
'--upgrade',
'--no-cache-dir',
'--no-warn-script-location',
'playwright',
], capture_output=False, cwd=out_dir)
run_shell([PYTHON_BINARY, '-m', 'playwright', 'install', 'chromium'], capture_output=False, cwd=out_dir)
proc = run_shell([PYTHON_BINARY, '-c', 'from playwright.sync_api import sync_playwright; print(sync_playwright().start().chromium.executable_path)'], capture_output=True, text=True, cwd=out_dir)
NEW_CHROME_BINARY = proc.stdout.decode().strip() if isinstance(proc.stdout, bytes) else proc.stdout.strip()
assert NEW_CHROME_BINARY and len(NEW_CHROME_BINARY), 'CHROME_BINARY must contain a path'
config(f'CHROME_BINARY={NEW_CHROME_BINARY}', set=True, out_dir=out_dir)
except BaseException as e: # lgtm [py/catch-base-exception]
stderr(f'[X] Failed to install chromium using playwright: {e.__class__.__name__} {e}', color='red')
raise SystemExit(1)
stderr('\n Installing SINGLEFILE_BINARY, READABILITY_BINARY, MERCURY_BINARY automatically using npm...')
if not NODE_VERSION:
stderr('[X] You must first install node using your system package manager', color='red')
hint([
'curl -sL https://deb.nodesource.com/setup_15.x | sudo -E bash -',
'or to disable all node-based modules run: archivebox config --set USE_NODE=False',
])
raise SystemExit(1)
if all((SINGLEFILE_VERSION, READABILITY_VERSION, MERCURY_VERSION)):
print('SINGLEFILE_BINARY, READABILITY_BINARY, and MERCURURY_BINARY are already installed')
else:
try:
# clear out old npm package locations
paths = (
out_dir / 'package.json',
out_dir / 'package_lock.json',
out_dir / 'node_modules',
)
for path in paths:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.is_file():
os.remove(path)
shutil.copyfile(PACKAGE_DIR / 'package.json', out_dir / 'package.json') # copy the js requirements list from the source install into the data dir
# lets blindly assume that calling out to npm via shell works reliably cross-platform 🤡 (until proven otherwise via support tickets)
run_shell([
'npm',
'install',
'--prefix', str(out_dir), # force it to put the node_modules dir in this folder
'--force', # overwrite any existing node_modules
'--no-save', # don't bother saving updating the package.json or package-lock.json file
'--no-audit', # don't bother checking for newer versions with security vuln fixes
'--no-fund', # hide "please fund our project" messages
'--loglevel', 'error', # only show erros (hide warn/info/debug) during installation
# these args are written in blood, change with caution
], capture_output=False, cwd=out_dir)
os.remove(out_dir / 'package.json')
except BaseException as e: # lgtm [py/catch-base-exception]
stderr(f'[X] Failed to install npm packages: {e}', color='red')
hint(f'Try deleting {out_dir}/node_modules and running it again')
raise SystemExit(1)
stderr('\n[√] Set up ArchiveBox and its dependencies successfully.', color='green')
run_shell([PYTHON_BINARY, ARCHIVEBOX_BINARY, '--version'], capture_output=False, cwd=out_dir)
@enforce_types
def config(config_options_str: Optional[str]=None,
@@ -924,7 +1133,7 @@ def config(config_options_str: Optional[str]=None,
stderr()
stderr('[X] These options failed to set (check for typos):', color='red')
stderr(' {}'.format('\n '.join(failed_options)))
raise SystemExit(bool(failed_options))
raise SystemExit(1)
elif reset:
stderr('[X] This command is not implemented yet.', color='red')
stderr(' Please manually remove the relevant lines from your config file:')
@@ -947,13 +1156,15 @@ def schedule(add: bool=False,
quiet: bool=False,
every: Optional[str]=None,
depth: int=0,
overwrite: bool=False,
update: bool=not ONLY_NEW,
import_path: Optional[str]=None,
out_dir: Path=OUTPUT_DIR):
"""Set ArchiveBox to regularly import URLs at specific times using cron"""
check_data_folder(out_dir=out_dir)
(Path(out_dir) / LOGS_DIR_NAME).mkdir(exist_ok=True)
Path(LOGS_DIR).mkdir(exist_ok=True)
cron = CronTab(user=True)
cron = dedupe_cron_jobs(cron)
@@ -967,15 +1178,21 @@ def schedule(add: bool=False,
if every or add:
every = every or 'day'
quoted = lambda s: f'"{s}"' if s and ' ' in str(s) else str(s)
quoted = lambda s: f'"{s}"' if (s and ' ' in str(s)) else str(s)
cmd = [
'cd',
quoted(out_dir),
'&&',
quoted(ARCHIVEBOX_BINARY),
*(['add', f'--depth={depth}', f'"{import_path}"'] if import_path else ['update']),
'>',
quoted(Path(LOGS_DIR) / 'archivebox.log'),
*([
'add',
*(['--overwrite'] if overwrite else []),
*(['--update'] if update else []),
f'--depth={depth}',
f'"{import_path}"',
] if import_path else ['update']),
'>>',
quoted(Path(LOGS_DIR) / 'schedule.log'),
'2>&1',
]
@@ -990,8 +1207,8 @@ def schedule(add: bool=False,
stderr('{red}[X] Got invalid timeperiod for cron task.{reset}'.format(**ANSI))
stderr(' It must be one of minute/hour/day/month')
stderr(' or a quoted cron-format schedule like:')
stderr(' archivebox init --every=day https://example.com/some/rss/feed.xml')
stderr(' archivebox init --every="0/5 * * * *" https://example.com/some/rss/feed.xml')
stderr(' archivebox init --every=day --depth=1 https://example.com/some/rss/feed.xml')
stderr(' archivebox init --every="0/5 * * * *" --depth=1 https://example.com/some/rss/feed.xml')
raise SystemExit(1)
cron = dedupe_cron_jobs(cron)
@@ -1017,7 +1234,7 @@ def schedule(add: bool=False,
else:
stderr('{red}[X] There are no ArchiveBox cron jobs scheduled for your user ({}).{reset}'.format(USER, **ANSI))
stderr(' To schedule a new job, run:')
stderr(' archivebox schedule --every=[timeperiod] https://example.com/some/rss/feed.xml')
stderr(' archivebox schedule --every=[timeperiod] --depth=1 https://example.com/some/rss/feed.xml')
raise SystemExit(0)
cron = CronTab(user=True)
@@ -1027,7 +1244,7 @@ def schedule(add: bool=False,
if foreground or run_all:
if not existing_jobs:
stderr('{red}[X] You must schedule some jobs first before running in foreground mode.{reset}'.format(**ANSI))
stderr(' archivebox schedule --every=hour https://example.com/some/rss/feed.xml')
stderr(' archivebox schedule --every=hour --depth=1 https://example.com/some/rss/feed.xml')
raise SystemExit(1)
print('{green}[*] Running {} ArchiveBox jobs in foreground task scheduler...{reset}'.format(len(existing_jobs), **ANSI))
@@ -1035,7 +1252,7 @@ def schedule(add: bool=False,
try:
for job in existing_jobs:
sys.stdout.write(f' > {job.command.split("/archivebox ")[0].split(" && ")[0]}\n')
sys.stdout.write(f' > {job.command.split("/archivebox ")[-1].split(" > ")[0]}')
sys.stdout.write(f' > {job.command.split("/archivebox ")[-1].split(" >> ")[0]}')
sys.stdout.flush()
job.run()
sys.stdout.write(f'\r √ {job.command.split("/archivebox ")[-1]}\n')
@@ -1046,7 +1263,7 @@ def schedule(add: bool=False,
if foreground:
try:
for job in existing_jobs:
print(f' > {job.command.split("/archivebox ")[-1].split(" > ")[0]}')
print(f' > {job.command.split("/archivebox ")[-1].split(" >> ")[0]}')
for result in cron.run_scheduler():
print(result)
except KeyboardInterrupt:
@@ -1059,6 +1276,7 @@ def server(runserver_args: Optional[List[str]]=None,
reload: bool=False,
debug: bool=False,
init: bool=False,
quick_init: bool=False,
createsuperuser: bool=False,
out_dir: Path=OUTPUT_DIR) -> None:
"""Run the ArchiveBox HTTP server"""
@@ -1067,9 +1285,14 @@ def server(runserver_args: Optional[List[str]]=None,
if init:
run_subcommand('init', stdin=None, pwd=out_dir)
print()
elif quick_init:
run_subcommand('init', subcommand_args=['--quick'], stdin=None, pwd=out_dir)
print()
if createsuperuser:
run_subcommand('manage', subcommand_args=['createsuperuser'], pwd=out_dir)
print()
# setup config for django runserver
from . import config
@@ -1081,12 +1304,9 @@ def server(runserver_args: Optional[List[str]]=None,
from django.core.management import call_command
from django.contrib.auth.models import User
admin_user = User.objects.filter(is_superuser=True).order_by('date_joined').only('username').last()
print('{green}[+] Starting ArchiveBox webserver...{reset}'.format(**ANSI))
if admin_user:
hint('The admin username is{lightblue} {}{reset}\n'.format(admin_user.username, **ANSI))
else:
print(' > Logging errors to ./logs/errors.log')
if not User.objects.filter(is_superuser=True).exists():
print('{lightyellow}[!] No admin users exist yet, you will not be able to edit links in the UI.{reset}'.format(**ANSI))
print()
print(' To create an admin user, run:')
@@ -1104,7 +1324,6 @@ def server(runserver_args: Optional[List[str]]=None,
config.SHOW_PROGRESS = False
config.DEBUG = config.DEBUG or debug
call_command("runserver", *runserver_args)
-1
View File
@@ -1 +0,0 @@
../package.json
+13
View File
@@ -0,0 +1,13 @@
{
"name": "archivebox",
"version": "0.7.0",
"description": "ArchiveBox: The self-hosted internet archive",
"author": "Nick Sweeting <archivebox-npm@sweeting.me>",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
"@postlight/parser": "^2.2.3",
"readability-extractor": "git+https://github.com/ArchiveBox/readability-extractor.git",
"single-file-cli": "^1.1.12"
}
}
+120 -70
View File
@@ -11,7 +11,7 @@ import re
from io import StringIO
from typing import IO, Tuple, List, Optional
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from ..system import atomic_write
@@ -20,6 +20,8 @@ from ..config import (
OUTPUT_DIR,
SOURCES_DIR_NAME,
TIMEOUT,
stderr,
hint,
)
from ..util import (
basename,
@@ -31,36 +33,44 @@ from ..util import (
from ..index.schema import Link
from ..logging_util import TimedProgress, log_source_saved
from .pocket_html import parse_pocket_html_export
from .pocket_api import parse_pocket_api_export
from .pinboard_rss import parse_pinboard_rss_export
from .wallabag_atom import parse_wallabag_atom_export
from .shaarli_rss import parse_shaarli_rss_export
from .medium_rss import parse_medium_rss_export
from .netscape_html import parse_netscape_html_export
from .generic_rss import parse_generic_rss_export
from .generic_json import parse_generic_json_export
from .generic_html import parse_generic_html_export
from .generic_txt import parse_generic_txt_export
from . import pocket_api
from . import readwise_reader_api
from . import wallabag_atom
from . import pocket_html
from . import pinboard_rss
from . import shaarli_rss
from . import medium_rss
PARSERS = (
from . import netscape_html
from . import generic_rss
from . import generic_json
from . import generic_html
from . import generic_txt
from . import url_list
PARSERS = {
# Specialized parsers
('Pocket API', parse_pocket_api_export),
('Wallabag ATOM', parse_wallabag_atom_export),
('Pocket HTML', parse_pocket_html_export),
('Pinboard RSS', parse_pinboard_rss_export),
('Shaarli RSS', parse_shaarli_rss_export),
('Medium RSS', parse_medium_rss_export),
# General parsers
('Netscape HTML', parse_netscape_html_export),
('Generic RSS', parse_generic_rss_export),
('Generic JSON', parse_generic_json_export),
('Generic HTML', parse_generic_html_export),
pocket_api.KEY: (pocket_api.NAME, pocket_api.PARSER),
readwise_reader_api.KEY: (readwise_reader_api.NAME, readwise_reader_api.PARSER),
wallabag_atom.KEY: (wallabag_atom.NAME, wallabag_atom.PARSER),
pocket_html.KEY: (pocket_html.NAME, pocket_html.PARSER),
pinboard_rss.KEY: (pinboard_rss.NAME, pinboard_rss.PARSER),
shaarli_rss.KEY: (shaarli_rss.NAME, shaarli_rss.PARSER),
medium_rss.KEY: (medium_rss.NAME, medium_rss.PARSER),
# Fallback parser
('Plain Text', parse_generic_txt_export),
)
# General parsers
netscape_html.KEY: (netscape_html.NAME, netscape_html.PARSER),
generic_rss.KEY: (generic_rss.NAME, generic_rss.PARSER),
generic_json.KEY: (generic_json.NAME, generic_json.PARSER),
generic_html.KEY: (generic_html.NAME, generic_html.PARSER),
# Catchall fallback parser
generic_txt.KEY: (generic_txt.NAME, generic_txt.PARSER),
# Explicitly specified parsers
url_list.KEY: (url_list.NAME, url_list.PARSER),
}
@enforce_types
@@ -68,7 +78,6 @@ def parse_links_memory(urls: List[str], root_url: Optional[str]=None):
"""
parse a list of URLS without touching the filesystem
"""
check_url_parsing_invariants()
timer = TimedProgress(TIMEOUT * 4)
#urls = list(map(lambda x: x + "\n", urls))
@@ -84,16 +93,14 @@ def parse_links_memory(urls: List[str], root_url: Optional[str]=None):
@enforce_types
def parse_links(source_file: str, root_url: Optional[str]=None) -> Tuple[List[Link], str]:
def parse_links(source_file: str, root_url: Optional[str]=None, parser: str="auto") -> Tuple[List[Link], str]:
"""parse a list of URLs with their metadata from an
RSS feed, bookmarks export, or text file
"""
check_url_parsing_invariants()
timer = TimedProgress(TIMEOUT * 4)
with open(source_file, 'r', encoding='utf-8') as file:
links, parser = run_parser_functions(file, timer, root_url=root_url)
links, parser = run_parser_functions(file, timer, root_url=root_url, parser=parser)
timer.end()
if parser is None:
@@ -101,15 +108,27 @@ def parse_links(source_file: str, root_url: Optional[str]=None) -> Tuple[List[Li
return links, parser
def run_parser_functions(to_parse: IO[str], timer, root_url: Optional[str]=None) -> Tuple[List[Link], Optional[str]]:
def run_parser_functions(to_parse: IO[str], timer, root_url: Optional[str]=None, parser: str="auto") -> Tuple[List[Link], Optional[str]]:
most_links: List[Link] = []
best_parser_name = None
for parser_name, parser_func in PARSERS:
if parser != "auto":
parser_name, parser_func = PARSERS[parser]
parsed_links = list(parser_func(to_parse, root_url=root_url))
if not parsed_links:
stderr()
stderr(f'[X] No links found using {parser_name} parser', color='red')
hint('Try a different parser or double check the input?')
stderr()
timer.end()
return parsed_links, parser_name
for parser_id in PARSERS:
parser_name, parser_func = PARSERS[parser_id]
try:
parsed_links = list(parser_func(to_parse, root_url=root_url))
if not parsed_links:
raise Exception('no links found')
raise Exception(f'No links found using {parser_name} parser')
# print(f'[√] Parser {parser_name} succeeded: {len(parsed_links)} links parsed')
if len(parsed_links) > len(most_links):
@@ -118,8 +137,8 @@ def run_parser_functions(to_parse: IO[str], timer, root_url: Optional[str]=None)
except Exception as err: # noqa
# Parsers are tried one by one down the list, and the first one
# that succeeds is used. To see why a certain parser was not used
# due to error or format incompatibility, uncomment this line:
# that succeeds is used. To debug why a certain parser was not used
# due to python error or format incompatibility, uncomment this line:
# print('[!] Parser {} failed: {} {}'.format(parser_name, err.__class__.__name__, err))
# raise
@@ -130,9 +149,19 @@ def run_parser_functions(to_parse: IO[str], timer, root_url: Optional[str]=None)
@enforce_types
def save_text_as_source(raw_text: str, filename: str='{ts}-stdin.txt', out_dir: Path=OUTPUT_DIR) -> str:
ts = str(datetime.now().timestamp()).split('.', 1)[0]
ts = str(datetime.now(timezone.utc).timestamp()).split('.', 1)[0]
source_path = str(out_dir / SOURCES_DIR_NAME / filename.format(ts=ts))
atomic_write(source_path, raw_text)
referenced_texts = ''
for entry in raw_text.split():
try:
if Path(entry).exists():
referenced_texts += Path(entry).read_text()
except Exception as err:
print(err)
atomic_write(source_path, raw_text + '\n' + referenced_texts)
log_source_saved(source_file=source_path)
return source_path
@@ -140,7 +169,7 @@ def save_text_as_source(raw_text: str, filename: str='{ts}-stdin.txt', out_dir:
@enforce_types
def save_file_as_source(path: str, timeout: int=TIMEOUT, filename: str='{ts}-{basename}.txt', out_dir: Path=OUTPUT_DIR) -> str:
"""download a given url's content into output/sources/domain-<timestamp>.txt"""
ts = str(datetime.now().timestamp()).split('.', 1)[0]
ts = str(datetime.now(timezone.utc).timestamp()).split('.', 1)[0]
source_path = str(OUTPUT_DIR / SOURCES_DIR_NAME / filename.format(basename=basename(path), ts=ts))
if any(path.startswith(s) for s in ('http://', 'https://', 'ftp://')):
@@ -159,7 +188,7 @@ def save_file_as_source(path: str, timeout: int=TIMEOUT, filename: str='{ts}-{ba
ANSI['reset'],
))
print(' ', e)
raise SystemExit(1)
raise e
else:
# Source is a path to a local file on the filesystem
@@ -173,31 +202,52 @@ def save_file_as_source(path: str, timeout: int=TIMEOUT, filename: str='{ts}-{ba
return source_path
def check_url_parsing_invariants() -> None:
"""Check that plain text regex URL parsing works as expected"""
# this is last-line-of-defense to make sure the URL_REGEX isn't
# misbehaving, as the consequences could be disastrous and lead to many
# incorrect/badly parsed links being added to the archive
test_urls = '''
https://example1.com/what/is/happening.html?what=1#how-about-this=1
https://example2.com/what/is/happening/?what=1#how-about-this=1
HTtpS://example3.com/what/is/happening/?what=1#how-about-this=1f
https://example4.com/what/is/happening.html
https://example5.com/
https://example6.com
<test>http://example7.com</test>
[https://example8.com/what/is/this.php?what=1]
[and http://example9.com?what=1&other=3#and-thing=2]
<what>https://example10.com#and-thing=2 "</about>
abc<this["https://example11.com/what/is#and-thing=2?whoami=23&where=1"]that>def
sdflkf[what](https://example12.com/who/what.php?whoami=1#whatami=2)?am=hi
example13.bada
and example14.badb
<or>htt://example15.badc</that>
'''
# print('\n'.join(re.findall(URL_REGEX, test_urls)))
assert len(re.findall(URL_REGEX, test_urls)) == 12
# Check that plain text regex URL parsing works as expected
# this is last-line-of-defense to make sure the URL_REGEX isn't
# misbehaving due to some OS-level or environment level quirks (e.g. bad regex lib)
# the consequences of bad URL parsing could be disastrous and lead to many
# incorrect/badly parsed links being added to the archive, so this is worth the cost of checking
_test_url_strs = {
'example.com': 0,
'/example.com': 0,
'//example.com': 0,
':/example.com': 0,
'://example.com': 0,
'htt://example8.com': 0,
'/htt://example.com': 0,
'https://example': 1,
'https://localhost/2345': 1,
'https://localhost:1234/123': 1,
'://': 0,
'https://': 0,
'http://': 0,
'ftp://': 0,
'ftp://example.com': 0,
'https://example.com': 1,
'https://example.com/': 1,
'https://a.example.com': 1,
'https://a.example.com/': 1,
'https://a.example.com/what/is/happening.html': 1,
'https://a.example.com/what/ís/happening.html': 1,
'https://a.example.com/what/is/happening.html?what=1&2%20b#höw-about-this=1a': 1,
'https://a.example.com/what/is/happéning/?what=1&2%20b#how-aboüt-this=1a': 1,
'HTtpS://a.example.com/what/is/happening/?what=1&2%20b#how-about-this=1af&2f%20b': 1,
'https://example.com/?what=1#how-about-this=1&2%20baf': 1,
'https://example.com?what=1#how-about-this=1&2%20baf': 1,
'<test>http://example7.com</test>': 1,
'https://<test>': 0,
'https://[test]': 0,
'http://"test"': 0,
'http://\'test\'': 0,
'[https://example8.com/what/is/this.php?what=1]': 1,
'[and http://example9.com?what=1&other=3#and-thing=2]': 1,
'<what>https://example10.com#and-thing=2 "</about>': 1,
'abc<this["https://example11.com/what/is#and-thing=2?whoami=23&where=1"]that>def': 1,
'sdflkf[what](https://example12.com/who/what.php?whoami=1#whatami=2)?am=hi': 1,
'<or>http://examplehttp://15.badc</that>': 2,
'https://a.example.com/one.html?url=http://example.com/inside/of/another?=http://': 2,
'[https://a.example.com/one.html?url=http://example.com/inside/of/another?=](http://a.example.com)': 3,
}
for url_str, num_urls in _test_url_strs.items():
assert len(re.findall(URL_REGEX, url_str)) == num_urls, (
f'{url_str} does not contain {num_urls} urls')
+7 -2
View File
@@ -4,7 +4,7 @@ __package__ = 'archivebox.parsers'
import re
from typing import IO, Iterable, Optional
from datetime import datetime
from datetime import datetime, timezone
from ..index.schema import Link
from ..util import (
@@ -46,8 +46,13 @@ def parse_generic_html_export(html_file: IO[str], root_url: Optional[str]=None,
for archivable_url in re.findall(URL_REGEX, url):
yield Link(
url=htmldecode(archivable_url),
timestamp=str(datetime.now().timestamp()),
timestamp=str(datetime.now(timezone.utc).timestamp()),
title=None,
tags=None,
sources=[html_file.name],
)
KEY = 'html'
NAME = 'Generic HTML'
PARSER = parse_generic_html_export
+11 -3
View File
@@ -3,7 +3,7 @@ __package__ = 'archivebox.parsers'
import json
from typing import IO, Iterable
from datetime import datetime
from datetime import datetime, timezone
from ..index.schema import Link
from ..util import (
@@ -17,7 +17,10 @@ def parse_generic_json_export(json_file: IO[str], **_kwargs) -> Iterable[Link]:
"""Parse JSON-format bookmarks export files (produced by pinboard.in/export/, or wallabag)"""
json_file.seek(0)
links = json.load(json_file)
# sometimes the first line is a comment or filepath, so we get everything after the first {
json_file_json_str = '{' + json_file.read().split('{', 1)[-1]
links = json.loads(json_file_json_str)
json_date = lambda s: datetime.strptime(s, '%Y-%m-%dT%H:%M:%S%z')
for link in links:
@@ -30,7 +33,7 @@ def parse_generic_json_export(json_file: IO[str], **_kwargs) -> Iterable[Link]:
raise Exception('JSON must contain URL in each entry [{"url": "http://...", ...}, ...]')
# Parse the timestamp
ts_str = str(datetime.now().timestamp())
ts_str = str(datetime.now(timezone.utc).timestamp())
if link.get('timestamp'):
# chrome/ff histories use a very precise timestamp
ts_str = str(link['timestamp'] / 10000000)
@@ -63,3 +66,8 @@ def parse_generic_json_export(json_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=htmldecode(link.get('tags')) or '',
sources=[json_file.name],
)
KEY = 'json'
NAME = 'Generic JSON'
PARSER = parse_generic_json_export
+5
View File
@@ -47,3 +47,8 @@ def parse_generic_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=None,
sources=[rss_file.name],
)
KEY = 'rss'
NAME = 'Generic RSS'
PARSER = parse_generic_rss_export
+9 -5
View File
@@ -4,7 +4,7 @@ __description__ = 'Plain Text'
import re
from typing import IO, Iterable
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from ..index.schema import Link
@@ -17,7 +17,7 @@ from ..util import (
@enforce_types
def parse_generic_txt_export(text_file: IO[str], **_kwargs) -> Iterable[Link]:
"""Parse raw links from each line in a text file"""
"""Parse links from a text file, ignoring other text"""
text_file.seek(0)
for line in text_file.readlines():
@@ -29,7 +29,7 @@ def parse_generic_txt_export(text_file: IO[str], **_kwargs) -> Iterable[Link]:
if Path(line).exists():
yield Link(
url=line,
timestamp=str(datetime.now().timestamp()),
timestamp=str(datetime.now(timezone.utc).timestamp()),
title=None,
tags=None,
sources=[text_file.name],
@@ -42,7 +42,7 @@ def parse_generic_txt_export(text_file: IO[str], **_kwargs) -> Iterable[Link]:
for url in re.findall(URL_REGEX, line):
yield Link(
url=htmldecode(url),
timestamp=str(datetime.now().timestamp()),
timestamp=str(datetime.now(timezone.utc).timestamp()),
title=None,
tags=None,
sources=[text_file.name],
@@ -54,8 +54,12 @@ def parse_generic_txt_export(text_file: IO[str], **_kwargs) -> Iterable[Link]:
for sub_url in re.findall(URL_REGEX, line[1:]):
yield Link(
url=htmldecode(sub_url),
timestamp=str(datetime.now().timestamp()),
timestamp=str(datetime.now(timezone.utc).timestamp()),
title=None,
tags=None,
sources=[text_file.name],
)
KEY = 'txt'
NAME = 'Generic TXT'
PARSER = parse_generic_txt_export
+5
View File
@@ -33,3 +33,8 @@ def parse_medium_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=None,
sources=[rss_file.name],
)
KEY = 'medium_rss'
NAME = 'Medium RSS'
PARSER = parse_medium_rss_export
+4
View File
@@ -37,3 +37,7 @@ def parse_netscape_html_export(html_file: IO[str], **_kwargs) -> Iterable[Link]:
sources=[html_file.name],
)
KEY = 'netscape_html'
NAME = 'Netscape HTML'
PARSER = parse_netscape_html_export
+13 -3
View File
@@ -2,7 +2,7 @@ __package__ = 'archivebox.parsers'
from typing import IO, Iterable
from datetime import datetime
from datetime import datetime, timezone
from xml.etree import ElementTree
@@ -21,13 +21,18 @@ def parse_pinboard_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
root = ElementTree.parse(rss_file).getroot()
items = root.findall("{http://purl.org/rss/1.0/}item")
for item in items:
find = lambda p: item.find(p).text.strip() if item.find(p) else None # type: ignore
find = lambda p: item.find(p).text.strip() if item.find(p) is not None else None # type: ignore
url = find("{http://purl.org/rss/1.0/}link")
tags = find("{http://purl.org/dc/elements/1.1/}subject")
title = find("{http://purl.org/rss/1.0/}title")
ts_str = find("{http://purl.org/dc/elements/1.1/}date")
if url is None:
# Yielding a Link with no URL will
# crash on a URL validation assertion
continue
# Pinboard includes a colon in its date stamp timezone offsets, which
# Python can't parse. Remove it:
if ts_str and ts_str[-3:-2] == ":":
@@ -36,7 +41,7 @@ def parse_pinboard_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
if ts_str:
time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
else:
time = datetime.now()
time = datetime.now(timezone.utc)
yield Link(
url=htmldecode(url),
@@ -45,3 +50,8 @@ def parse_pinboard_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=htmldecode(tags) or None,
sources=[rss_file.name],
)
KEY = 'pinboard_rss'
NAME = 'Pinboard RSS'
PARSER = parse_pinboard_rss_export
+7 -2
View File
@@ -47,11 +47,11 @@ def get_pocket_articles(api: Pocket, since=None, page=0):
def link_from_article(article: dict, sources: list):
url: str = article['resolved_url'] or article['given_url']
url: str = article.get('resolved_url') or article['given_url']
broken_protocol = _BROKEN_PROTOCOL_RE.match(url)
if broken_protocol:
url = url.replace(f'{broken_protocol.group(1)}:/', f'{broken_protocol.group(1)}://')
title = article['resolved_title'] or article['given_title'] or url
title = article.get('resolved_title') or article.get('given_title') or url
return Link(
url=url,
@@ -111,3 +111,8 @@ def parse_pocket_api_export(input_buffer: IO[str], **_kwargs) -> Iterable[Link]:
yield link_from_article(article, sources=[line])
write_since(username, api.last_since)
KEY = 'pocket_api'
NAME = 'Pocket API'
PARSER = parse_pocket_api_export
+5
View File
@@ -36,3 +36,8 @@ def parse_pocket_html_export(html_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=tags or '',
sources=[html_file.name],
)
KEY = 'pocket_html'
NAME = 'Pocket HTML'
PARSER = parse_pocket_html_export
+123
View File
@@ -0,0 +1,123 @@
__package__ = "archivebox.parsers"
import re
import requests
from datetime import datetime
from typing import IO, Iterable, Optional
from configparser import ConfigParser
from pathlib import Path
from ..index.schema import Link
from ..util import enforce_types
from ..system import atomic_write
from ..config import (
SOURCES_DIR,
READWISE_READER_TOKENS,
)
API_DB_PATH = Path(SOURCES_DIR) / "readwise_reader_api.db"
class ReadwiseReaderAPI:
cursor: Optional[str]
def __init__(self, api_token, cursor=None) -> None:
self.api_token = api_token
self.cursor = cursor
def get_archive(self):
response = requests.get(
url="https://readwise.io/api/v3/list/",
headers={"Authorization": f"Token {self.api_token}"},
params={
"location": "archive",
"pageCursor": self.cursor,
}
)
response.raise_for_status()
return response
def get_readwise_reader_articles(api: ReadwiseReaderAPI):
response = api.get_archive()
body = response.json()
articles = body["results"]
yield from articles
if body['nextPageCursor']:
api.cursor = body["nextPageCursor"]
yield from get_readwise_reader_articles(api)
def link_from_article(article: dict, sources: list):
url: str = article['source_url']
title = article["title"] or url
timestamp = datetime.fromisoformat(article['updated_at']).timestamp()
return Link(
url=url,
timestamp=str(timestamp),
title=title,
tags="",
sources=sources,
)
def write_cursor(username: str, since: str):
if not API_DB_PATH.exists():
atomic_write(API_DB_PATH, "")
since_file = ConfigParser()
since_file.optionxform = str
since_file.read(API_DB_PATH)
since_file[username] = {"since": since}
with open(API_DB_PATH, "w+") as new:
since_file.write(new)
def read_cursor(username: str) -> Optional[str]:
if not API_DB_PATH.exists():
atomic_write(API_DB_PATH, "")
config_file = ConfigParser()
config_file.optionxform = str
config_file.read(API_DB_PATH)
return config_file.get(username, "since", fallback=None)
@enforce_types
def should_parse_as_readwise_reader_api(text: str) -> bool:
return text.startswith("readwise-reader://")
@enforce_types
def parse_readwise_reader_api_export(input_buffer: IO[str], **_kwargs) -> Iterable[Link]:
"""Parse bookmarks from the Readwise Reader API"""
input_buffer.seek(0)
pattern = re.compile(r"^readwise-reader:\/\/(\w+)")
for line in input_buffer:
if should_parse_as_readwise_reader_api(line):
username = pattern.search(line).group(1)
api = ReadwiseReaderAPI(READWISE_READER_TOKENS[username], cursor=read_cursor(username))
for article in get_readwise_reader_articles(api):
yield link_from_article(article, sources=[line])
if api.cursor:
write_cursor(username, api.cursor)
KEY = "readwise_reader_api"
NAME = "Readwise Reader API"
PARSER = parse_readwise_reader_api_export
+5
View File
@@ -48,3 +48,8 @@ def parse_shaarli_rss_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
tags=None,
sources=[rss_file.name],
)
KEY = 'shaarli_rss'
NAME = 'Shaarli RSS'
PARSER = parse_shaarli_rss_export
+37
View File
@@ -0,0 +1,37 @@
__package__ = 'archivebox.parsers'
__description__ = 'URL list'
import re
from typing import IO, Iterable
from datetime import datetime, timezone
from ..index.schema import Link
from ..util import (
enforce_types,
URL_REGEX,
)
@enforce_types
def parse_url_list(text_file: IO[str], **_kwargs) -> Iterable[Link]:
"""Parse raw URLs from each line in a text file"""
text_file.seek(0)
for line in text_file.readlines():
url = line.strip()
if (not url) or not re.findall(URL_REGEX, url):
continue
yield Link(
url=url,
timestamp=str(datetime.now(timezone.utc).timestamp()),
title=None,
tags=None,
sources=[text_file.name],
)
KEY = 'url_list'
NAME = 'URL List'
PARSER = parse_url_list
+16 -5
View File
@@ -34,13 +34,19 @@ def parse_wallabag_atom_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
trailing_removed = entry.split('</entry>', 1)[0]
leading_removed = trailing_removed.strip()
rows = leading_removed.split('\n')
splits_fixed = leading_removed.replace('"\n href="', '" href="')
rows = splits_fixed.split('\n')
def get_row(key):
return [r.strip() for r in rows if r.strip().startswith('<{}'.format(key))][0]
def get_row(prefix):
return [
row.strip()
for row in rows
if row.strip().startswith('<{}'.format(prefix))
][0]
title = str_between(get_row('title'), '<title><![CDATA[', ']]></title>').strip()
url = str_between(get_row('link rel="via"'), '<link rel="via">', '</link>')
url_inside_link = str_between(get_row('link rel="via"'), '<link rel="via">', '</link>')
url_inside_attr = str_between(get_row('link rel="via"'), 'href="', '"/>')
ts_str = str_between(get_row('published'), '<published>', '</published>')
time = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%S%z")
try:
@@ -49,9 +55,14 @@ def parse_wallabag_atom_export(rss_file: IO[str], **_kwargs) -> Iterable[Link]:
tags = None
yield Link(
url=htmldecode(url),
url=htmldecode(url_inside_attr or url_inside_link),
timestamp=str(time.timestamp()),
title=htmldecode(title) or None,
tags=tags or '',
sources=[rss_file.name],
)
KEY = 'wallabag_atom'
NAME = 'Wallabag Atom'
PARSER = parse_wallabag_atom_export
+2 -2
View File
@@ -2,7 +2,7 @@ import re
from subprocess import run, PIPE
from typing import List, Generator
from archivebox.config import ARCHIVE_DIR, RIPGREP_VERSION
from archivebox.config import ARCHIVE_DIR, RIPGREP_VERSION, SEARCH_BACKEND_TIMEOUT
from archivebox.util import enforce_types
RG_IGNORE_EXTENSIONS = ('css','js','orig','svg')
@@ -32,7 +32,7 @@ def search(text: str) -> List[str]:
from core.models import Snapshot
rg_cmd = ['rg', RG_ADD_TYPE, RG_IGNORE_ARGUMENTS, RG_DEFAULT_ARGUMENTS, RG_REGEX_ARGUMENT, text, str(ARCHIVE_DIR)]
rg = run(rg_cmd, stdout=PIPE, stderr=PIPE, timeout=60)
rg = run(rg_cmd, stdout=PIPE, stderr=PIPE, timeout=SEARCH_BACKEND_TIMEOUT)
file_paths = [p.decode() for p in rg.stdout.splitlines()]
timestamps = set()
for path in file_paths:
+20 -4
View File
@@ -5,15 +5,31 @@ from sonic import IngestClient, SearchClient
from archivebox.util import enforce_types
from archivebox.config import SEARCH_BACKEND_HOST_NAME, SEARCH_BACKEND_PORT, SEARCH_BACKEND_PASSWORD, SONIC_BUCKET, SONIC_COLLECTION
MAX_SONIC_TEXT_LENGTH = 2000
MAX_SONIC_TEXT_TOTAL_LENGTH = 100000000 # dont index more than 100 million characters per text
MAX_SONIC_TEXT_CHUNK_LENGTH = 2000 # dont index more than 2000 characters per chunk
MAX_SONIC_ERRORS_BEFORE_ABORT = 5
@enforce_types
def index(snapshot_id: str, texts: List[str]):
error_count = 0
with IngestClient(SEARCH_BACKEND_HOST_NAME, SEARCH_BACKEND_PORT, SEARCH_BACKEND_PASSWORD) as ingestcl:
for text in texts:
chunks = [text[i:i+MAX_SONIC_TEXT_LENGTH] for i in range(0, len(text), MAX_SONIC_TEXT_LENGTH)]
for chunk in chunks:
ingestcl.push(SONIC_COLLECTION, SONIC_BUCKET, snapshot_id, str(chunk))
chunks = (
text[i:i+MAX_SONIC_TEXT_CHUNK_LENGTH]
for i in range(
0,
min(len(text), MAX_SONIC_TEXT_TOTAL_LENGTH),
MAX_SONIC_TEXT_CHUNK_LENGTH,
)
)
try:
for chunk in chunks:
ingestcl.push(SONIC_COLLECTION, SONIC_BUCKET, snapshot_id, str(chunk))
except Exception as err:
print(f'[!] Sonic search backend threw an error while indexing: {err.__class__.__name__} {err}')
error_count += 1
if error_count > MAX_SONIC_ERRORS_BEFORE_ABORT:
raise
@enforce_types
def search(text: str) -> List[str]:
+195
View File
@@ -0,0 +1,195 @@
import codecs
from typing import List, Generator
import sqlite3
from archivebox.util import enforce_types
from archivebox.config import (
FTS_SEPARATE_DATABASE,
FTS_TOKENIZERS,
FTS_SQLITE_MAX_LENGTH
)
FTS_TABLE = "snapshot_fts"
FTS_ID_TABLE = "snapshot_id_fts"
FTS_COLUMN = "texts"
if FTS_SEPARATE_DATABASE:
database = sqlite3.connect("search.sqlite3")
# Make get_connection callable, because `django.db.connection.cursor()`
# has to be called to get a context manager, but sqlite3.Connection
# is a context manager without being called.
def get_connection():
return database
SQLITE_BIND = "?"
else:
from django.db import connection as database # type: ignore[no-redef, assignment]
get_connection = database.cursor
SQLITE_BIND = "%s"
# Only Python >= 3.11 supports sqlite3.Connection.getlimit(),
# so fall back to the default if the API to get the real value isn't present
try:
limit_id = sqlite3.SQLITE_LIMIT_LENGTH
try:
with database.temporary_connection() as cursor: # type: ignore[attr-defined]
SQLITE_LIMIT_LENGTH = cursor.connection.getlimit(limit_id)
except AttributeError:
SQLITE_LIMIT_LENGTH = database.getlimit(limit_id)
except AttributeError:
SQLITE_LIMIT_LENGTH = FTS_SQLITE_MAX_LENGTH
def _escape_sqlite3(value: str, *, quote: str, errors='strict') -> str:
assert isinstance(quote, str), "quote is not a str"
assert len(quote) == 1, "quote must be a single character"
encodable = value.encode('utf-8', errors).decode('utf-8')
nul_index = encodable.find("\x00")
if nul_index >= 0:
error = UnicodeEncodeError("NUL-terminated utf-8", encodable,
nul_index, nul_index + 1, "NUL not allowed")
error_handler = codecs.lookup_error(errors)
replacement, _ = error_handler(error)
assert isinstance(replacement, str), "handling a UnicodeEncodeError should return a str replacement"
encodable = encodable.replace("\x00", replacement)
return quote + encodable.replace(quote, quote * 2) + quote
def _escape_sqlite3_value(value: str, errors='strict') -> str:
return _escape_sqlite3(value, quote="'", errors=errors)
def _escape_sqlite3_identifier(value: str) -> str:
return _escape_sqlite3(value, quote='"', errors='strict')
@enforce_types
def _create_tables():
table = _escape_sqlite3_identifier(FTS_TABLE)
# Escape as value, because fts5() expects
# string literal column names
column = _escape_sqlite3_value(FTS_COLUMN)
id_table = _escape_sqlite3_identifier(FTS_ID_TABLE)
tokenizers = _escape_sqlite3_value(FTS_TOKENIZERS)
trigger_name = _escape_sqlite3_identifier(f"{FTS_ID_TABLE}_ad")
with get_connection() as cursor:
# Create a contentless-delete FTS5 table that indexes
# but does not store the texts of snapshots
try:
cursor.execute(
f"CREATE VIRTUAL TABLE {table}"
f" USING fts5({column},"
f" tokenize={tokenizers},"
" content='', contentless_delete=1);"
)
except Exception as e:
msg = str(e)
if 'unrecognized option: "contentlessdelete"' in msg:
sqlite_version = getattr(sqlite3, "sqlite_version", "Unknown")
raise RuntimeError(
"SQLite full-text search requires SQLite >= 3.43.0;"
f" the running version is {sqlite_version}"
) from e
else:
raise
# Create a one-to-one mapping between ArchiveBox snapshot_id
# and FTS5 rowid, because the column type of rowid can't be
# customized.
cursor.execute(
f"CREATE TABLE {id_table}("
" rowid INTEGER PRIMARY KEY AUTOINCREMENT,"
" snapshot_id char(32) NOT NULL UNIQUE"
");"
)
# Create a trigger to delete items from the FTS5 index when
# the snapshot_id is deleted from the mapping, to maintain
# consistency and make the `flush()` query simpler.
cursor.execute(
f"CREATE TRIGGER {trigger_name}"
f" AFTER DELETE ON {id_table} BEGIN"
f" DELETE FROM {table} WHERE rowid=old.rowid;"
" END;"
)
def _handle_query_exception(exc: Exception):
message = str(exc)
if message.startswith("no such table:"):
raise RuntimeError(
"SQLite full-text search index has not yet"
" been created; run `archivebox update --index-only`."
)
else:
raise exc
@enforce_types
def index(snapshot_id: str, texts: List[str]):
text = ' '.join(texts)[:SQLITE_LIMIT_LENGTH]
table = _escape_sqlite3_identifier(FTS_TABLE)
column = _escape_sqlite3_identifier(FTS_COLUMN)
id_table = _escape_sqlite3_identifier(FTS_ID_TABLE)
with get_connection() as cursor:
retries = 2
while retries > 0:
retries -= 1
try:
# If there is already an FTS index rowid to snapshot_id mapping,
# then don't insert a new one, silently ignoring the operation.
# {id_table}.rowid is AUTOINCREMENT, so will generate an unused
# rowid for the index if it is an unindexed snapshot_id.
cursor.execute(
f"INSERT OR IGNORE INTO {id_table}(snapshot_id) VALUES({SQLITE_BIND})",
[snapshot_id])
# Fetch the FTS index rowid for the given snapshot_id
id_res = cursor.execute(
f"SELECT rowid FROM {id_table} WHERE snapshot_id = {SQLITE_BIND}",
[snapshot_id])
rowid = id_res.fetchone()[0]
# (Re-)index the content
cursor.execute(
"INSERT OR REPLACE INTO"
f" {table}(rowid, {column}) VALUES ({SQLITE_BIND}, {SQLITE_BIND})",
[rowid, text])
# All statements succeeded; return
return
except Exception as e:
if str(e).startswith("no such table:") and retries > 0:
_create_tables()
else:
raise
raise RuntimeError("Failed to create tables for SQLite FTS5 search")
@enforce_types
def search(text: str) -> List[str]:
table = _escape_sqlite3_identifier(FTS_TABLE)
id_table = _escape_sqlite3_identifier(FTS_ID_TABLE)
with get_connection() as cursor:
try:
res = cursor.execute(
f"SELECT snapshot_id FROM {table}"
f" INNER JOIN {id_table}"
f" ON {id_table}.rowid = {table}.rowid"
f" WHERE {table} MATCH {SQLITE_BIND}",
[text])
except Exception as e:
_handle_query_exception(e)
snap_ids = [row[0] for row in res.fetchall()]
return snap_ids
@enforce_types
def flush(snapshot_ids: Generator[str, None, None]):
snapshot_ids = list(snapshot_ids) # type: ignore[assignment]
id_table = _escape_sqlite3_identifier(FTS_ID_TABLE)
with get_connection() as cursor:
try:
cursor.executemany(
f"DELETE FROM {id_table} WHERE snapshot_id={SQLITE_BIND}",
[snapshot_ids])
except Exception as e:
_handle_query_exception(e)
+5 -5
View File
@@ -16,7 +16,7 @@ def get_file_result_content(res, extra_path, use_pwd=False):
if extra_path:
fpath = f'{fpath}/{extra_path}'
with open(fpath, 'r') as file:
with open(fpath, 'r', encoding='utf-8') as file:
data = file.read()
if data:
return [data]
@@ -36,10 +36,10 @@ def get_indexable_content(results: QuerySet):
# TODO: banish this duplication and get these from the extractor file
if method == 'readability':
return get_file_result_content(res, 'content.txt')
return get_file_result_content(res, 'content.txt', use_pwd=True)
elif method == 'singlefile':
return get_file_result_content(res,'',use_pwd=True)
return get_file_result_content(res, '', use_pwd=True)
elif method == 'dom':
return get_file_result_content(res,'',use_pwd=True)
return get_file_result_content(res, '', use_pwd=True)
elif method == 'wget':
return get_file_result_content(res,'',use_pwd=True)
return get_file_result_content(res, '', use_pwd=True)
+75 -14
View File
@@ -2,34 +2,79 @@ __package__ = 'archivebox'
import os
import signal
import shutil
from json import dump
from pathlib import Path
from typing import Optional, Union, Set, Tuple
from subprocess import run as subprocess_run
from subprocess import _mswindows, PIPE, Popen, CalledProcessError, CompletedProcess, TimeoutExpired
from crontab import CronTab
from atomicwrites import atomic_write as lib_atomic_write
from .vendor.atomicwrites import atomic_write as lib_atomic_write
from .util import enforce_types, ExtendedEncoder
from .config import OUTPUT_PERMISSIONS
from .config import PYTHON_BINARY, OUTPUT_PERMISSIONS, DIR_OUTPUT_PERMISSIONS, ENFORCE_ATOMIC_WRITES
def run(*args, input=None, capture_output=True, text=False, **kwargs):
"""Patched of subprocess.run to fix blocking io making timeout=innefective"""
def run(cmd, *args, input=None, capture_output=True, timeout=None, check=False, text=False, start_new_session=True, **kwargs):
"""Patched of subprocess.run to kill forked child subprocesses and fix blocking io making timeout=innefective
Mostly copied from https://github.com/python/cpython/blob/master/Lib/subprocess.py
"""
if input is not None:
if 'stdin' in kwargs:
if kwargs.get('stdin') is not None:
raise ValueError('stdin and input arguments may not both be used.')
kwargs['stdin'] = PIPE
if capture_output:
if ('stdout' in kwargs) or ('stderr' in kwargs):
raise ValueError('stdout and stderr arguments may not be used '
'with capture_output.')
kwargs['stdout'] = PIPE
kwargs['stderr'] = PIPE
return subprocess_run(*args, input=input, capture_output=capture_output, text=text, **kwargs)
pgid = None
try:
if isinstance(cmd, (list, tuple)) and cmd[0].endswith('.py'):
cmd = (PYTHON_BINARY, *cmd)
with Popen(cmd, *args, start_new_session=start_new_session, **kwargs) as process:
pgid = os.getpgid(process.pid)
try:
stdout, stderr = process.communicate(input, timeout=timeout)
except TimeoutExpired as exc:
process.kill()
if _mswindows:
# Windows accumulates the output in a single blocking
# read() call run on child threads, with the timeout
# being done in a join() on those threads. communicate()
# _after_ kill() is required to collect that and add it
# to the exception.
exc.stdout, exc.stderr = process.communicate()
else:
# POSIX _communicate already populated the output so
# far into the TimeoutExpired exception.
process.wait()
raise
except: # Including KeyboardInterrupt, communicate handled that.
process.kill()
# We don't call process.wait() as .__exit__ does that for us.
raise
retcode = process.poll()
if check and retcode:
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
finally:
# force kill any straggler subprocesses that were forked from the main proc
try:
os.killpg(pgid, signal.SIGINT)
except Exception:
pass
return CompletedProcess(process.args, retcode, stdout, stderr)
@enforce_types
@@ -37,23 +82,34 @@ def atomic_write(path: Union[Path, str], contents: Union[dict, str, bytes], over
"""Safe atomic write to filesystem by writing to temp file + atomic rename"""
mode = 'wb+' if isinstance(contents, bytes) else 'w'
encoding = None if isinstance(contents, bytes) else 'utf-8' # enforce utf-8 on all text writes
# print('\n> Atomic Write:', mode, path, len(contents), f'overwrite={overwrite}')
try:
with lib_atomic_write(path, mode=mode, overwrite=overwrite) as f:
with lib_atomic_write(path, mode=mode, overwrite=overwrite, encoding=encoding) as f:
if isinstance(contents, dict):
dump(contents, f, indent=4, sort_keys=True, cls=ExtendedEncoder)
elif isinstance(contents, (bytes, str)):
f.write(contents)
except OSError as e:
print(f"[X] OSError: Failed to write {path} with fcntl.F_FULLFSYNC. ({e})")
print(" For data integrity, ArchiveBox requires a filesystem that supports atomic writes.")
print(" Filesystems and network drives that don't implement FSYNC are incompatible and require workarounds.")
raise SystemExit(1)
if ENFORCE_ATOMIC_WRITES:
print(f"[X] OSError: Failed to write {path} with fcntl.F_FULLFSYNC. ({e})")
print(" You can store the archive/ subfolder on a hard drive or network share that doesn't support support syncronous writes,")
print(" but the main folder containing the index.sqlite3 and ArchiveBox.conf files must be on a filesystem that supports FSYNC.")
raise SystemExit(1)
# retry the write without forcing FSYNC (aka atomic mode)
with open(path, mode=mode, encoding=encoding) as f:
if isinstance(contents, dict):
dump(contents, f, indent=4, sort_keys=True, cls=ExtendedEncoder)
elif isinstance(contents, (bytes, str)):
f.write(contents)
# set file permissions
os.chmod(path, int(OUTPUT_PERMISSIONS, base=8))
@enforce_types
def chmod_file(path: str, cwd: str='.', permissions: str=OUTPUT_PERMISSIONS) -> None:
def chmod_file(path: str, cwd: str='.') -> None:
"""chmod -R <permissions> <cwd>/<path>"""
root = Path(cwd) / path
@@ -61,10 +117,15 @@ def chmod_file(path: str, cwd: str='.', permissions: str=OUTPUT_PERMISSIONS) ->
raise Exception('Failed to chmod: {} does not exist (did the previous step fail?)'.format(path))
if not root.is_dir():
# path is just a plain file
os.chmod(root, int(OUTPUT_PERMISSIONS, base=8))
else:
for subpath in Path(path).glob('**/*'):
os.chmod(subpath, int(OUTPUT_PERMISSIONS, base=8))
if subpath.is_dir():
# directories need execute permissions to be able to list contents
os.chmod(subpath, int(DIR_OUTPUT_PERMISSIONS, base=8))
else:
os.chmod(subpath, int(OUTPUT_PERMISSIONS, base=8))
@enforce_types
@@ -1 +0,0 @@
actions_as_select
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more