mirror of
https://github.com/bellingcat/auto-archiver.git
synced 2026-06-10 12:18:30 +03:00
Compare commits
502 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792838f1a1 | ||
|
|
17c4ae15eb | ||
|
|
a08af07348 | ||
|
|
e54077f4e8 | ||
|
|
319c0528da | ||
|
|
ae0e53e434 | ||
|
|
82fc786d56 | ||
|
|
aa65299844 | ||
|
|
1b69ec1f00 | ||
|
|
304e5d40b1 | ||
|
|
3194fee95d | ||
|
|
0040810e2e | ||
|
|
63cfe34e23 | ||
|
|
23a88e3cf4 | ||
|
|
3cac160cc1 | ||
|
|
e9a92272c5 | ||
|
|
5d6c5ac2b1 | ||
|
|
f1de07c9aa | ||
|
|
1e1e060a77 | ||
|
|
b43d229326 | ||
|
|
077b03fc61 | ||
|
|
cf77cfa64d | ||
|
|
bc66dd4f2a | ||
|
|
139d647197 | ||
|
|
f465b570cd | ||
|
|
52a7cabaf1 | ||
|
|
a739361e12 | ||
|
|
9a97fede43 | ||
|
|
2d13077fad | ||
|
|
8a4a314cf9 | ||
|
|
75e8b788ae | ||
|
|
defe2315bf | ||
|
|
b9ab26ed5a | ||
|
|
ba0dffdd5e | ||
|
|
a09927c507 | ||
|
|
6c938c489a | ||
|
|
0e39768da9 | ||
|
|
1e5d6ec4a6 | ||
|
|
3385d004cf | ||
|
|
7f27f7fce0 | ||
|
|
a6e3240af1 | ||
|
|
bf4c196cc2 | ||
|
|
c640cc898a | ||
|
|
3e2c0b564b | ||
|
|
5fd23baa55 | ||
|
|
8a450310c7 | ||
|
|
bef8a14089 | ||
|
|
cd0b093e7a | ||
|
|
096c9d09ef | ||
|
|
df3521e9ca | ||
|
|
a89d0193e4 | ||
|
|
536cbd905f | ||
|
|
a936921c4e | ||
|
|
68f672a4fa | ||
|
|
4ee0ad1cf8 | ||
|
|
bac809451c | ||
|
|
53dc9904ce | ||
|
|
c1f312d42a | ||
|
|
23c9dfe717 | ||
|
|
d02e7e0f02 | ||
|
|
56526a9ac7 | ||
|
|
3a22cc28c0 | ||
|
|
dbb3dfa04f | ||
|
|
01bdb35f5d | ||
|
|
43cbc6ac56 | ||
|
|
9c7cab1ae2 | ||
|
|
a9a0bae083 | ||
|
|
97d133ce79 | ||
|
|
432ee3dcfd | ||
|
|
94e0803fb3 | ||
|
|
794b4f6052 | ||
|
|
965d7d41dd | ||
|
|
e73faa70cc | ||
|
|
80beab9f23 | ||
|
|
200cea4e12 | ||
|
|
1256fde159 | ||
|
|
65e222e177 | ||
|
|
f2eb9ef784 | ||
|
|
2081c16555 | ||
|
|
d3efd7121c | ||
|
|
9d3cd5774b | ||
|
|
80d61e8b85 | ||
|
|
d36cdbfa87 | ||
|
|
c1506ee1cf | ||
|
|
3a34a49822 | ||
|
|
37c6d97275 | ||
|
|
7234eda85f | ||
|
|
a8c1ef3912 | ||
|
|
52ed8196a5 | ||
|
|
2051e8e491 | ||
|
|
21255db86a | ||
|
|
eae0da08b3 | ||
|
|
0d1447117c | ||
|
|
0f56a5aae5 | ||
|
|
649412053e | ||
|
|
c2c9718f73 | ||
|
|
30ea8a0ba4 | ||
|
|
73c8dc583f | ||
|
|
b2648fa3cd | ||
|
|
4ad71b3589 | ||
|
|
7c9475cde2 | ||
|
|
afd9090a4c | ||
|
|
ad29cb4447 | ||
|
|
ce4d7ac649 | ||
|
|
ade7feb5a0 | ||
|
|
12b457706b | ||
|
|
592dc30415 | ||
|
|
4a36e6f6b0 | ||
|
|
d46eeee9b6 | ||
|
|
302e6f4258 | ||
|
|
e803c5d0e3 | ||
|
|
e1d0314a9e | ||
|
|
5d5119e053 | ||
|
|
d6c90d87f1 | ||
|
|
212bf67ab1 | ||
|
|
6abe2edb13 | ||
|
|
03c0cf09ae | ||
|
|
0db77c7e68 | ||
|
|
cd6607943d | ||
|
|
3869ea73d7 | ||
|
|
918cb220be | ||
|
|
76fd329fe5 | ||
|
|
a3ae9ebbb3 | ||
|
|
23b781c866 | ||
|
|
2aec240128 | ||
|
|
c5a2fd45f9 | ||
|
|
216226e7cc | ||
|
|
ad168785e7 | ||
|
|
74a1561c3d | ||
|
|
55d9ffaacd | ||
|
|
f19fb575a7 | ||
|
|
f53b2075ba | ||
|
|
d20486c02a | ||
|
|
6085a66c58 | ||
|
|
33cca734d9 | ||
|
|
2f1a07abbf | ||
|
|
664ee8d037 | ||
|
|
1b260788de | ||
|
|
f0b876e67c | ||
|
|
8067da0f60 | ||
|
|
6f949738a3 | ||
|
|
1b6d85884b | ||
|
|
7ab804d163 | ||
|
|
b3adc5603a | ||
|
|
ba3f1a52e8 | ||
|
|
a60d800b31 | ||
|
|
f2e80758a7 | ||
|
|
f07fdbc500 | ||
|
|
b236f2510d | ||
|
|
529d8b60bf | ||
|
|
cd6a2b6031 | ||
|
|
dfb361e3a0 | ||
|
|
3d31c7605b | ||
|
|
d7a48e465b | ||
|
|
aaa9ead39d | ||
|
|
f5be7a50c1 | ||
|
|
2adcf231f7 | ||
|
|
cd19181d8f | ||
|
|
b60469767a | ||
|
|
d60d02c16e | ||
|
|
e567bba6f9 | ||
|
|
3cf51dd874 | ||
|
|
69ddb72146 | ||
|
|
1039e9631f | ||
|
|
79f42c3c41 | ||
|
|
8314833ae8 | ||
|
|
6279610a43 | ||
|
|
fc89d96517 | ||
|
|
54fda9cad4 | ||
|
|
71636233cb | ||
|
|
fdbe96f2e4 | ||
|
|
22bd8727df | ||
|
|
499c272260 | ||
|
|
f232bc45b8 | ||
|
|
4270e06728 | ||
|
|
ca00aa302d | ||
|
|
773fa82f06 | ||
|
|
ef0e909a72 | ||
|
|
6bbc7fb47a | ||
|
|
809b8c7749 | ||
|
|
6d82655cc4 | ||
|
|
6bd493a791 | ||
|
|
287e823f43 | ||
|
|
c815488daa | ||
|
|
f53e34d6bd | ||
|
|
4cfbc3008b | ||
|
|
6f02493ff1 | ||
|
|
1f2d637928 | ||
|
|
18cc05a2fe | ||
|
|
c96fd71f35 | ||
|
|
b3183510ea | ||
|
|
d13a5ef003 | ||
|
|
48c1ab3c1f | ||
|
|
b2ee42ee95 | ||
|
|
07ff5baf07 | ||
|
|
d202d79e0f | ||
|
|
e2e6490b49 | ||
|
|
952487da30 | ||
|
|
c7a84bc97a | ||
|
|
c0be41950d | ||
|
|
ae547ef83f | ||
|
|
8a897cf601 | ||
|
|
14c8af5cc8 | ||
|
|
8e2e18ef75 | ||
|
|
5491f3e9e7 | ||
|
|
264ba82ea0 | ||
|
|
05231445d9 | ||
|
|
2c6be4447f | ||
|
|
5f68c151a0 | ||
|
|
6d2aec032f | ||
|
|
bc8cf2fb29 | ||
|
|
f066111d49 | ||
|
|
e6f3826a3a | ||
|
|
e5a78a5d06 | ||
|
|
258fb4faaf | ||
|
|
5ec00f7811 | ||
|
|
22408e2a98 | ||
|
|
378b1a6d22 | ||
|
|
d130c1b3fa | ||
|
|
cbd189c97d | ||
|
|
d2e8f1a512 | ||
|
|
488802b632 | ||
|
|
c772082f0e | ||
|
|
ee68f3efee | ||
|
|
efe2a1a8b6 | ||
|
|
6735fa890b | ||
|
|
69028588b3 | ||
|
|
b351a33593 | ||
|
|
87e1cdc102 | ||
|
|
4170c2011c | ||
|
|
dd4e372703 | ||
|
|
b9f7927a3b | ||
|
|
d99b7c9efe | ||
|
|
48be13fb2a | ||
|
|
4aae5047f5 | ||
|
|
258e56aa26 | ||
|
|
9ad6213efa | ||
|
|
2f36e50e0b | ||
|
|
2d7206f99d | ||
|
|
ac24fd8f49 | ||
|
|
ee3e871dd8 | ||
|
|
e6fdef66df | ||
|
|
5cf640af8a | ||
|
|
33cacd145f | ||
|
|
0f69b5fe0c | ||
|
|
ad2e8397b2 | ||
|
|
144adaad5b | ||
|
|
c7c7eb00a1 | ||
|
|
7e4ba62918 | ||
|
|
9c2b506189 | ||
|
|
8940580638 | ||
|
|
c2821d7c83 | ||
|
|
a590647279 | ||
|
|
1edfdae03e | ||
|
|
6c7f6af4b4 | ||
|
|
8685b6bf13 | ||
|
|
0ce7f5a1b5 | ||
|
|
85d3f2fa02 | ||
|
|
fd540bd03a | ||
|
|
86f328515c | ||
|
|
68992025b0 | ||
|
|
6544934825 | ||
|
|
197599b406 | ||
|
|
96efdcbba1 | ||
|
|
2ec494b4b9 | ||
|
|
1d18399d70 | ||
|
|
3550a009e6 | ||
|
|
dd7d85b4b4 | ||
|
|
c510c04643 | ||
|
|
a0d955fe84 | ||
|
|
5e7c57650b | ||
|
|
1db7d6702d | ||
|
|
b1a8792f9f | ||
|
|
f715100dd5 | ||
|
|
dbcf19d1b8 | ||
|
|
0840b7283c | ||
|
|
b5dc1854a2 | ||
|
|
efab0f9a91 | ||
|
|
bc35116975 | ||
|
|
25f1f5dc93 | ||
|
|
f99dcc63a1 | ||
|
|
48fbfc3b86 | ||
|
|
e7aae76ffe | ||
|
|
1466700b45 | ||
|
|
00b29db390 | ||
|
|
2a0dfaead2 | ||
|
|
a448e2532c | ||
|
|
46a51cce11 | ||
|
|
b7949a489f | ||
|
|
e0e9f93065 | ||
|
|
e06b0c0585 | ||
|
|
95ea9fb231 | ||
|
|
17d2d14680 | ||
|
|
f54b5c5f18 | ||
|
|
456b2746c8 | ||
|
|
2cad5edea8 | ||
|
|
580de88366 | ||
|
|
093ce34a6a | ||
|
|
7872d9356c | ||
|
|
23e7dd0995 | ||
|
|
565275ac37 | ||
|
|
4a02407659 | ||
|
|
ae523eb06f | ||
|
|
d87c0dc3a9 | ||
|
|
1612fef59b | ||
|
|
fbf51f61b9 | ||
|
|
a9ff55a36e | ||
|
|
20bc80b9ef | ||
|
|
5bb0cbf3ff | ||
|
|
3eb9ffddfe | ||
|
|
76e90dd23a | ||
|
|
0450d3fcb9 | ||
|
|
e9ee4d67ba | ||
|
|
43a80dbcda | ||
|
|
cb3ae055d6 | ||
|
|
4cfa6455c7 | ||
|
|
0073a08525 | ||
|
|
46e31808f6 | ||
|
|
4af23e13d1 | ||
|
|
d6be1ff84f | ||
|
|
633290a9cc | ||
|
|
040a864d5c | ||
|
|
b4c33318c4 | ||
|
|
74974ef0ed | ||
|
|
5c6005d843 | ||
|
|
d6a7f31248 | ||
|
|
8aba663534 | ||
|
|
ace97ac7fd | ||
|
|
ad373ae733 | ||
|
|
260e76dd3d | ||
|
|
a9fe959ea1 | ||
|
|
beb7f3893d | ||
|
|
5055402c2a | ||
|
|
3c4625d708 | ||
|
|
31fa7380f5 | ||
|
|
396ec03bae | ||
|
|
e811196711 | ||
|
|
dfde6f1995 | ||
|
|
7b454baa02 | ||
|
|
0f9c6a9a5c | ||
|
|
c980500978 | ||
|
|
01516724d3 | ||
|
|
a066bf4ca9 | ||
|
|
2233af81f7 | ||
|
|
aacb874b56 | ||
|
|
4b5a8c0199 | ||
|
|
14c56f4916 | ||
|
|
5b131996c6 | ||
|
|
168dfb6254 | ||
|
|
42e16aebd6 | ||
|
|
d6d5a08204 | ||
|
|
e6c5705f70 | ||
|
|
613ba0c05d | ||
|
|
b997bbea2b | ||
|
|
54f53886ef | ||
|
|
0a5ba3385e | ||
|
|
034857075d | ||
|
|
6700250891 | ||
|
|
5e5e1c43a1 | ||
|
|
1e19ad77c6 | ||
|
|
f22af5e123 | ||
|
|
799cef3a8c | ||
|
|
2921061fde | ||
|
|
e531906d73 | ||
|
|
244341d22c | ||
|
|
90932a7bc8 | ||
|
|
488675056b | ||
|
|
93921e71d4 | ||
|
|
675de50ee7 | ||
|
|
fc6946f78a | ||
|
|
2fdf6b7564 | ||
|
|
a577228465 | ||
|
|
ba9d67e4bb | ||
|
|
c4e63ebd8c | ||
|
|
f6863b8eb2 | ||
|
|
b83bfda187 | ||
|
|
5c34ac1293 | ||
|
|
cb632723bd | ||
|
|
7d972ee9b8 | ||
|
|
b64826dc16 | ||
|
|
0c892f3cf1 | ||
|
|
23e74803ee | ||
|
|
d03ecdb037 | ||
|
|
a5ebbf4726 | ||
|
|
89e387030d | ||
|
|
8ec053ed1b | ||
|
|
43ef8f2aeb | ||
|
|
e6b1a8c893 | ||
|
|
8548b7def7 | ||
|
|
29db537fab | ||
|
|
bbe25537c7 | ||
|
|
c4a3a45bf7 | ||
|
|
5daeae994a | ||
|
|
3ea02c115e | ||
|
|
ab03e48708 | ||
|
|
3d4056ef70 | ||
|
|
51041bf91e | ||
|
|
f5bbfe5d1c | ||
|
|
f56cd6891b | ||
|
|
0765640bff | ||
|
|
06b1f4c0ca | ||
|
|
59b910ec30 | ||
|
|
7e360240bf | ||
|
|
9e03d745d8 | ||
|
|
7badf89c28 | ||
|
|
d59530c8e7 | ||
|
|
0ec5451f66 | ||
|
|
99e9ac2465 | ||
|
|
42162c5e3f | ||
|
|
3afe519176 | ||
|
|
f13349bacf | ||
|
|
92c79ed994 | ||
|
|
2643b8e717 | ||
|
|
b2238427a0 | ||
|
|
282380d8cc | ||
|
|
6920585f6d | ||
|
|
17463de937 | ||
|
|
29cc1d317f | ||
|
|
733aef0b08 | ||
|
|
562d06916e | ||
|
|
b21467c922 | ||
|
|
a8e5585e6c | ||
|
|
abaeec0cc6 | ||
|
|
19715c8ec2 | ||
|
|
17ae75fb95 | ||
|
|
b8da7607e8 | ||
|
|
a01a873f37 | ||
|
|
72f48f0147 | ||
|
|
846474a4e2 | ||
|
|
f504d2e304 | ||
|
|
5f7a8b1ac0 | ||
|
|
4af3cd7b2a | ||
|
|
ad2784c5de | ||
|
|
c7c24fbaf2 | ||
|
|
4d67dce4c8 | ||
|
|
f6b13327f0 | ||
|
|
589c834047 | ||
|
|
0efeaaabb1 | ||
|
|
b908655cc8 | ||
|
|
2e25e59fa6 | ||
|
|
10ceb7aa15 | ||
|
|
0bef78b0b4 | ||
|
|
15222199d9 | ||
|
|
e7489ac4c4 | ||
|
|
16012df30b | ||
|
|
8673bc5979 | ||
|
|
e76551ba22 | ||
|
|
6e52a534e7 | ||
|
|
753c3c6214 | ||
|
|
1d664524eb | ||
|
|
394b8b2dd1 | ||
|
|
79f576be1d | ||
|
|
94aeee8313 | ||
|
|
abc90b19d5 | ||
|
|
1423c10363 | ||
|
|
8ca7698fa0 | ||
|
|
28041d94d9 | ||
|
|
b70ed97ffd | ||
|
|
28c5396b74 | ||
|
|
94543e9a67 | ||
|
|
37eac64442 | ||
|
|
89ee6f19b6 | ||
|
|
294033f156 | ||
|
|
7a81ab617a | ||
|
|
2ffe124d95 | ||
|
|
1db8be91db | ||
|
|
81aa343f21 | ||
|
|
441f341139 | ||
|
|
e2442b2f6b | ||
|
|
3f6acc0917 | ||
|
|
e7fa88f1c7 | ||
|
|
ca44a40b88 | ||
|
|
85abe1837a | ||
|
|
3fcec57492 | ||
|
|
2b91dc9514 | ||
|
|
a9c3477289 | ||
|
|
770f4c8a3d | ||
|
|
cbb0414e5f | ||
|
|
f4f2424eb5 | ||
|
|
58bd38e292 | ||
|
|
e89a8da3b4 | ||
|
|
76bb1496c8 | ||
|
|
7e4b44883b | ||
|
|
77b517cfc1 | ||
|
|
2c1753e14b | ||
|
|
dd07b0b830 | ||
|
|
0eae2bee6a | ||
|
|
a0869bb3b2 | ||
|
|
afc117a229 | ||
|
|
4dcb77c29f | ||
|
|
898faf6fe4 | ||
|
|
6987a4827e | ||
|
|
f8e846d59a | ||
|
|
2d4f1b5b79 | ||
|
|
01bf88a695 | ||
|
|
c5127f5fd1 | ||
|
|
158d448cbc | ||
|
|
d0c379a3ba | ||
|
|
3163cb793a | ||
|
|
7bb4d68a22 | ||
|
|
2d87935042 | ||
|
|
4c1c8953ca |
40
.github/dependabot.yml
vendored
Normal file
40
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
groups:
|
||||
python:
|
||||
patterns:
|
||||
- "*"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/scripts/settings/"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
# Look for a `Dockerfile` in the `root` directory
|
||||
directory: "/"
|
||||
# Check for updates once a week
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
6
.github/workflows/docker-publish.yaml
vendored
6
.github/workflows/docker-publish.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -33,14 +33,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
with:
|
||||
images: bellingcat/auto-archiver
|
||||
|
||||
|
||||
4
.github/workflows/python-publish.yaml
vendored
4
.github/workflows/python-publish.yaml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: pyproject.toml
|
||||
|
||||
|
||||
34
.github/workflows/ruff.yaml
vendored
Normal file
34
.github/workflows/ruff.yaml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Ruff Formatting & Linting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
- ".github"
|
||||
- "poetry.lock"
|
||||
- "scripts/settings"
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
- ".github"
|
||||
- "poetry.lock"
|
||||
- "scripts/settings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ruff
|
||||
|
||||
- name: Run Ruff
|
||||
run: ruff check --output-format=github . && ruff format --check
|
||||
25
.github/workflows/tests-core.yaml
vendored
25
.github/workflows/tests-core.yaml
vendored
@@ -20,25 +20,34 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
os: [ubuntu-22.04]
|
||||
#TODO: re-enable ubuntu-latest, this is disabled as oscrypto cannot be pinned to github commit and pushed to pypi
|
||||
os: [ubuntu-22.04, ubuntu-latest]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install latest Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Cache Poetry and pip artifacts
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.cache/pip
|
||||
key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
|
||||
- name: Install dependencies from source only
|
||||
run: poetry install --no-interaction --with dev
|
||||
|
||||
- name: Run Core Tests
|
||||
|
||||
24
.github/workflows/tests-download.yaml
vendored
24
.github/workflows/tests-download.yaml
vendored
@@ -20,21 +20,31 @@ jobs:
|
||||
working-directory: ./
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry
|
||||
run: pipx install poetry
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install latest Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Cache Poetry and pip artifacts
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.cache/pip
|
||||
key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
|
||||
- name: Install dependencies from source only
|
||||
run: poetry install --no-interaction --with dev
|
||||
|
||||
- name: Run Download Tests
|
||||
run: poetry run pytest -ra -v -x -m "download"
|
||||
env:
|
||||
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN }}
|
||||
TWITTER_BEARER_TOKEN: ${{ secrets.TWITTER_BEARER_TOKEN || '' }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,9 +1,11 @@
|
||||
tmp*/
|
||||
temp/
|
||||
.env*
|
||||
!.env*.example
|
||||
.DS_Store
|
||||
expmt/
|
||||
service_account.json
|
||||
service_account-*.json
|
||||
__pycache__/
|
||||
._*
|
||||
anu.html
|
||||
@@ -34,4 +36,9 @@ docs/_build/
|
||||
docs/source/autoapi/
|
||||
docs/source/modules/autogen/
|
||||
scripts/settings_page.html
|
||||
scripts/settings/src/schema.json
|
||||
.vite
|
||||
downloaded_files
|
||||
latest_logs
|
||||
# for launch.json
|
||||
.vscode
|
||||
7
.pre-commit-config.yaml
Normal file
7
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Run Ruff formatter on commits.
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
@@ -7,6 +7,8 @@ version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
apt_packages:
|
||||
- ffmpeg
|
||||
tools:
|
||||
python: "3.10"
|
||||
nodejs: "22"
|
||||
@@ -21,7 +23,7 @@ build:
|
||||
# generate the config editor page. Schema then HTML
|
||||
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry run python scripts/generate_settings_schema.py
|
||||
# install node dependencies and build the settings
|
||||
- cd scripts/settings && npm install && npm run build && yes | cp dist/index.html ../../docs/source/installation/settings_base.html && cd ../..
|
||||
- cd scripts/settings && npm install && npm run build && yes | cp -v dist/index.html ../../docs/source/installation/settings.html && cd ../..
|
||||
|
||||
|
||||
sphinx:
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM webrecorder/browsertrix-crawler:1.4.2 AS base
|
||||
FROM webrecorder/browsertrix-crawler:1.12.4 AS base
|
||||
|
||||
ENV RUNNING_IN_DOCKER=1 \
|
||||
LANG=C.UTF-8 \
|
||||
@@ -11,26 +11,8 @@ ENV RUNNING_IN_DOCKER=1 \
|
||||
ARG TARGETARCH
|
||||
|
||||
# Installing system dependencies
|
||||
RUN add-apt-repository ppa:mozillateam/ppa && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends gcc ffmpeg fonts-noto exiftool && \
|
||||
apt-get install -y --no-install-recommends firefox-esr && \
|
||||
ln -s /usr/bin/firefox-esr /usr/bin/firefox
|
||||
|
||||
ARG GECKODRIVER_VERSION=0.36.0
|
||||
|
||||
RUN if [ $(uname -m) = "aarch64" ]; then \
|
||||
GECKODRIVER_ARCH=linux-aarch64; \
|
||||
else \
|
||||
GECKODRIVER_ARCH=linux64; \
|
||||
fi && \
|
||||
wget https://github.com/mozilla/geckodriver/releases/download/v${GECKODRIVER_VERSION}/geckodriver-v${GECKODRIVER_VERSION}-${GECKODRIVER_ARCH}.tar.gz && \
|
||||
tar -xvzf geckodriver* -C /usr/local/bin && \
|
||||
chmod +x /usr/local/bin/geckodriver && \
|
||||
rm geckodriver-v* && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends gcc ffmpeg fonts-noto exiftool python3-tk
|
||||
|
||||
# Poetry and runtime
|
||||
FROM base AS runtime
|
||||
@@ -59,11 +41,21 @@ COPY ./src/ .
|
||||
RUN /poetry-venv/bin/poetry install --only main --no-cache
|
||||
|
||||
|
||||
# Run as non-root user to avoid permission issues with mounted volumes (see #342)
|
||||
# The base image already has an 'ubuntu' user at UID/GID 1000.
|
||||
# Ensure directories that need write access at runtime are writable.
|
||||
RUN chown 1000:1000 /app && \
|
||||
chown -R 1000:1000 /app/.venv/lib/python3.12/site-packages/seleniumbase/drivers/ && \
|
||||
mkdir -p /app/local_archive /app/secrets /tmp/archive && \
|
||||
chown -R 1000:1000 /app/local_archive /app/secrets /tmp/archive
|
||||
|
||||
# Update PATH to include virtual environment binaries
|
||||
# Allowing entry point to run the application directly with Python
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
USER 1000
|
||||
|
||||
ENTRYPOINT ["python3", "-m", "auto_archiver"]
|
||||
|
||||
# should be executed with 2 volumes (3 if local_storage is used)
|
||||
|
||||
79
Makefile
Normal file
79
Makefile
Normal file
@@ -0,0 +1,79 @@
|
||||
# Variables
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = docs/source
|
||||
BUILDDIR = docs/_build
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
@echo "Additional Commands:"
|
||||
@echo " make test - Run all tests in 'tests/' with pytest"
|
||||
@echo " make ruff-check - Run Ruff linting and formatting checks (safe)"
|
||||
@echo " make ruff-clean - Auto-fix Ruff linting and formatting issues"
|
||||
@echo " make docs - Generate documentation (same as 'make html')"
|
||||
@echo " make clean-docs - Remove generated docs"
|
||||
@echo " make docker-build - Build the Auto Archiver Docker image"
|
||||
@echo " make docker-compose - Run Auto Archiver with Docker Compose"
|
||||
@echo " make docker-compose-rebuild - Rebuild and run Auto Archiver with Docker Compose"
|
||||
@echo " make show-docs - Build and open the documentation in a browser"
|
||||
|
||||
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@pytest tests --disable-warnings
|
||||
|
||||
|
||||
.PHONY: ruff-check
|
||||
ruff-check:
|
||||
@echo "Checking code style with Ruff (safe)..."
|
||||
@ruff check .
|
||||
|
||||
|
||||
.PHONY: ruff-clean
|
||||
ruff-clean:
|
||||
@echo "Fixing lint and formatting issues with Ruff..."
|
||||
@ruff check . --fix
|
||||
@ruff format .
|
||||
|
||||
|
||||
.PHONY: docs
|
||||
docs:
|
||||
@echo "Building documentation..."
|
||||
@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)"
|
||||
|
||||
|
||||
.PHONY: clean-docs
|
||||
clean-docs:
|
||||
@echo "Cleaning up generated documentation files..."
|
||||
@$(SPHINXBUILD) -M clean "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
@rm -rf "$(SOURCEDIR)/autoapi/" "$(SOURCEDIR)/modules/autogen/"
|
||||
@echo "Cleanup complete."
|
||||
|
||||
|
||||
.PHONY: show-docs
|
||||
show-docs:
|
||||
@echo "Opening documentation in browser..."
|
||||
@open "$(BUILDDIR)/html/index.html"
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build:
|
||||
@echo "Building local Auto Archiver Docker image..."
|
||||
@docker compose build # Uses the same build context as docker-compose.yml
|
||||
|
||||
.PHONY: docker-compose
|
||||
docker-compose:
|
||||
@echo "Running Auto Archiver with Docker Compose..."
|
||||
@docker compose up
|
||||
|
||||
.PHONY: docker-compose-rebuild
|
||||
docker-compose-rebuild:
|
||||
@echo "Rebuilding and running Auto Archiver with Docker Compose..."
|
||||
@docker compose up --build
|
||||
|
||||
# Catch-all for Sphinx commands
|
||||
.PHONY: Makefile
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
11
README.md
11
README.md
@@ -1,16 +1,17 @@
|
||||
<h1 align="center">Auto Archiver</h1>
|
||||
|
||||
[](https://auto-archiver.readthedocs.io/en/latest/?badge=latest)
|
||||
[](https://badge.fury.io/py/auto-archiver)
|
||||
[](https://hub.docker.com/r/bellingcat/auto-archiver)
|
||||
[](https://hub.docker.com/r/bellingcat/auto-archiver)
|
||||
[](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-core.yaml)
|
||||
[](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-download.yaml)
|
||||
<!-- [](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-download.yaml) -->
|
||||
|
||||
<!--  -->
|
||||
<!-- [](https://pypi.python.org/pypi/auto-archiver/) -->
|
||||
<!-- [](https://vk-url-scraper.readthedocs.io/en/latest/?badge=latest) -->
|
||||
|
||||
|
||||
|
||||
Auto Archiver is a Python tool to automatically archive content on the web in a secure and verifiable way. It takes URLs from different sources (e.g. a CSV file, Google Sheets, command line etc.) and archives the content of each one. It can archive social media posts, videos, images and webpages. Content can enriched, then saved either locally or remotely (S3 bucket, Google Drive). The status of the archiving process can be appended to a CSV report, or if using Google Sheets – back to the original sheet.
|
||||
Auto Archiver is a Python tool to automatically archive content on the web in a secure and verifiable way. It takes URLs from different sources (e.g. a CSV file, Google Sheets, command line etc.) and archives the content of each one. It can archive social media posts, videos, images and webpages. Content can be enriched, then saved either locally or remotely (S3 bucket, Google Drive). The status of the archiving process can be appended to a CSV report, or if using Google Sheets – back to the original sheet.
|
||||
|
||||
<div class="hidden_rtd">
|
||||
|
||||
@@ -29,7 +30,7 @@ View the [Installation Guide](https://auto-archiver.readthedocs.io/en/latest/ins
|
||||
|
||||
To get started quickly using Docker:
|
||||
|
||||
`docker pull bellingcat/auto-archiver && docker run --rm -v secrets:/app/secrets bellingcat/auto-archiver --config secrets/orchestration.yaml`
|
||||
`docker pull bellingcat/auto-archiver && docker run -it --rm -v secrets:/app/secrets bellingcat/auto-archiver --config secrets/orchestration.yaml`
|
||||
|
||||
Or pip:
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
auto-archiver:
|
||||
@@ -7,10 +6,10 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: auto-archiver
|
||||
# Override user to match host UID/GID and avoid permission issues on volumes.
|
||||
# Set USER_ID and GROUP_ID env vars, or defaults to 1000:1000.
|
||||
user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
|
||||
volumes:
|
||||
- ./secrets:/app/secrets
|
||||
- ./local_archive:/app/local_archive
|
||||
environment:
|
||||
- WACZ_ENABLE_DOCKER=true
|
||||
- RUNNING_IN_DOCKER=true
|
||||
command: --config secrets/orchestration.yaml
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
@@ -1 +1 @@
|
||||
from scripts import generate_module_docs
|
||||
from scripts import generate_module_docs
|
||||
|
||||
@@ -10,12 +10,12 @@ MODULES_FOLDER = Path(__file__).parent.parent.parent.parent / "src" / "auto_arch
|
||||
SAVE_FOLDER = Path(__file__).parent.parent / "source" / "modules" / "autogen"
|
||||
|
||||
type_color = {
|
||||
'feeder': "<span style='color: #FFA500'>[feeder](/core_modules.md#feeder-modules)</a></span>",
|
||||
'extractor': "<span style='color: #00FF00'>[extractor](/core_modules.md#extractor-modules)</a></span>",
|
||||
'enricher': "<span style='color: #0000FF'>[enricher](/core_modules.md#enricher-modules)</a></span>",
|
||||
'database': "<span style='color: #FF00FF'>[database](/core_modules.md#database-modules)</a></span>",
|
||||
'storage': "<span style='color: #FFFF00'>[storage](/core_modules.md#storage-modules)</a></span>",
|
||||
'formatter': "<span style='color: #00FFFF'>[formatter](/core_modules.md#formatter-modules)</a></span>",
|
||||
"feeder": "<span style='color: #FFA500'>[feeder](/core_modules.md#feeder-modules)</a></span>",
|
||||
"extractor": "<span style='color: #00FF00'>[extractor](/core_modules.md#extractor-modules)</a></span>",
|
||||
"enricher": "<span style='color: #0000FF'>[enricher](/core_modules.md#enricher-modules)</a></span>",
|
||||
"database": "<span style='color: #FF00FF'>[database](/core_modules.md#database-modules)</a></span>",
|
||||
"storage": "<span style='color: #FFFF00'>[storage](/core_modules.md#storage-modules)</a></span>",
|
||||
"formatter": "<span style='color: #00FFFF'>[formatter](/core_modules.md#formatter-modules)</a></span>",
|
||||
}
|
||||
|
||||
TABLE_HEADER = ("Option", "Description", "Default", "Type")
|
||||
@@ -34,6 +34,7 @@ steps:
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def generate_module_docs():
|
||||
yaml = YAML()
|
||||
SAVE_FOLDER.mkdir(exist_ok=True)
|
||||
@@ -46,51 +47,71 @@ def generate_module_docs():
|
||||
|
||||
for module in sorted(ModuleFactory().available_modules(), key=lambda x: (x.requires_setup, x.name)):
|
||||
# generate the markdown file from the __manifest__.py file.
|
||||
|
||||
manifest = module.manifest
|
||||
for type in manifest['type']:
|
||||
for type in manifest["type"]:
|
||||
modules_by_type.setdefault(type, []).append(module)
|
||||
|
||||
description = "\n".join(l.lstrip() for l in manifest['description'].split("\n"))
|
||||
types = ", ".join(type_color[t] for t in manifest['type'])
|
||||
description = "\n".join(line.lstrip() for line in manifest["description"].split("\n"))
|
||||
types = ", ".join(type_color[t] for t in manifest["type"])
|
||||
readme_str = f"""
|
||||
# {manifest['name']}
|
||||
# {manifest["name"]}
|
||||
```{{admonition}} Module type
|
||||
|
||||
{types}
|
||||
```
|
||||
{description}
|
||||
"""
|
||||
steps_str = "\n".join(f" {t}s:\n - {module.name}" for t in manifest['type'])
|
||||
"""
|
||||
steps_str = "\n".join(f" {t}s:\n - {module.name}" for t in manifest["type"])
|
||||
|
||||
if not manifest['configs']:
|
||||
if manifest.get("autodoc_dropins"):
|
||||
loaded_module = module.load({})
|
||||
dropins = loaded_module.load_dropins()
|
||||
dropin_str = "\n##### Available Dropins\n"
|
||||
for dropin in dropins:
|
||||
if not (ddoc := dropin.documentation()):
|
||||
continue
|
||||
dropin_str += f"\n###### {ddoc.get('name', dropin.__name__)}\n\n"
|
||||
dropin_str += f"{ddoc.get('description')}\n\n"
|
||||
if ddoc.get("site"):
|
||||
dropin_str += f"**Site**: {ddoc['site']}\n\n"
|
||||
if dauth := ddoc.get("authentication"):
|
||||
dropin_str += "**YAML configuration**:\n"
|
||||
dropin_auth_yaml = "authentication:\n...\n"
|
||||
for site, creds in dauth.items():
|
||||
dropin_auth_yaml += f" {site}:\n"
|
||||
for k, v in creds.items():
|
||||
dropin_auth_yaml += f' {k}: "{v}"\n'
|
||||
dropin_str += f"```{{code}} yaml\n{dropin_auth_yaml}...\n```\n"
|
||||
readme_str += dropin_str
|
||||
|
||||
if not manifest["configs"]:
|
||||
config_string = f"# No configuration options for {module.name}.*\n"
|
||||
else:
|
||||
|
||||
config_table = header_row
|
||||
config_yaml = {}
|
||||
|
||||
global_yaml[module.name] = CommentedMap()
|
||||
global_yaml.yaml_set_comment_before_after_key(module.name, f"\n\n{module.display_name} configuration options")
|
||||
global_yaml.yaml_set_comment_before_after_key(
|
||||
module.name, f"\n\n{module.display_name} configuration options"
|
||||
)
|
||||
|
||||
|
||||
for key, value in manifest['configs'].items():
|
||||
type = value.get('type', 'string')
|
||||
if type == 'json_loader':
|
||||
value['type'] = 'json'
|
||||
elif type == 'str':
|
||||
for key, value in manifest["configs"].items():
|
||||
type = value.get("type", "string")
|
||||
if type == "json_loader":
|
||||
value["type"] = "json"
|
||||
elif type == "str":
|
||||
type = "string"
|
||||
|
||||
default = value.get('default', '')
|
||||
|
||||
default = value.get("default", "")
|
||||
config_yaml[key] = default
|
||||
|
||||
global_yaml[module.name][key] = default
|
||||
|
||||
if value.get('help', ''):
|
||||
global_yaml[module.name].yaml_add_eol_comment(value.get('help', ''), key)
|
||||
if value.get("help", ""):
|
||||
global_yaml[module.name].yaml_add_eol_comment(value.get("help", ""), key)
|
||||
|
||||
help = "**Required**. " if value.get('required', False) else "Optional. "
|
||||
help += value.get('help', '')
|
||||
help = "**Required**. " if value.get("required", False) else "Optional. "
|
||||
help += value.get("help", "")
|
||||
config_table += f"| `{module.name}.{key}` | {help} | {value.get('default', '')} | {type} |\n"
|
||||
global_table += f"| `{module.name}.{key}` | {help} | {default} | {type} |\n"
|
||||
readme_str += "\n## Configuration Options\n"
|
||||
@@ -98,18 +119,18 @@ def generate_module_docs():
|
||||
|
||||
config_string = io.BytesIO()
|
||||
yaml.dump({module.name: config_yaml}, config_string)
|
||||
config_string = config_string.getvalue().decode('utf-8')
|
||||
config_string = config_string.getvalue().decode("utf-8")
|
||||
yaml_string = EXAMPLE_YAML.format(steps_str=steps_str, config_string=config_string)
|
||||
readme_str += f"```{{code}} yaml\n{yaml_string}\n```\n"
|
||||
|
||||
if manifest['configs']:
|
||||
if manifest["configs"]:
|
||||
readme_str += "\n### Command Line:\n"
|
||||
readme_str += config_table
|
||||
|
||||
# add a link to the autodoc refs
|
||||
readme_str += f"\n[API Reference](../../../autoapi/{module.name}/index)\n"
|
||||
# create the module.type folder, use the first type just for where to store the file
|
||||
for type in manifest['type']:
|
||||
for type in manifest["type"]:
|
||||
type_folder = SAVE_FOLDER / type
|
||||
type_folder.mkdir(exist_ok=True)
|
||||
with open(type_folder / f"{module.name}.md", "w") as f:
|
||||
@@ -117,10 +138,10 @@ def generate_module_docs():
|
||||
f.write(readme_str)
|
||||
generate_index(modules_by_type)
|
||||
|
||||
del global_yaml['placeholder']
|
||||
del global_yaml["placeholder"]
|
||||
global_string = io.BytesIO()
|
||||
global_yaml = yaml.dump(global_yaml, global_string)
|
||||
global_string = global_string.getvalue().decode('utf-8')
|
||||
global_string = global_string.getvalue().decode("utf-8")
|
||||
global_yaml = f"```yaml\n{global_string}\n```"
|
||||
with open(SAVE_FOLDER / "configs_cheatsheet.md", "w") as f:
|
||||
f.write("### Configuration File\n" + global_yaml + "\n### Command Line\n" + global_table)
|
||||
@@ -144,4 +165,4 @@ def generate_index(modules_by_type):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_module_docs()
|
||||
generate_module_docs()
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from importlib.metadata import metadata
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.append(os.path.abspath('../scripts'))
|
||||
sys.path.append(os.path.abspath("../scripts"))
|
||||
from scripts import generate_module_docs
|
||||
from auto_archiver.version import __version__
|
||||
|
||||
@@ -20,33 +20,35 @@ project = package_metadata["name"]
|
||||
copyright = str(datetime.now().year)
|
||||
author = "Bellingcat"
|
||||
release = package_metadata["version"]
|
||||
language = 'en'
|
||||
language = "en"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
extensions = [
|
||||
"myst_parser", # Markdown support
|
||||
"autoapi.extension", # Generate API documentation from docstrings
|
||||
"sphinxcontrib.mermaid", # Mermaid diagrams
|
||||
"sphinx.ext.viewcode", # Source code links
|
||||
"myst_parser", # Markdown support
|
||||
"autoapi.extension", # Generate API documentation from docstrings
|
||||
"sphinxcontrib.mermaid", # Mermaid diagrams
|
||||
"sphinx.ext.viewcode", # Source code links
|
||||
"sphinx_copybutton",
|
||||
"sphinx.ext.napoleon", # Google-style and NumPy-style docstrings
|
||||
"sphinx.ext.napoleon", # Google-style and NumPy-style docstrings
|
||||
"sphinx.ext.autosectionlabel",
|
||||
# 'sphinx.ext.autosummary', # Summarize module/class/function docs
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ""]
|
||||
|
||||
|
||||
# -- AutoAPI Configuration ---------------------------------------------------
|
||||
autoapi_type = 'python'
|
||||
autoapi_type = "python"
|
||||
autoapi_dirs = ["../../src/auto_archiver/core/", "../../src/auto_archiver/utils/"]
|
||||
# get all the modules and add them to the autoapi_dirs
|
||||
autoapi_dirs.extend([f"../../src/auto_archiver/modules/{m}" for m in os.listdir("../../src/auto_archiver/modules")])
|
||||
autodoc_typehints = "signature" # Include type hints in the signature
|
||||
autoapi_ignore = ["*/version.py", ] # Ignore specific modules
|
||||
autoapi_keep_files = True # Option to retain intermediate JSON files for debugging
|
||||
autoapi_add_toctree_entry = True # Include API docs in the TOC
|
||||
autodoc_typehints = "signature" # Include type hints in the signature
|
||||
autoapi_ignore = [
|
||||
"*/version.py",
|
||||
] # Ignore specific modules
|
||||
autoapi_keep_files = True # Option to retain intermediate JSON files for debugging
|
||||
autoapi_add_toctree_entry = True # Include API docs in the TOC
|
||||
autoapi_python_use_implicit_namespaces = True
|
||||
autoapi_template_dir = "../_templates/autoapi"
|
||||
autoapi_options = [
|
||||
@@ -59,13 +61,13 @@ autoapi_options = [
|
||||
|
||||
# -- Markdown Support --------------------------------------------------------
|
||||
myst_enable_extensions = [
|
||||
"deflist", # Definition lists
|
||||
"html_admonition", # HTML-style admonitions
|
||||
"html_image", # Inline HTML images
|
||||
"replacements", # Substitutions like (C)
|
||||
"smartquotes", # Smart quotes
|
||||
"linkify", # Auto-detect links
|
||||
"substitution", # Text substitutions
|
||||
"deflist", # Definition lists
|
||||
"html_admonition", # HTML-style admonitions
|
||||
"html_image", # Inline HTML images
|
||||
"replacements", # Substitutions like (C)
|
||||
"smartquotes", # Smart quotes
|
||||
"linkify", # Auto-detect links
|
||||
"substitution", # Text substitutions
|
||||
]
|
||||
myst_heading_anchors = 2
|
||||
myst_fence_as_directive = ["mermaid"]
|
||||
@@ -76,7 +78,7 @@ source_suffix = {
|
||||
}
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
html_theme = 'sphinx_book_theme'
|
||||
html_theme = "sphinx_book_theme"
|
||||
html_static_path = ["../_static"]
|
||||
html_css_files = ["custom.css"]
|
||||
html_title = f"Auto Archiver v{__version__}"
|
||||
@@ -87,7 +89,6 @@ html_theme_options = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
copybutton_prompt_text = r">>> |\.\.\."
|
||||
copybutton_prompt_is_regexp = True
|
||||
copybutton_only_copy_prompt_lines = False
|
||||
copybutton_only_copy_prompt_lines = False
|
||||
|
||||
@@ -21,7 +21,7 @@ This allows you to run the auto-archiver without the `poetry run` prefix.
|
||||
### Optional Development Packages
|
||||
|
||||
Install development packages (used for unit tests etc.) using:
|
||||
`poetry install -with dev`
|
||||
`poetry install --with dev`
|
||||
|
||||
|
||||
```{toctree}
|
||||
@@ -32,4 +32,5 @@ testing
|
||||
docs
|
||||
release
|
||||
settings_page
|
||||
```
|
||||
style_guide
|
||||
```
|
||||
|
||||
@@ -36,3 +36,12 @@ open docs/_build/html/index.html
|
||||
sphinx-autobuild docs/source docs/_build/html
|
||||
```
|
||||
|
||||
|
||||
### Managing Readthedocs (RTD) Versions
|
||||
|
||||
Version management is done at [https://app.readthedocs.org/projects/auto-archiver/](https://app.readthedocs.org/projects/auto-archiver/)
|
||||
(login required). Once logged in, you can create new versions, delete old versions or change visibility of versions. More info on
|
||||
[RTD](https://docs.readthedocs.com/platform/stable/versions.html).
|
||||
|
||||
Currently, the Auto Archiver project is set up to automatically create a new docs version for each `vX.Y.Z` release. For more on this,
|
||||
see the RTD [instructions on automation](https://docs.readthedocs.com/platform/stable/guides/automation-rules.html) or edit the existing automation rule in the project settings.
|
||||
70
docs/source/development/style_guide.md
Normal file
70
docs/source/development/style_guide.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Style Guide
|
||||
|
||||
|
||||
The project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.
|
||||
Our style configurations are set in the `pyproject.toml` file. If needed, you can modify them there.
|
||||
|
||||
|
||||
### **Formatting (Auto-Run Before Commit) 🛠️**
|
||||
|
||||
We have a pre-commit hook to run the formatter before you commit.
|
||||
This requires you to set it up once locally, then it will run automatically when you commit changes.
|
||||
|
||||
```shell
|
||||
poetry run pre-commit install
|
||||
```
|
||||
|
||||
Ruff can also be to run automatically.
|
||||
Alternative: Ruff can also be [integrated with most editors](https://docs.astral.sh/ruff/editors/setup/) for real-time formatting.
|
||||
|
||||
If you wish to disable the pre-commit hook (for example, if you want to commit some WIP code) you can use the `--no-verify` flag when you commit.
|
||||
For example: `git commit -m "WIP Code" --no-verify`
|
||||
|
||||
### **Linting (Check Before Pushing) 🔍**
|
||||
|
||||
We recommend you also run the linter before pushing code.
|
||||
|
||||
We have [Makefile](../../../Makefile) commands to run common tasks.
|
||||
|
||||
Tip: if you're on Windows you might need to install `make` first, or alternatively you can use ruff commands directly.
|
||||
|
||||
|
||||
**Lint Check:** This outputs a report of any issues found, without attempting to fix them:
|
||||
```shell
|
||||
make ruff-check
|
||||
```
|
||||
|
||||
Tip: To see a more detailed linting report, you can remove the following line from the `pyproject.toml` file:
|
||||
```toml
|
||||
[tool.ruff]
|
||||
|
||||
# Remove this for a more detailed lint report
|
||||
output-format = "concise"
|
||||
```
|
||||
|
||||
**Lint Fix:** This command will attempt to fix some of the issues it picked up with the lint check.
|
||||
|
||||
Note not all warnings can be fixed automatically.
|
||||
|
||||
⚠️ Warning: This can cause breaking changes. ⚠️
|
||||
|
||||
Most fixes are safe, but some non-standard practices such as dynamic loading are not picked up by linters. Ensure you check any modifications by this before committing them.
|
||||
```shell
|
||||
make ruff-clean
|
||||
```
|
||||
|
||||
**Changing Configurations ⚙️**
|
||||
|
||||
|
||||
Our rules are quite lenient for general usage, but if you want to run more rigorous checks you can then run checks with additional rules to see more nuanced errors which you can review manually.
|
||||
Check out the [ruff documentation](https://docs.astral.sh/ruff/configuration/) for the full list of rules.
|
||||
One example is to extend the selected rules for linting the `pyproject.toml` file:
|
||||
|
||||
```toml
|
||||
[tool.ruff.lint]
|
||||
# Extend the rules to check for by adding them to this option:
|
||||
# See documentation for more details: https://docs.astral.sh/ruff/rules/
|
||||
extend-select = ["B"]
|
||||
```
|
||||
|
||||
Then re-run the `make ruff-check` command to see the new rules in action.
|
||||
@@ -3,14 +3,14 @@
|
||||
`pytest` is used for testing. There are two main types of tests:
|
||||
|
||||
1. 'core' tests which should be run on every change
|
||||
2. 'download' tests which hit the network. These tests will do things like make API calls (e.g. Twitter, Bluesky etc.) and should be run regularly to make sure that APIs have not changed.
|
||||
2. 'download' tests which hit the network. These tests will do things like make API calls (e.g. Twitter, Bluesky etc.) and should be run regularly to make sure that APIs have not changed, they take longer.
|
||||
|
||||
|
||||
## Running Tests
|
||||
|
||||
1. Make sure you've installed the dev dependencies with `pytest install --with dev`
|
||||
1. Make sure you've installed the dev dependencies with `poetry install --with dev`
|
||||
2. Tests can be run as follows:
|
||||
```
|
||||
```{code} bash
|
||||
#### Command prefix of 'poetry run' removed here for simplicity
|
||||
# run core tests
|
||||
pytest -ra -v -m "not download"
|
||||
@@ -18,4 +18,15 @@ pytest -ra -v -m "not download"
|
||||
pytest -ra -v -m "download"
|
||||
# run all tests
|
||||
pytest -ra -v
|
||||
```
|
||||
|
||||
|
||||
# run a specific test file
|
||||
pytest -ra -v tests/test_file.py
|
||||
# run a specific test function
|
||||
pytest -ra -v tests/test_file.py::test_function_name
|
||||
```
|
||||
|
||||
3. Some tests require environment variables to be set. You can use the example `tests/.env.test.example` file as a template. Copy it to `tests/.env.test` and fill in the required values. This file will be loaded automatically by `pytest`.
|
||||
```{code} bash
|
||||
cp tests/.env.test.example tests/.env.test
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ The archiver archives web pages using the following workflow
|
||||
4. **Formatter** creates a report from all the archived content (HTML, PDF, ...)
|
||||
5. **Database** knows what's been archived and also stores the archive result (spreadsheet, CSV, or just the console)
|
||||
|
||||
Each step in the workflow is handled by 'modules' that interact with the data in different ways. For example, the Twitter Extractor Module would extract information from the Twitter website. The Screenshot Enricher Module will take screenshots of the given page. See the [core modules page](core_modules.md) for an overview of all the modules that are available.
|
||||
Each step in the workflow is handled by 'modules' that interact with the data in different ways. For example, the Twitter Extractor Module would extract information from the Twitter website. The AntiBot Module will download HTML and take screenshots of the given page. See the [core modules page](core_modules.md) for an overview of all the modules that are available.
|
||||
|
||||
Auto-archiver must have at least one module defined for each step of the workflow. This is done by setting the [configuration](installation/configurations.md) for your auto-archiver instance.
|
||||
|
||||
|
||||
@@ -106,5 +106,117 @@ Finally,Some important things to remember:
|
||||
|
||||
## Authenticating on XXXX site with username/password
|
||||
|
||||
```{note} This section is still under construction 🚧
|
||||
```{note}
|
||||
This section is still under construction 🚧
|
||||
```
|
||||
|
||||
|
||||
# Proof of Origin Tokens
|
||||
|
||||
YouTube uses **Proof of Origin Tokens (POT)** as part of its bot detection system to verify that requests originate from valid clients. If a token is missing or invalid, some videos may return errors like "Sign in to confirm you're not a bot."
|
||||
|
||||
yt-dlp provides [a detailed guide to POTs](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide).
|
||||
|
||||
### How Auto Archiver Uses POT
|
||||
This feature is enabled for the Generic Archiver via two yt-dlp plugins:
|
||||
|
||||
- **Client-side plugin**: [yt-dlp-get-pot](https://github.com/coletdjnz/yt-dlp-get-pot)
|
||||
Detects when a token is required and requests one from a provider.
|
||||
|
||||
- **Provider plugin**: [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
|
||||
Includes both a Python plugin and a **Node.js server or script** to generate the token.
|
||||
|
||||
These are installed in our Poetry environment.
|
||||
|
||||
### Integration Methods
|
||||
|
||||
**Docker (Recommended)**:
|
||||
|
||||
When running the Auto Archiver using the Docker image, we use the [Node.js token generation script](https://github.com/Brainicism/bgutil-ytdlp-pot-provider/tree/master/server).
|
||||
This is to avoid managing a separate server process, and is handled automatically inside the Docker container when needed.
|
||||
|
||||
This is already included in the Docker image, however if you need to disable this you can set the config option `bguils_po_token_method` under the `generic_extractor` section of your `orchestration.yaml` config file to "disabled".
|
||||
```yaml
|
||||
generic_extractor:
|
||||
bguils_po_token_method: "disabled"
|
||||
```
|
||||
|
||||
**PyPi/ Local**:
|
||||
|
||||
When using the Auto Archiver PyPI package, or running locally, you will need additional system requirements to run the token generation script, namely either Docker, or Node.js and Yarn.
|
||||
|
||||
See the [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider?tab=readme-ov-file#a-http-server-option) documentation for more details.
|
||||
|
||||
⚠️WARNING⚠️: This will add the server scripts to the home directory of wherever this is running.
|
||||
|
||||
- You can set the config option `bguils_po_token_method` under the `generic_extractor` section of your `orchestration.yaml` config file to "script" to enable the token generation script process locally.
|
||||
- Alternatively you can run the bgutil-ytdlp-pot-provider server separately using their Docker image or Node.js server.
|
||||
|
||||
### Notes
|
||||
|
||||
- The token generation script is only triggered when needed by yt-dlp, so it should have no effect unless YouTube requests a POT.
|
||||
- If you're running the Auto Archiver in Docker, this is set up automatically.
|
||||
- If you're running locally, you'll need to run the setup script manually or enable the feature in your config.
|
||||
- You can set up both the server and the script, and the plugin will fallback on each other if needed. This is recommended for robustness!
|
||||
|
||||
### Configurations:
|
||||
|
||||
## Configurations Summary
|
||||
|
||||
| Option | Behavior | Docker Default? |
|
||||
|------------| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||
| `auto` | Docker: Automatically downloads and uses the token generation script. Local: Does nothing; assumes a separate server is running externally. | ✅ Yes |
|
||||
| `script` | Explicitly downloads and uses the token generation script, even locally. | ❌ No |
|
||||
| `disabled` | Disables token generation completely. | ❌ No |
|
||||
|
||||
Example configuration:
|
||||
|
||||
|
||||
```yaml
|
||||
generic_extractor:
|
||||
# ...
|
||||
bguils_po_token_method: "script"
|
||||
# For debugging add the verbose flag here:
|
||||
ytdlp_args: "--no-abort-on-error --abort-on-error --verbose"
|
||||
|
||||
```
|
||||
|
||||
**Advanced Configuration:**
|
||||
|
||||
If you change the default port of the bgutil-ytdlp-pot-provider server, you can pass the updated values using our `extractor_args` option for the gereric extractor.
|
||||
|
||||
```yaml
|
||||
generic_extractor:
|
||||
ytdlp_args: "--no-abort-on-error --abort-on-error --verbose"
|
||||
ytdlp_update_interval: 5
|
||||
bguils_po_token_method: "script"
|
||||
extractor_args:
|
||||
youtube:
|
||||
getpot_bgutil_baseurl: "http://127.0.0.1:8080"
|
||||
player_client: web,tv
|
||||
```
|
||||
For more details on this for bgutils see [here](https://github.com/Brainicism/bgutil-ytdlp-pot-provider?tab=readme-ov-file#usage)
|
||||
|
||||
### Checking the logs
|
||||
|
||||
To verify that the POT process working, look for the following lines in your log after adding the config option:
|
||||
|
||||
```shell
|
||||
[GetPOT] BgUtilScript: Generating POT via script: /Users/you/bgutil-ytdlp-pot-provider/server/build/generate_once.js
|
||||
[debug] [GetPOT] BgUtilScript: Executing command to get POT via script: /Users/you/.nvm/versions/node/v20.18.0/bin/node /Users/you/bgutil-ytdlp-pot-provider/server/build/generate_once.js -v ymCMy8OflKM
|
||||
[debug] [GetPOT] BgUtilScript: stdout:
|
||||
{"poToken":"MlMxojNFhEJvUzGeHEkVRSK_luXtwcDnwSNIOgaUutqB7t99nmlNvtWgYayboopG6ZopZgmQ-6PJCWEMHv89MIiFGGlJRY25Fkwzxmia_8uYgf5AWf==","generatedAt":"2025-03-26T10:45:26.156Z","visitIdentifier":"ymCMy8OflKM"}
|
||||
[debug] [GetPOT] Fetching gvs PO Token for tv client
|
||||
```
|
||||
|
||||
If it can't find the script or something, you'll see something like this:
|
||||
```shell
|
||||
[debug] [GetPOT] Fetching player PO Token for tv client
|
||||
WARNING: [GetPOT] BgUtilScript: Script path doesn't exist: /Users/you/bgutil-ytdlp-pot-provider/server/build/generate_once.js. Please make sure the script has been transpiled correctly.
|
||||
WARNING: [GetPOT] BgUtilHTTP: Error reaching GET http://127.0.0.1:4416/ping (caused by TransportError). Please make sure that the server is reachable at http://127.0.0.1:4416.
|
||||
[debug] [GetPOT] No player PO Token provider available for tv client
|
||||
```
|
||||
|
||||
In this case check that the script has been transpiled correctly and is available at the path specified in the log,
|
||||
or that the server is running and reachable.
|
||||
|
||||
@@ -6,12 +6,43 @@ This guide explains how to set up Google Sheets to process URLs automatically an
|
||||
2. Setting up a service account so Auto Archiver can access the sheet
|
||||
3. Setting the Auto Archiver settings
|
||||
|
||||
### 1. Setting up your Google Sheet
|
||||
|
||||
Any Google sheet must have at least *one* column, with the name 'link' (you can change this name afterwards). This is the column with the URLs that you want the Auto Archiver to archive.
|
||||
Your sheet can have many other columns that the Auto Archiver can use, and you can also include any additional columns for your own personal use. The order of the columns does not matter, the naming just needs to be correctly assigned to its corresponding value in the configuration file.
|
||||
## 1. Setting up a Google Service Account
|
||||
|
||||
We recommend copying [this template Google Sheet](https://docs.google.com/spreadsheets/d/1NJZo_XZUBKTI1Ghlgi4nTPVvCfb0HXAs6j5tNGas72k/edit?usp=sharing) as a starting point for your project, as this matches the default column names.
|
||||
Once your Google Sheet is set up, you need to create what's called a 'service account' that will allow the Auto Archiver to access it.
|
||||
|
||||
To do this, you can either:
|
||||
* a) follow the steps in [this guide](https://gspread.readthedocs.io/en/latest/oauth2.html) all the way up until step 8. You should have downloaded a file called `service_account.json` and should save it in the `secrets/` folder
|
||||
* b) run the following script to automatically generate the file:
|
||||
```{code} bash
|
||||
https://raw.githubusercontent.com/bellingcat/auto-archiver/refs/heads/main/scripts/generate_google_services.sh | bash -s --
|
||||
```
|
||||
This uses gcloud to create a new project, a new user and downloads the service account automatically for you. The service account file will have the name `service_account-XXXXXXX.json` where XXXXXXX is a random 16 letter/digit string for the project created.
|
||||
|
||||
```{note}
|
||||
To save the generated file to a different folder, pass an argument as follows:
|
||||
```{code} bash
|
||||
https://raw.githubusercontent.com/bellingcat/auto-archiver/refs/heads/main/scripts/generate_google_services.sh | bash -s -- /path/to/secrets
|
||||
```
|
||||
|
||||
----------
|
||||
|
||||
Once you've downloaded the file, you can save it to `secrets/service_account.json` (the default name), or to another file and then change the location in the settings (see step 4).
|
||||
|
||||
Also make sure to **note down** the email address for this service account. You'll need that for step 3.
|
||||
|
||||
```{note}
|
||||
The email address created in this step can be found either by opening the `service_account.json` file, or if you used b) the `generate_google_services.sh` script, then the script will have printed it out for you.
|
||||
|
||||
The email address will look something like `user@project-name.iam.gserviceaccount.com`
|
||||
```
|
||||
|
||||
|
||||
## 2. Setting up your Google Sheet
|
||||
|
||||
We recommend copying [this template Google Sheet](https://docs.google.com/spreadsheets/d/1NJZo_XZUBKTI1Ghlgi4nTPVvCfb0HXAs6j5tNGas72k/edit?usp=sharing) as a starting point for your project, as this matches all the columns required.
|
||||
|
||||
But if you like, you can also create your own custom sheet. The only columns required are 'link', 'archive status', and 'archive location'. 'link' is the column with the URLs that you want the Auto Archiver to archive, the other two record the archival status and result.
|
||||
|
||||
Here's an overview of all the columns, and what a complete sheet would look like.
|
||||
|
||||
@@ -46,21 +77,18 @@ In this example the Ghseet Feeder and Gsheet DB are being used, and the archive
|
||||
|
||||

|
||||
|
||||
We'll change the name of the 'Destination Folder' column in step 3.
|
||||
We'll change the name of the 'Destination Folder' column in the Step 4a.
|
||||
|
||||
## 2. Setting up your Service Account
|
||||
## 3. Share your Google Sheet with your Service Account email address
|
||||
|
||||
Once your Google Sheet is set up, you need to create what's called a 'service account' that will allow the Auto Archiver to access it.
|
||||
Remember that email address you copied in Step 1? Now that you've set up your Google sheet, click 'Share' in the top
|
||||
right hand corner and enter the email address. Make sure to give the account **Editor** access. Here's how that looks:
|
||||
|
||||
To do this, follow the steps in [this guide](https://gspread.readthedocs.io/en/latest/oauth2.html) all the way up until step 8. You should have downloaded a file called `service_account.json` and shared the Google Sheet with the log 'client_email' email address in this file.
|
||||

|
||||
|
||||
Once you've downloaded the file, save it to `secrets/service_account.json`
|
||||
## 4. Setting up the configuration file
|
||||
|
||||
## 3. Setting up the configuration file
|
||||
|
||||
Now that you've set up your Google sheet, and you've set up the service account so Auto Archiver can access the sheet, the final step is to set your configuration.
|
||||
|
||||
First, make sure you have `gsheet_feeder_db` set in the `steps.feeders` section of your config. If you wish to store the results of the archiving process back in your Google sheet, make sure to also set the `ghseet_db` settig in the `steps.databases` section. Here's how this might look:
|
||||
The final step is to set your configuration. First, make sure you have `gsheet_feeder_db` set in the `steps.feeders` section of your config. If you wish to store the results of the archiving process back in your Google sheet, make sure to also put `gsheet_feeder_db` setting in the `steps.databases` section. Here's how this might look:
|
||||
|
||||
```{code} yaml
|
||||
steps:
|
||||
@@ -75,22 +103,25 @@ steps:
|
||||
Next, set up the `gsheet_feeder_db` configuration settings in the 'Configurations' part of the config `orchestration.yaml` file. Open up the file, and set the `gsheet_feeder_db.sheet` setting or the `gsheet_feeder_db.sheet_id` setting. The `sheet` should be the name of your sheet, as it shows in the top left of the sheet.
|
||||
For example, the sheet [here](https://docs.google.com/spreadsheets/d/1NJZo_XZUBKTI1Ghlgi4nTPVvCfb0HXAs6j5tNGas72k/edit?gid=0#gid=0) is called 'Public Auto Archiver template'.
|
||||
|
||||
If you saved your `service_account.json` file to anywhere other than the default location (`secrets/service_account.json`), then also make sure to change that now:
|
||||
|
||||
Here's how this might look:
|
||||
|
||||
```{code} yaml
|
||||
...
|
||||
gsheet_feeder_db:
|
||||
sheet: 'My Awesome Sheet'
|
||||
service_account: secrets/service_account-XXXXX.json # or leave as secrets/service_account.json
|
||||
...
|
||||
```
|
||||
|
||||
You can also pass these settings directly on the command line without having to edit the file, here'a an example of how to do that (using docker):
|
||||
|
||||
`docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver:dockerize --gsheet_feeder_db.sheet "My Awesome Sheet 2"`.
|
||||
`docker run -it --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver:dockerize --gsheet_feeder_db.sheet "My Awesome Sheet 2"`.
|
||||
|
||||
Here, the sheet name has been overridden/specified in the command line invocation.
|
||||
|
||||
### 3a. (Optional) Changing the column names
|
||||
### 4a. (Optional) Changing the column names
|
||||
|
||||
In step 1, we said we would change the name of the 'Destination Folder'. Perhaps you don't like this name, or already have a sheet with a different name. In our example here, we want to name this column 'Save Folder'. To do this, we need to edit the `ghseet_feeder_db.column` setting in the configuration file.
|
||||
For more information on this setting, see the [Gsheet Feeder Database docs](../modules/autogen/feeder/gsheet_feeder_db.md#configuration-options). We will first copy the default settings from the Gsheet Feeder docs for the 'column' settings, and then edit the 'Destination Folder' section to rename it 'Save Folder'. Our final configuration section looks like:
|
||||
104
docs/source/how_to/03_logging.md
Normal file
104
docs/source/how_to/03_logging.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Keeping Logs
|
||||
|
||||
Auto Archiver's logs can be helpful for debugging problematic archiving processes. This guide shows you how to use the logs configuration.
|
||||
|
||||
## Setting up logging
|
||||
|
||||
Logging settings can be set on the command line or using the orchestration config file ([learn more](../installation/configuration)). A special `logging` section defines the logging options.
|
||||
|
||||
#### Enabling or Disabling Logging
|
||||
|
||||
Logging to the console is enabled by default. If you want to globally disable Auto Archiver's logging, then you can set `enabled: false` in your `logging` config file:
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
...
|
||||
logging:
|
||||
enabled: false
|
||||
...
|
||||
```
|
||||
|
||||
```{note}
|
||||
This will disable all logs from Auto Archiver, but it does not disable logs for other tools that the Auto Archiver uses (for example: yt-dlp, firefox or ffmpeg). These logs will still appear in your console.
|
||||
```
|
||||
|
||||
#### Logging Level
|
||||
|
||||
There are 7 logging levels in total, with 5 of them used in this tool. They are: `DEBUG`, `INFO`, `SUCCESS`, `WARNING` and `ERROR`. If you select a level, only that and higher (more serious) levels will be included. `DEBUG` is the most verbose, while `ERROR` is the least verbose.
|
||||
|
||||
Change the warning level by setting the value in your orchestration config file:
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
...
|
||||
logging:
|
||||
level: DEBUG # or INFO / WARNING / ERROR
|
||||
...
|
||||
```
|
||||
|
||||
For normal usage, it is recommended to use the `INFO` level, or if you prefer quieter logs with less information, you can use the `WARNING` level. If you encounter issues with the archiving, then it's recommended to enable the `DEBUG` level.
|
||||
|
||||
```{note} To learn about all logging levels, see the [loguru documentation](https://loguru.readthedocs.io/en/stable/api/logger.html)
|
||||
```
|
||||
|
||||
### Logging Format
|
||||
By default, the console logs are formatted in a human-readable way and the file logs are formatted in JSON. This is new from version 1.1.1. If you want to change the format of the console logs to JSON too you can set the `format:` option in your logging settings.
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
format: json
|
||||
```
|
||||
|
||||
When the Auto Archiver is writing logs it will include context about specific tasks, so if you are archiving a URL from a Google Sheet, both the URL (and a unique `trace_id` for that URL's archiving attempt) and the Spreadsheet name and row will be included in the logs. This is useful for debugging and understanding what the Auto Archiver is doing.
|
||||
|
||||
Using JSON allows you to easily parse the logs and extract specific information, tools like [`jq`](https://jqlang.org/) can be used to filter and search through the logs.
|
||||
|
||||
### Logging to a file
|
||||
|
||||
As default, auto-archiver will log to the console. But if you wish to store your logs for future reference, or you are running the auto-archiver from within code a implementation, then you may wish to enable file logging. This can be done by setting the `file:` config value in the logging settings.
|
||||
|
||||
**Rotation:** For file logging, you can choose to 'rotate' your log files (creating new log files) so they do not get too large. Change this by setting the 'rotation' option in your logging settings. For a full list of rotation options, see the [loguru docs](https://loguru.readthedocs.io/en/stable/overview.html#easier-file-logging-with-rotation-retention-compression).
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
...
|
||||
file: /my/log/file.log
|
||||
rotation: 1 day
|
||||
```
|
||||
|
||||
### Logging each level to a different file
|
||||
If you want to log each level to a different file, you can do this by setting the `each_level_in_separate_file:` option to `true` and also setting your `file:` name, a new file will be created for each of the 5 levels used, by appending the `0_level` name to the file like so `your_file.log.1_error`. In this case the `level:` option is ignored, and all levels will be logged.
|
||||
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
each_level_in_separate_file: true
|
||||
file: /my/logs/file.log
|
||||
```
|
||||
This will create the following files:
|
||||
- `/my/logs/file.log.1_debug`
|
||||
- `/my/logs/file.log.2_info`
|
||||
- `/my/logs/file.log.3_success`
|
||||
- `/my/logs/file.log.4_warning`
|
||||
- `/my/logs/file.log.5_error`
|
||||
|
||||
### Full logging example
|
||||
|
||||
The below example logs only `DEBUG` logs to the console and to the file `/my/file.log`, rotating that file once per week:
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
level: DEBUG
|
||||
format: json
|
||||
file: /my/file.log
|
||||
rotation: 1 week
|
||||
```
|
||||
169
docs/source/how_to/04_run_instagrapi_server.md
Normal file
169
docs/source/how_to/04_run_instagrapi_server.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# InstagrAPI Server
|
||||
|
||||
The instagram API Extractor requires access to a running instance of the InstagrAPI server.
|
||||
We have a lightweight script with the endpoints required for our Instagram API Extractor module which you can run locally, or via Docker.
|
||||
|
||||
|
||||
|
||||
⚠️ Warning: Remember that it's best not to use your own personal account for archiving. [Here's why](../installation/authentication.md#recommendations-for-authentication).
|
||||
## Quick Start: Using Docker
|
||||
|
||||
We've provided a convenient shell script (`run_instagrapi_server.sh`) that simplifies the process of setting up and running the Instagrapi server in Docker. This script handles building the Docker image, setting up credentials, and starting the container.
|
||||
|
||||
### 🔧 Running the script:
|
||||
|
||||
Run this script either from the repository root or from within the `scripts/instagrapi_server` directory:
|
||||
|
||||
```bash
|
||||
./scripts/instagrapi_server/run_instagrapi_server.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Prompt for your Instagram username and password.
|
||||
- Create the necessary `.env` file.
|
||||
- Build the Docker image.
|
||||
- Start the Docker container and authenticate with Instagram, creating a session automatically.
|
||||
|
||||
### ⏱ To run the server again later:
|
||||
```bash
|
||||
docker start ig-instasrv
|
||||
```
|
||||
|
||||
### 🐛 Debugging:
|
||||
View logs:
|
||||
```bash
|
||||
docker logs ig-instasrv
|
||||
```
|
||||
|
||||
|
||||
### Overview: How the Setup Works
|
||||
|
||||
1. You enter your Instagram credentials in a local `.env` file
|
||||
2. You run the server **once locally** to generate a session file
|
||||
3. After that, you can choose to run the server again locally or inside Docker without needing to log in again
|
||||
|
||||
---
|
||||
|
||||
## Optional: Manual / Local Setup
|
||||
|
||||
If you'd prefer to run the server manually (without Docker), you can follow these steps:
|
||||
|
||||
|
||||
1. **Navigate to the server folder (and stay there for the rest of this guide)**:
|
||||
```bash
|
||||
cd scripts/instagrapi_server
|
||||
```
|
||||
|
||||
2. **Create a `secrets/` folder** (if it doesn't already exist in `scripts/instagrapi_server`):
|
||||
```bash
|
||||
mkdir -p secrets
|
||||
```
|
||||
|
||||
3. **Create a `.env` file** inside `secrets/` with your Instagram credentials:
|
||||
```dotenv
|
||||
INSTAGRAM_USERNAME="your_username"
|
||||
INSTAGRAM_PASSWORD="your_password"
|
||||
```
|
||||
|
||||
4. **Install dependencies** using the pyproject.toml file:
|
||||
|
||||
```bash
|
||||
poetry install --no-root
|
||||
```
|
||||
|
||||
5. **Run the server locally**:
|
||||
```bash
|
||||
poetry run uvicorn src.instaserver:app --port 8000
|
||||
```
|
||||
|
||||
6. **Watch for the message**:
|
||||
```
|
||||
Login successful, session saved.
|
||||
```
|
||||
|
||||
✅ Your session is now saved to `secrets/instagrapi_session.json`.
|
||||
|
||||
### To run it again locally:
|
||||
```bash
|
||||
poetry run uvicorn src.instaserver:app --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding the API Endpoint to Auto Archiver
|
||||
|
||||
The server should now be running within that session, and accessible at http://127.0.0.1:8000
|
||||
|
||||
You can set this in the Auto Archiver orchestration.yaml file like this:
|
||||
```yaml
|
||||
instagram_api_extractor:
|
||||
api_endpoint: http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. Running the Server Again
|
||||
|
||||
Once the session file is created, you should be able to run the server without logging in again.
|
||||
|
||||
### To run it locally (from scripts/instagrapi_server):
|
||||
```bash
|
||||
poetry run uvicorn src.instgrapinstance.instaserver:app --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running via Docker (After Setup is Complete, either locally or via the script)
|
||||
|
||||
Once the `instagrapi_session.json` and `.env` files are set up, you can pass them Docker and it should authenticate successfully.
|
||||
|
||||
### 🔨 Build the Docker image manually:
|
||||
```bash
|
||||
docker build -t instagrapi-server .
|
||||
```
|
||||
|
||||
### ▶️ Run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--env-file secrets/.env \
|
||||
-v "$(pwd)/secrets:/app/secrets" \
|
||||
-p 8000:8000 \
|
||||
--name ig-instasrv \
|
||||
instagrapi-server
|
||||
```
|
||||
|
||||
This passes the /secrets/ directory to docker as well as the environment variables from the `.env` file.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. Optional Cleanup
|
||||
|
||||
- **Stop the Docker container**:
|
||||
```bash
|
||||
docker stop ig-instasrv
|
||||
```
|
||||
|
||||
- **Remove the container**:
|
||||
```bash
|
||||
docker rm ig-instasrv
|
||||
```
|
||||
|
||||
- **Remove the Docker image**:
|
||||
```bash
|
||||
docker rmi instagrapi-server
|
||||
```
|
||||
|
||||
### ⏱ To run again later:
|
||||
```bash
|
||||
docker start ig-instasrv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Never share your `.env` or `instagrapi_session.json` — these contain sensitive login data.
|
||||
- If you want to reset your session, simply delete the `secrets/instagrapi_session.json` file and re-run the local server.
|
||||
54
docs/source/how_to/05_upgrading_to_1_1_0.md
Normal file
54
docs/source/how_to/05_upgrading_to_1_1_0.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Upgrading from v1.0.1
|
||||
|
||||
```{note} This how-to is only relevant for people who used Auto Archiver before June 2025 (versions prior to 1.1.0).
|
||||
|
||||
If you are new to Auto Archiver, then you are already using the latest configuration format and this how-to is not relevant for you.
|
||||
```
|
||||
|
||||
Versions 1.1.0+ of Auto Archiver has breaking changes in the configuration format, which means earlier configuration formats will not work without slight modifications.
|
||||
|
||||
|
||||
## Dropping `vk_extractor` module
|
||||
We have dropped the `vk_extractor` because of problems in a project we relied on. You will need to remove it from your configuration file, otherwise you will see an error like:
|
||||
|
||||
```{code} console
|
||||
Module 'vk_extractor' not found. Are you sure it's installed/exists?
|
||||
```
|
||||
|
||||
## Dropping `screenshot_enricher` module
|
||||
We have dropped the `screenshot_enricher` module because a new `antibot_extractor_enricher` (see below) module replaces its functionality more robustly and with less dependency hassle on geckodriver/firefox. You will need to remove it from your configuration file, otherwise you will see an error like:
|
||||
|
||||
```{code} console
|
||||
Module 'screenshot_enricher' not found. Are you sure it's installed/exists?
|
||||
```
|
||||
|
||||
|
||||
## New `antibot_extractor_enricher` module and VkDropin
|
||||
We have added a new [`antibot_extractor_enricher`](../modules/autogen/extractor/antibot_extractor_enricher.md) module that uses a computer-controlled browser to extract content from websites that use anti-bot measures. You can add it to your configuration file like this:
|
||||
|
||||
```{code} yaml
|
||||
steps:
|
||||
extractors:
|
||||
- antibot_extractor_enricher
|
||||
|
||||
# or alternatively, if you want to use it as an enricher:
|
||||
enrichers:
|
||||
- antibot_extractor_enricher
|
||||
```
|
||||
|
||||
It will take a full page screenshot, a PDF capture, extract HTML source code, and any other relevant media.
|
||||
|
||||
It comes with Dropins that we will be adding and maintaining.
|
||||
|
||||
> Dropin: A module with site-specific behaviours that is loaded automatically. You don't need to add them to your configuration steps for them to run. Sometimes they need `authentication` configurations though.
|
||||
|
||||
One such Dropin is the VkDropin which uses this automated browser to access VKontakte (VK) pages. You should add a username/password to the configuration file if you get authentication blocks from VK, to do so use the [authentication settings](authentication_how_to.md):
|
||||
|
||||
```{code} yaml
|
||||
authentication:
|
||||
vk.com:
|
||||
username: your_username
|
||||
password: your_password
|
||||
```
|
||||
|
||||
See all available Dropins in [the source code](https://github.com/bellingcat/auto-archiver/tree/main/src/auto_archiver/modules/antibot_extractor_enricher/dropins). Usually each Dropin needs its own authentication settings, similarly to the VkDropin.
|
||||
@@ -71,7 +71,6 @@ The names of the actual modules have also changed, so for any extractor modules
|
||||
- `telethon_archiver` → `telethon_extractor`
|
||||
- `wacz_archiver_enricher` → `wacz_extractor_enricher`
|
||||
- `wayback_archiver_enricher` → `wayback_extractor_enricher`
|
||||
- `vk_archiver` → `vk_extractor`
|
||||
|
||||
|
||||
#### c) Module Renaming
|
||||
@@ -1,71 +0,0 @@
|
||||
# Keeping Logs
|
||||
|
||||
Auto Archiver's logs can be helpful for debugging problematic archiving processes. This guide shows you how to use the logs to
|
||||
|
||||
## Setting up logging
|
||||
|
||||
Logging settings can be set on the command line or using the orchestration config file ([learn more](../installation/configuration)). A special `logging` section defines the logging options.
|
||||
|
||||
#### Enabling or Disabling Logging
|
||||
|
||||
Logging to the console is enabled by default. If you want to globally disable Auto Archiver's logging, then you can set `enabled: false` in your `logging` config:
|
||||
|
||||
```{code} yaml
|
||||
|
||||
...
|
||||
logging:
|
||||
enabled: false
|
||||
...
|
||||
```
|
||||
|
||||
```{note}
|
||||
This will disable all logs from Auto Archiver, but it does not disable logs for other tools that the Auto Archiver uses (for example: yt-dlp, firefox or ffmpeg). These logs will still appear in your console.
|
||||
```
|
||||
|
||||
#### Logging Level
|
||||
|
||||
There are 7 logging levels in total, with 4 commonly used levels. They are: `DEBUG`, `INFO`, `WARNING` and `ERROR`.
|
||||
|
||||
Change the warning level by setting the value in your orchestration config file:
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
...
|
||||
logging:
|
||||
level: DEBUG # or INFO / WARNING / ERROR
|
||||
...
|
||||
```
|
||||
|
||||
For normal usage, it is recommended to use the `INFO` level, or if you prefer quieter logs with less information, you can use the `WARNING` level. If you encounter issues with the archiving, then it's recommended to enable the `DEBUG` level.
|
||||
|
||||
```{note} To learn about all logging levels, see the [loguru documentation](https://loguru.readthedocs.io/en/stable/api/logger.html)
|
||||
```
|
||||
|
||||
### Logging to a file
|
||||
|
||||
As default, auto-archiver will log to the console. But if you wish to store your logs for future reference, or you are running the auto-archiver from within code a implementation, then you may with to enable file logging. This can be done by setting the `file:` config value in the logging settings.
|
||||
|
||||
**Rotation:** For file logging, you can choose to 'rotate' your log files (creating new log files) so they do not get too large. Change this by setting the 'rotation' option in your logging settings. For a full list of rotation options, see the [loguru docs](https://loguru.readthedocs.io/en/stable/overview.html#easier-file-logging-with-rotation-retention-compression).
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
...
|
||||
file: /my/log/file.log
|
||||
rotation: 1 day
|
||||
```
|
||||
|
||||
### Full logging example
|
||||
|
||||
The below example logs only `WARNING` logs to the console and to the file `/my/file.log`, rotating that file once per week:
|
||||
|
||||
```{code} yaml
|
||||
:caption: orchestration.yaml
|
||||
|
||||
logging:
|
||||
level: WARNING
|
||||
file: /my/file.log
|
||||
rotation: 1 week
|
||||
```
|
||||
BIN
docs/source/how_to/share_sheet.png
Normal file
BIN
docs/source/how_to/share_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -6,6 +6,15 @@ There are two main use cases for authentication:
|
||||
* Some websites require some kind of authentication in order to view the content. Examples include Facebook, Telegram etc.
|
||||
* Some websites use anti-bot systems to block bot-like tools from accessing the website. Adding real login information to auto-archiver can sometimes bypass this.
|
||||
|
||||
```{note}
|
||||
|
||||
The Authentication framework currently only works with the following modules:
|
||||
* [Generic Extractor](../modules/autogen/extractor/generic_extractor.md) - the main module for extracting content from websites
|
||||
* [Antibot Extractor/Enricher](../modules/autogen/extractor/antibot_extractor_enricher.md)
|
||||
|
||||
To authenticate for WACZ archiving, see the instructions on the [](../modules/autogen/enricher/wacz_extractor_enricher.md) page.
|
||||
```
|
||||
|
||||
## The Authentication Config
|
||||
|
||||
You can save your authentication information directly inside your orchestration config file, or as a separate file (for security/multi-deploy purposes). Whether storing your settings inside the orchestration file, or as a separate file, the configuration format is the same. Currently, auto-archiver supports the following authentication types:
|
||||
@@ -25,9 +34,10 @@ You can save your authentication information directly inside your orchestration
|
||||
|
||||
```{note}
|
||||
|
||||
The Username & Password, and API settings only work with the Generic Extractor. Other modules (like the screenshot enricher) can only use the `cookies` options. Furthermore, many sites can still detect bots and block username/password logins. Twitter/X and YouTube are two prominent ones that block username/password logging.
|
||||
Currently, the Username & Password, and API settings only work with the Generic and Antibot Extractors. Furthermore, many sites can still detect bots and block username/password logins. Twitter/X and YouTube are two prominent ones that block username/password logins.
|
||||
|
||||
One of the 'Cookies' options is recommended for the most robust archiving.
|
||||
|
||||
One of the 'Cookies' options is recommended for the most robust archiving, but it still isn't guaranteed to work.
|
||||
```
|
||||
|
||||
```{code} yaml
|
||||
@@ -43,12 +53,12 @@ authentication:
|
||||
username: myusername
|
||||
password: 123
|
||||
|
||||
facebook.com:
|
||||
cookie: single_cookie
|
||||
facebook.com:
|
||||
cookie: single_cookie
|
||||
|
||||
othersite.com:
|
||||
api_key: 123
|
||||
api_secret: 1234
|
||||
othersite.com:
|
||||
api_key: 123
|
||||
api_secret: 1234
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
|
||||
### Bash script for Ubuntu 24 Server install
|
||||
|
||||
> NOTE: this script has not been tested by the maintainers and results from the personal experience of a user. It is meant as a guide and not an out of the box script, as you will see it's aimed at a custom branches, users, and features like the Geckodriver which are removed as of version 1.0.2.
|
||||
|
||||
This acts as a handy guide on all requirements. This is built and tested on the 29th of May 2025 on Ubuntu Server 24.04.2 LTS (which is the current latest LTS)
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
|
||||
# I usually run steps manually as logged in with the user: dave
|
||||
# which the application runs under which makes debugging easier
|
||||
|
||||
cd ~
|
||||
sudo apt update -y
|
||||
sudo apt upgrade -y
|
||||
|
||||
# Clone only my latest branch
|
||||
git clone -b v1-test --single-branch https://github.com/djhmateer/auto-archiver
|
||||
|
||||
mkdir ~/auto-archiver/secrets
|
||||
sudo chown -R dave ~/auto-archiver
|
||||
|
||||
sudo apt update -y
|
||||
sudo apt upgrade -y
|
||||
|
||||
## Python 3.12.3 comes with Ubuntu 24.04.2
|
||||
|
||||
# Poetry install 2.1.3 on 2nd June 25
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# had to restart here..
|
||||
sudo reboot
|
||||
|
||||
# C++ compiler so pdqhash will install next
|
||||
sudo apt install build-essential python3-dev -y
|
||||
|
||||
cd auto-archiver
|
||||
|
||||
poetry install
|
||||
|
||||
# FFMpeg
|
||||
# 6.1.1-3ubuntu5 on 2nd June 25
|
||||
sudo apt install ffmpeg -y
|
||||
|
||||
## Firefox
|
||||
# 139.0+build2-0ubuntu0.24.04.1~mt1 on 2nd Jun 25
|
||||
# 16th Jun - don't need anymore as using Chrome in antibot
|
||||
# cd ~
|
||||
# sudo add-apt-repository ppa:mozillateam/ppa -y
|
||||
|
||||
# echo '
|
||||
# Package: *
|
||||
# Pin: release o=LP-PPA-mozillateam
|
||||
# Pin-Priority: 1001
|
||||
# ' | sudo tee /etc/apt/preferences.d/mozilla-firefox
|
||||
|
||||
# echo 'Unattended-Upgrade::Allowed-Origins:: "LP-PPA-mozillateam:${distro_codename}";' | sudo tee /etc/apt/apt.conf.d/51unattended-upgrades-firefox
|
||||
|
||||
# sudo apt install firefox -y
|
||||
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
|
||||
# Chrome
|
||||
cd ~
|
||||
# got problems here - fixed below
|
||||
# 137.0.7151.103 on 16th Jun 2025
|
||||
sudo dpkg -i google-chrome-stable_current_amd64.deb
|
||||
|
||||
# fix dependencies on install above
|
||||
sudo apt-get install -f
|
||||
|
||||
# had to click a lot on UI to get going.
|
||||
# to test
|
||||
# google-chrome
|
||||
|
||||
## Gecko driver
|
||||
# check version numbers for new ones
|
||||
# https://github.com/mozilla/geckodriver/releases/
|
||||
wget https://github.com/mozilla/geckodriver/releases/download/v0.36.0/geckodriver-v0.36.0-linux64.tar.gz
|
||||
tar -xvzf geckodriver*
|
||||
chmod +x geckodriver
|
||||
sudo mv geckodriver /usr/local/bin/
|
||||
rm geckodriver*
|
||||
|
||||
# Fonts so selenium via firefox can render other languages eg Burmese
|
||||
sudo apt install fonts-noto -y
|
||||
|
||||
# Docker
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install ca-certificates curl -y
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo apt-get update -y
|
||||
|
||||
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
|
||||
|
||||
# add dave user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# reboot otherwise can't pull images
|
||||
|
||||
# https://github.com/webrecorder/browsertrix-crawler
|
||||
# https://hub.docker.com/r/webrecorder/browsertrix-crawler/tags
|
||||
# 1.6.2 on 4th Jun 2025
|
||||
docker pull webrecorder/browsertrix-crawler:latest
|
||||
|
||||
# exif
|
||||
sudo apt install libimage-exiftool-perl -y
|
||||
|
||||
|
||||
## CRON run every minute
|
||||
# the cron job running as user dave will execute the shell script
|
||||
# I have many scripts running from cron_11 upwards.
|
||||
# patch in the correct number
|
||||
sudo chmod +x ~/auto-archiver/scripts/cron_15.sh
|
||||
|
||||
# don't want service to run until a reboot otherwise problems with Gecko driver
|
||||
sudo service cron stop
|
||||
|
||||
# runs the script every minute
|
||||
# notice put in a # to disable so will have to manually start it.
|
||||
cat <<EOT >> run-auto-archive
|
||||
#*/1 * * * * dave /home/dave/auto-archiver/scripts/cron_15.sh
|
||||
EOT
|
||||
|
||||
sudo mv run-auto-archive /etc/cron.d
|
||||
sudo chown root /etc/cron.d/run-auto-archive
|
||||
sudo chmod 600 /etc/cron.d/run-auto-archive
|
||||
|
||||
# Helper alias 'c' to open the above file
|
||||
echo "alias c='sudo vim /etc/cron.d/run-auto-archive'" >> ~/.bashrc
|
||||
|
||||
# secrets folder copy
|
||||
# I run dev from:
|
||||
# \\wsl.localhost\Ubuntu-24.04\home\dave\code\auto-archiver\secrets\
|
||||
|
||||
# orchestration.yaml - for aa config
|
||||
# service_account - for google spreadsheet
|
||||
# anon.session - for telethon so don't have to type in phone number
|
||||
# profile.tar.gz - for wacz to have a logged in profile for facebook, x.com and instagram to get data
|
||||
|
||||
# Youtube - POT Tokens
|
||||
# https://github.com/Brainicism/bgutil-ytdlp-pot-provider
|
||||
docker run --name bgutil-provider --restart unless-stopped -d -p 4416:4416 brainicism/bgutil-ytdlp-pot-provider
|
||||
|
||||
|
||||
# test run
|
||||
cd ~/auto-archiver
|
||||
|
||||
poetry run python src/auto_archiver --config secrets/orchestration-aa-demo-main.yaml
|
||||
```
|
||||
59
docs/source/installation/faq.md
Normal file
59
docs/source/installation/faq.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
|
||||
### Q: What websites does the Auto Archiver support?
|
||||
**A:** The Auto Archiver works for a large variety of sites. Firstly, the Auto Archiver can download
|
||||
and archive any video website supported by YT-DLP, a powerful video-downloading tool ([full list of of
|
||||
sites here](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md)). Aside from these sites,
|
||||
there are various different 'Extractors' for specific websites. See the full list of extractors that
|
||||
are available on the [extractors](../modules/extractor.md) page. Some sites supported include:
|
||||
|
||||
* Twitter
|
||||
* Instagram
|
||||
* Telegram
|
||||
* Tiktok
|
||||
* Bluesky
|
||||
|
||||
```{note} What websites the Auto Archiver can archie depends on what extractors you have enabled in
|
||||
your configuration. See [configuration](./configurations.md) for more info.
|
||||
```
|
||||
|
||||
### Q: Does the Auto Archiver only work for social media posts ?
|
||||
**A:** No, the Auto Archiver can archive any web page on the internet, not just social media posts.
|
||||
However, for social media posts Auto Archiver can extract more relevant/useful information (such as
|
||||
post comments, likes, author etc.) which may not be available for a generic website. If you are looking
|
||||
to more generally archive webpages, then you should make sure to enable the [](../modules/autogen/extractor/wacz_extractor_enricher.md)
|
||||
and the [](../modules/autogen/extractor/wayback_extractor_enricher.md).
|
||||
|
||||
### Q: What kind of data is stored for each webpage that's archived?
|
||||
**A:** This depends on the website archived, but more generally, for social media posts any videos and photos in
|
||||
the post will be archived. For video sites, the video will be downloaded separately. For most of these sites, additional
|
||||
metadata such as published date, uploader/author and ratings/comments will also be saved. Additionally, further data can be
|
||||
saved depending on the enrichers that you have enabled. Some other types of data saved are timestamps if you have the
|
||||
[](../modules/autogen/enricher/timestamping_enricher.md) or [](../modules/autogen/enricher/opentimestamps_enricher.md) enabled,
|
||||
screenshots of the web page with the [](../modules/autogen/enricher/screenshot_enricher.md), and for videos, thumbnails of the
|
||||
video with the [](../modules/autogen/enricher/thumbnail_enricher.md). You can also store things like hashes (SHA256, or pdq hashes)
|
||||
with the various hash enrichers.
|
||||
|
||||
### Q: Where is my data stored?
|
||||
**A:** With the default configuration, data is stored on your local computer in the `local_storage` folder. You can adjust these settings by
|
||||
changing the [storage modules](../modules/storage.md) you have enabled. For example, you could choose to store your data in an S3 bucket or
|
||||
on Google Drive.
|
||||
|
||||
```{note}
|
||||
You can choose to store your data in multiple places, for example your local drive **and** an S3 bucket for redundancy.
|
||||
```
|
||||
|
||||
### Q: What should I do is something doesn't work?
|
||||
**A:** First, read through the log files to see if you can find a specific reason why something isn't working. Learn more about logging
|
||||
and how to enable debug logging in the [Logging Howto](../how_to/logging.md).
|
||||
|
||||
If you cannot find an answer in the logs, then try searching this documentation or existing / closed issues on the [Github Issue Tracker](https://github.com/bellingcat/auto-archiver/issues?q=is%3Aissue%20). If you still cannot find an answer, then consider opening an issue on the Github Issue Tracker or asking in the Bellingcat Discord
|
||||
'Auto Archiver' group.
|
||||
|
||||
#### Common reasons why an archiving might not work:
|
||||
|
||||
* The website may have temporarily adjusted its settings - sometimes sites like Telegram or Twitter adjust their scraping protection settings. Often,
|
||||
waiting a day or two and then trying again can work.
|
||||
* The site requires you to be logged in - you could try using cookies or authentication to bypass any blocks. See [](../installation/authentication.md) for more information.
|
||||
* The website you're trying to archive has changed its settings/structure. Make sure you're using the latest version of Auto Archiver and try again.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Installation
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 1
|
||||
|
||||
upgrading.md
|
||||
```
|
||||
|
||||
There are 3 main ways to use the auto-archiver. We recommend the 'docker' method for most uses. This installs all the requirements in one command.
|
||||
|
||||
1. Easiest (recommended): [via docker](#installing-with-docker)
|
||||
@@ -45,11 +51,14 @@ After this, you're ready to set up your [your configuration file](configurations
|
||||
If using the local installation method, you will also need to install the following dependencies locally:
|
||||
|
||||
1.[ffmpeg](https://www.ffmpeg.org/) - for handling of downloaded videos
|
||||
2. [firefox](https://www.mozilla.org/en-US/firefox/new/) and [geckodriver](https://github.com/mozilla/geckodriver/releases) on a path folder like `/usr/local/bin` - for taking webpage screenshots with the screenshot enricher
|
||||
3. (optional) [fonts-noto](https://fonts.google.com/noto) to deal with multiple unicode characters during selenium/geckodriver's screenshots: `sudo apt install fonts-noto -y`.
|
||||
<!-- 2. [firefox](https://www.mozilla.org/en-US/firefox/new/) and [geckodriver](https://github.com/mozilla/geckodriver/releases) on a path folder like `/usr/local/bin` - for taking webpage screenshots with the screenshot enricher -->
|
||||
3. (optional) [fonts-noto](https://fonts.google.com/noto) to deal with multiple unicode characters during selenium screenshots: `sudo apt install fonts-noto -y`.
|
||||
4. [Browsertrix Crawler docker image](https://hub.docker.com/r/webrecorder/browsertrix-crawler) for the WACZ enricher/archiver
|
||||
|
||||
|
||||
### Custom installation scripts
|
||||
- [Ubuntu 24 Server Install by @djhmateer](example_scripts/ubuntu_24_server_install.md) - a WYSIWYG example script from a user who set up the Auto Archiver on a fresh Ubuntu 24 server.
|
||||
|
||||
|
||||
## Developer Install
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
# Getting Started
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 1
|
||||
:hidden:
|
||||
|
||||
installation.md
|
||||
@@ -9,6 +8,7 @@ configurations.md
|
||||
config_editor.md
|
||||
authentication.md
|
||||
requirements.md
|
||||
faq.md
|
||||
config_cheatsheet.md
|
||||
```
|
||||
|
||||
@@ -27,20 +27,22 @@ The way you run the Auto Archiver depends on how you installed it (docker instal
|
||||
If you installed Auto Archiver using docker, open up your terminal, and copy-paste / type the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver
|
||||
```
|
||||
docker run -it --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver -- "https://example.com/1/"
|
||||
```
|
||||
|
||||
breaking this command down:
|
||||
1. `docker run` tells docker to start a new container (an instance of the image)
|
||||
2. `--rm` makes sure this container is removed after execution (less garbage locally)
|
||||
3. `-v $PWD/secrets:/app/secrets` - your secrets folder with settings
|
||||
2. `-it` tells docker to run in 'interactive mode' so that we get nice colour logs
|
||||
3. `--rm` makes sure this container is removed after execution (less garbage locally)
|
||||
4. `-v $PWD/secrets:/app/secrets` - your secrets folder with settings
|
||||
1. `-v` is a volume flag which means a folder that you have on your computer will be connected to a folder inside the docker container
|
||||
2. `$PWD/secrets` points to a `secrets/` folder in your current working directory (where your console points to), we use this folder as a best practice to hold all the secrets/tokens/passwords/... you use
|
||||
3. `/app/secrets` points to the path the docker container where this image can be found
|
||||
4. `-v $PWD/local_archive:/app/local_archive` - (optional) if you use local_storage
|
||||
5. `-v $PWD/local_archive:/app/local_archive` - (optional) if you use local_storage
|
||||
1. `-v` same as above, this is a volume instruction
|
||||
2. `$PWD/local_archive` is a folder `local_archive/` in case you want to archive locally and have the files accessible outside docker
|
||||
3. `/app/local_archive` is a folder inside docker that you can reference in your orchestration.yml file
|
||||
6. ` -- "https://example.com/1/"` this will pass the URL to archive to the default [command line feeder](../modules/autogen/feeder/cli_feeder.md)
|
||||
|
||||
### Example invocations
|
||||
|
||||
@@ -48,13 +50,14 @@ The invocations below will run the auto-archiver Docker image using a configurat
|
||||
|
||||
```bash
|
||||
# Have auto-archiver run with the default settings, generating a settings file in ./secrets/orchestration.yaml
|
||||
docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver
|
||||
docker run -it --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver
|
||||
|
||||
# uses the same configuration, but with the `gsheet_feeder`, a header on row 2 and with some different column names
|
||||
# Note this expects you to have followed the [Google Sheets setup](how_to/google_sheets.md) and added your service_account.json to the `secrets/` folder
|
||||
# notice that columns is a dictionary so you need to pass it as JSON and it will override only the values provided
|
||||
docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --feeders=gsheet_feeder --gsheet_feeder.sheet="use it on another sheets doc" --gsheet_feeder.header=2 --gsheet_feeder.columns='{"url": "link"}'
|
||||
docker run -it --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --feeders=gsheet_feeder --gsheet_feeder.sheet="use it on another sheets doc" --gsheet_feeder.header=2 --gsheet_feeder.columns='{"url": "link"}'
|
||||
# Runs auto-archiver for the first time, but in 'full' mode, enabling all modules to get a full settings file
|
||||
docker run --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --mode full
|
||||
docker run -it --rm -v $PWD/secrets:/app/secrets -v $PWD/local_archive:/app/local_archive bellingcat/auto-archiver --mode full
|
||||
```
|
||||
|
||||
------------
|
||||
|
||||
30
docs/source/installation/upgrading.md
Normal file
30
docs/source/installation/upgrading.md
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
# Upgrading
|
||||
|
||||
If an update is available, then you will see a message in the logs when you
|
||||
run Auto Archiver. Here's what those logs look like:
|
||||
|
||||
```{code} bash
|
||||
********* IMPORTANT: UPDATE AVAILABLE ********
|
||||
A new version of auto-archiver is available (v0.13.6, you have 0.13.4)
|
||||
Make sure to update to the latest version using: `pip install --upgrade auto-archiver`
|
||||
```
|
||||
|
||||
Upgrading Auto Archiver depends on the way you installed it.
|
||||
|
||||
## Docker
|
||||
|
||||
To upgrade using docker, update the docker image with:
|
||||
|
||||
```
|
||||
docker pull bellingcat/auto-archiver:latest
|
||||
```
|
||||
|
||||
## Pip
|
||||
|
||||
To upgrade the pip package, use:
|
||||
|
||||
```
|
||||
pip install --upgrade auto-archiver
|
||||
```
|
||||
|
||||
@@ -4,8 +4,9 @@ Extractor modules are used to extract the content of a given URL. Typically, one
|
||||
|
||||
Extractors that are able to extract content from a wide range of websites include:
|
||||
1. Generic Extractor: parses videos and images on sites using the powerful yt-dlp library.
|
||||
2. Wayback Machine Extractor: sends pages to the Wayback machine for archiving, and stores the link.
|
||||
3. WACZ Extractor: runs a web browser to 'browse' the URL and save a copy of the page in WACZ format.
|
||||
2. Antibot Extractor: uses a headless browser to bypass bot detection and extract content.
|
||||
3. WACZ Extractor: runs a web browser to 'browse' the URL and save a copy of the page in WACZ format.
|
||||
4. Wayback Machine Extractor: sends pages to the Wayback machine for archiving, and stores the archived link.
|
||||
|
||||
```{include} autogen/extractor.md
|
||||
```
|
||||
|
||||
3947
poetry.lock
generated
3947
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[project]
|
||||
name = "auto-archiver"
|
||||
version = "0.13.5"
|
||||
version = "1.2.6"
|
||||
description = "Automatically archive links to videos, images, and social media content from Google Sheets (and more)."
|
||||
|
||||
requires-python = ">=3.10,<3.13"
|
||||
@@ -27,7 +27,6 @@ dependencies = [
|
||||
"bs4 (>=0.0.0)",
|
||||
"loguru (>=0.0.0)",
|
||||
"ffmpeg-python (>=0.0.0)",
|
||||
"selenium (>=0.0.0)",
|
||||
"telethon (>=0.0.0)",
|
||||
"google-api-python-client (>=0.0.0)",
|
||||
"google-auth-httplib2 (>=0.0.0)",
|
||||
@@ -41,22 +40,25 @@ dependencies = [
|
||||
"instaloader (>=0.0.0)",
|
||||
"tqdm (>=0.0.0)",
|
||||
"jinja2 (>=0.0.0)",
|
||||
"pyOpenSSL (==24.2.1)",
|
||||
"cryptography (>=41.0.0,<42.0.0)",
|
||||
"boto3 (>=1.28.0,<2.0.0)",
|
||||
"dataclasses-json (>=0.0.0)",
|
||||
"yt-dlp (>=2025.1.26,<2026.0.0)",
|
||||
"numpy (==2.1.3)",
|
||||
"vk-url-scraper (>=0.0.0)",
|
||||
"requests[socks] (>=0.0.0)",
|
||||
"warcio (>=0.0.0)",
|
||||
"jsonlines (>=0.0.0)",
|
||||
"pysubs2 (>=0.0.0)",
|
||||
"retrying (>=0.0.0)",
|
||||
"tsp-client (>=0.0.0)",
|
||||
"certvalidator (>=0.0.0)",
|
||||
"rich-argparse (>=1.6.0,<2.0.0)",
|
||||
"ruamel-yaml (>=0.18.10,<0.19.0)",
|
||||
"rfc3161-client (>=1.0.5)",
|
||||
"cryptography (>=46.0.3)",
|
||||
"opentimestamps (>=0.4.5,<0.5.0)",
|
||||
"bgutil-ytdlp-pot-provider (>=1.0.0)",
|
||||
"yt-dlp[curl-cffi,default] (>=2025.5.22)",
|
||||
"secretstorage (>=3.3.3,<4.0.0)",
|
||||
"seleniumbase (>=4.36.4,<5.0.0)",
|
||||
"pyautogui (>=0.9.54,<0.10.0)",
|
||||
"pyperclip (>=1.9.0)",
|
||||
]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
@@ -64,6 +66,8 @@ pytest = "^8.3.4"
|
||||
autopep8 = "^2.3.1"
|
||||
pytest-loguru = "^0.4.0"
|
||||
pytest-mock = "^3.14.0"
|
||||
ruff = "^0.15.2"
|
||||
pre-commit = "^4.1.0"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = "^8.1.3"
|
||||
@@ -89,4 +93,29 @@ documentation = "https://github.com/bellingcat/auto-archiver"
|
||||
markers = [
|
||||
"download: marks tests that download content from the network",
|
||||
"incremental: marks a class to run tests incrementally. If a test fails in the class, the remaining tests will be skipped",
|
||||
]
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
#exclude = ["docs"]
|
||||
line-length = 120
|
||||
# Remove this for a more detailed lint report
|
||||
output-format = "concise"
|
||||
# TODO: temp ignore rule for timestamping_enricher to allow for open PR
|
||||
exclude = ["src/auto_archiver/modules/timestamping_enricher/*"]
|
||||
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Extend the rules to check for by adding them to this option:
|
||||
# See documentation for more details: https://docs.astral.sh/ruff/rules/
|
||||
#extend-select = ["B"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Ignore import violations in __init__.py files
|
||||
"__init__.py" = ["F401", "F403"]
|
||||
# Ignore 'useless expression' in manifest files.
|
||||
"__manifest__.py" = ["B018"]
|
||||
|
||||
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = false
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os.path
|
||||
import click, json
|
||||
import click
|
||||
import json
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
@@ -70,11 +71,7 @@ def main(credentials, token):
|
||||
print(emailAddress)
|
||||
|
||||
# Call the Drive v3 API and return some files
|
||||
results = (
|
||||
service.files()
|
||||
.list(pageSize=10, fields="nextPageToken, files(id, name)")
|
||||
.execute()
|
||||
)
|
||||
results = service.files().list(pageSize=10, fields="nextPageToken, files(id, name)").execute()
|
||||
items = results.get("files", [])
|
||||
|
||||
if not items:
|
||||
|
||||
135
scripts/generate_google_services.sh
Normal file
135
scripts/generate_google_services.sh
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
|
||||
UUID=$(LC_ALL=C tr -dc a-z0-9 </dev/urandom | head -c 16)
|
||||
PROJECT_NAME="auto-archiver-$UUID"
|
||||
ACCOUNT_NAME="autoarchiver"
|
||||
KEY_FILE="service_account-$UUID.json"
|
||||
DEST_DIR="$1"
|
||||
|
||||
echo "====================================================="
|
||||
echo "🔧 Auto-Archiver Google Services Setup Script"
|
||||
echo "====================================================="
|
||||
echo "This script will:"
|
||||
echo " 1. Install Google Cloud SDK if needed"
|
||||
echo " 2. Create a Google Cloud project named $PROJECT_NAME"
|
||||
echo " 3. Create a service account for Auto-Archiver"
|
||||
echo " 4. Generate a key file for API access"
|
||||
echo ""
|
||||
echo " Tip: Pass a directory path as an argument to this script to move the key file there"
|
||||
echo " e.g. ./generate_google_services.sh /path/to/secrets"
|
||||
echo "====================================================="
|
||||
|
||||
# Check and install Google Cloud SDK based on platform
|
||||
install_gcloud_sdk() {
|
||||
if command -v gcloud &> /dev/null; then
|
||||
echo "✅ Google Cloud SDK is already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "📦 Installing Google Cloud SDK..."
|
||||
|
||||
# Detect OS
|
||||
case "$(uname -s)" in
|
||||
Darwin*)
|
||||
if command -v brew &> /dev/null; then
|
||||
echo "🍺 Installing via Homebrew..."
|
||||
brew install google-cloud-sdk --cask
|
||||
else
|
||||
echo "📥 Downloading Google Cloud SDK for macOS..."
|
||||
curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-latest-darwin-x86_64.tar.gz
|
||||
tar -xf google-cloud-cli-latest-darwin-x86_64.tar.gz
|
||||
./google-cloud-sdk/install.sh --quiet
|
||||
rm google-cloud-cli-latest-darwin-x86_64.tar.gz
|
||||
echo "🔄 Please restart your terminal and run this script again"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
Linux*)
|
||||
echo "📥 Downloading Google Cloud SDK for Linux..."
|
||||
curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-latest-linux-x86_64.tar.gz
|
||||
tar -xf google-cloud-cli-latest-linux-x86_64.tar.gz
|
||||
./google-cloud-sdk/install.sh --quiet
|
||||
rm google-cloud-cli-latest-linux-x86_64.tar.gz
|
||||
echo "🔄 Please restart your terminal and run this script again"
|
||||
exit 0
|
||||
;;
|
||||
CYGWIN*|MINGW*|MSYS*)
|
||||
echo "⚠️ Windows detected. Please follow manual installation instructions at:"
|
||||
echo "https://cloud.google.com/sdk/docs/install-sdk"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ Unknown operating system. Please follow manual installation instructions at:"
|
||||
echo "https://cloud.google.com/sdk/docs/install-sdk"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "✅ Google Cloud SDK installed"
|
||||
}
|
||||
|
||||
# Install Google Cloud SDK if needed
|
||||
install_gcloud_sdk
|
||||
|
||||
# Login to Google Cloud
|
||||
if gcloud auth list --filter=status:ACTIVE --format="value(account)" | grep -q "@"; then
|
||||
echo "✅ Already authenticated with Google Cloud"
|
||||
else
|
||||
echo "🔑 Authenticating with Google Cloud..."
|
||||
gcloud auth login
|
||||
fi
|
||||
|
||||
# Create project
|
||||
echo "🌟 Creating Google Cloud project: $PROJECT_NAME"
|
||||
gcloud projects create $PROJECT_NAME
|
||||
|
||||
# Create service account
|
||||
echo "👤 Creating service account: $ACCOUNT_NAME"
|
||||
gcloud iam service-accounts create $ACCOUNT_NAME --project $PROJECT_NAME
|
||||
|
||||
# Enable required APIs (uncomment and add APIs as needed)
|
||||
echo "⬆️ Enabling required Google APIs..."
|
||||
gcloud services enable sheets.googleapis.com --project $PROJECT_NAME
|
||||
gcloud services enable drive.googleapis.com --project $PROJECT_NAME
|
||||
|
||||
# Get the service account email
|
||||
echo "📧 Retrieving service account email..."
|
||||
ACCOUNT_EMAIL=$(gcloud iam service-accounts list --project $PROJECT_NAME --format="value(email)")
|
||||
|
||||
# Create and download key
|
||||
echo "🔑 Generating service account key file: $KEY_FILE"
|
||||
gcloud iam service-accounts keys create $KEY_FILE --iam-account=$ACCOUNT_EMAIL
|
||||
|
||||
# move the file to TARGET_DIR if provided
|
||||
if [[ -n "$DEST_DIR" ]]; then
|
||||
# Expand `~` if used
|
||||
DEST_DIR=$(eval echo "$DEST_DIR")
|
||||
|
||||
# Ensure the directory exists
|
||||
if [[ ! -d "$DEST_DIR" ]]; then
|
||||
mkdir -p "$DEST_DIR"
|
||||
fi
|
||||
|
||||
DEST_PATH="$DEST_DIR/$KEY_FILE"
|
||||
echo "🚚 Moving key file to: $DEST_PATH"
|
||||
mv "$KEY_FILE" "$DEST_PATH"
|
||||
KEY_FILE="$DEST_PATH"
|
||||
fi
|
||||
|
||||
echo "====================================================="
|
||||
echo "✅ SETUP COMPLETE!"
|
||||
echo "====================================================="
|
||||
echo "📝 Important Information:"
|
||||
echo " • Project Name: $PROJECT_NAME"
|
||||
echo " • Service Account: $ACCOUNT_EMAIL"
|
||||
echo " • Key File: $KEY_FILE"
|
||||
echo ""
|
||||
echo "📋 Next Steps:"
|
||||
echo " 1. Share any Google Sheets with this email address:"
|
||||
echo " $ACCOUNT_EMAIL"
|
||||
echo " 2. Move $KEY_FILE to your auto-archiver secrets directory"
|
||||
echo " 3. Update your auto-archiver config to use this key file (if needed)"
|
||||
echo "====================================================="
|
||||
@@ -8,12 +8,14 @@ from auto_archiver.core.module import ModuleFactory
|
||||
from auto_archiver.core.consts import MODULE_TYPES
|
||||
from auto_archiver.core.config import EMPTY_CONFIG
|
||||
|
||||
|
||||
class SchemaEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, set):
|
||||
return list(obj)
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
# Get available modules
|
||||
module_factory = ModuleFactory()
|
||||
available_modules = module_factory.available_modules()
|
||||
@@ -21,32 +23,41 @@ available_modules = module_factory.available_modules()
|
||||
modules_by_type = {}
|
||||
# Categorize modules by type
|
||||
for module in available_modules:
|
||||
for type in module.manifest.get('type', []):
|
||||
for type in module.manifest.get("type", []):
|
||||
modules_by_type.setdefault(type, []).append(module)
|
||||
|
||||
all_modules_ordered_by_type = sorted(available_modules, key=lambda x: (MODULE_TYPES.index(x.type[0]), not x.requires_setup))
|
||||
all_modules_ordered_by_type = sorted(
|
||||
available_modules, key=lambda x: (MODULE_TYPES.index(x.type[0]), not x.requires_setup)
|
||||
)
|
||||
|
||||
yaml: YAML = YAML()
|
||||
|
||||
config_string = io.BytesIO()
|
||||
yaml.dump(EMPTY_CONFIG, config_string)
|
||||
config_string = config_string.getvalue().decode('utf-8')
|
||||
config_string = config_string.getvalue().decode("utf-8")
|
||||
output_schema = {
|
||||
'modules': dict((module.name,
|
||||
{
|
||||
'name': module.name,
|
||||
'display_name': module.display_name,
|
||||
'manifest': module.manifest,
|
||||
'configs': module.configs or None
|
||||
}
|
||||
) for module in all_modules_ordered_by_type),
|
||||
'steps': dict((f"{module_type}s", [module.name for module in modules_by_type[module_type]]) for module_type in MODULE_TYPES),
|
||||
'configs': [m.name for m in all_modules_ordered_by_type if m.configs],
|
||||
'module_types': MODULE_TYPES,
|
||||
'empty_config': config_string
|
||||
"modules": dict(
|
||||
(
|
||||
module.name,
|
||||
{
|
||||
"name": module.name,
|
||||
"display_name": module.display_name,
|
||||
"manifest": module.manifest,
|
||||
"configs": module.configs or None,
|
||||
},
|
||||
)
|
||||
for module in all_modules_ordered_by_type
|
||||
),
|
||||
"steps": dict(
|
||||
(f"{module_type}s", [module.name for module in modules_by_type[module_type]]) for module_type in MODULE_TYPES
|
||||
),
|
||||
"configs": [m.name for m in all_modules_ordered_by_type if m.configs],
|
||||
"module_types": MODULE_TYPES,
|
||||
"empty_config": config_string,
|
||||
}
|
||||
|
||||
current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
output_file = os.path.join(current_file_dir, 'settings/src/schema.json')
|
||||
with open(output_file, 'w') as file:
|
||||
json.dump(output_schema, file, indent=4, cls=SchemaEncoder)
|
||||
output_file = os.path.join(current_file_dir, "settings/src/schema.json")
|
||||
with open(output_file, "w") as file:
|
||||
print(f"Writing schema to {output_file}")
|
||||
json.dump(output_schema, file, indent=4, cls=SchemaEncoder)
|
||||
|
||||
2
scripts/instagrapi_server/.gitignore
vendored
Normal file
2
scripts/instagrapi_server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
secrets*
|
||||
*instagrapi_session.json
|
||||
19
scripts/instagrapi_server/Dockerfile
Normal file
19
scripts/instagrapi_server/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install poetry
|
||||
|
||||
# Copy all source code
|
||||
COPY . .
|
||||
|
||||
# Prevent Poetry from creating a virtual environment
|
||||
RUN poetry config virtualenvs.create false
|
||||
|
||||
# Install dependencies
|
||||
RUN poetry install --no-root
|
||||
|
||||
|
||||
# Use uvicorn to run the FastAPI app
|
||||
CMD ["poetry", "run", "uvicorn", "src.instaserver:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
18
scripts/instagrapi_server/pyproject.toml
Normal file
18
scripts/instagrapi_server/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "instaserver"
|
||||
version = "0.1.0"
|
||||
description = "A FastAPI InstagrAPI server"
|
||||
package-mode = false
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi (>=0.115.12,<0.116.0)",
|
||||
"instagrapi (>=2.1.3,<3.0.0)",
|
||||
"uvicorn (>=0.34.0,<0.35.0)",
|
||||
"pillow (>=11.1.0,<12.0.0)",
|
||||
"python-dotenv (>=1.1.0,<2.0.0)"
|
||||
]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
48
scripts/instagrapi_server/run_instagrapi_server.sh
Executable file
48
scripts/instagrapi_server/run_instagrapi_server.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# run_instagrapi_server.sh
|
||||
# Usage:
|
||||
# From repo root: ./scripts/instagrapi_server/run_instagrapi_server.sh
|
||||
# Or from script dir: ./run_instagrapi_server.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Step 1: cd to the script's directory (contains Dockerfile and secrets/)
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
|
||||
# Create secrets/ if it doesn't exist
|
||||
if [[ ! -d "secrets" ]]; then
|
||||
echo "Creating secrets/ directory..."
|
||||
mkdir secrets
|
||||
fi
|
||||
|
||||
echo "Enter your Instagram credentials to store in secrets/.env"
|
||||
read -rp "Instagram Username: " IGUSER
|
||||
read -rsp "Instagram Password: " IGPASS
|
||||
echo ""
|
||||
|
||||
cat <<EOF > secrets/.env
|
||||
INSTAGRAM_USERNAME=$IGUSER
|
||||
INSTAGRAM_PASSWORD=$IGPASS
|
||||
EOF
|
||||
echo "Created secrets/.env with your credentials."
|
||||
|
||||
# Build Docker image
|
||||
IMAGE_NAME="instagrapi-server"
|
||||
echo "Building Docker image '$IMAGE_NAME'..."
|
||||
docker build -t "$IMAGE_NAME" .
|
||||
|
||||
# Run container
|
||||
CONTAINER_NAME="ig-instasrv"
|
||||
echo "Running container '$CONTAINER_NAME'..."
|
||||
docker run -d \
|
||||
--env-file secrets/.env \
|
||||
-v "$(pwd)/secrets:/app/secrets" \
|
||||
-p 8000:8000 \
|
||||
--name "$CONTAINER_NAME" \
|
||||
"$IMAGE_NAME"
|
||||
|
||||
echo "Done! Instagrapi server is running on port 8000."
|
||||
echo "Use 'docker logs $CONTAINER_NAME' to view logs."
|
||||
echo "Use 'docker stop $CONTAINER_NAME' and 'docker rm $CONTAINER_NAME' to stop/remove the container."
|
||||
157
scripts/instagrapi_server/src/instaserver.py
Normal file
157
scripts/instagrapi_server/src/instaserver.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""https://subzeroid.github.io/instagrapi/
|
||||
|
||||
Run using the following command:
|
||||
uvicorn src.instgrapinstance.instaserver:app --host 0.0.0.0 --port 8000 --reload
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from instagrapi import Client
|
||||
from instagrapi.exceptions import LoginRequired, BadCredentials
|
||||
|
||||
load_dotenv(dotenv_path="secrets/.env")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
|
||||
INSTAGRAM_USERNAME = os.getenv("INSTAGRAM_USERNAME")
|
||||
INSTAGRAM_PASSWORD = os.getenv("INSTAGRAM_PASSWORD")
|
||||
SESSION_FILE = "secrets/instagrapi_session.json"
|
||||
|
||||
app = FastAPI()
|
||||
cl = Client()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
"""Login automatically when server starts"""
|
||||
try:
|
||||
login_instagram()
|
||||
except RuntimeError as e:
|
||||
logging.error(f"API failed to start: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def login_instagram():
|
||||
"""Ensures Instagrapi is logged in and session is persistent"""
|
||||
if not INSTAGRAM_USERNAME or not INSTAGRAM_PASSWORD:
|
||||
raise RuntimeError("Instagram credentials are missing.")
|
||||
|
||||
if os.path.exists(SESSION_FILE):
|
||||
try:
|
||||
cl.load_settings(SESSION_FILE)
|
||||
cl.get_timeline_feed()
|
||||
logging.info("Using saved session.")
|
||||
return
|
||||
except LoginRequired:
|
||||
logging.info("Session expired. Logging in again...")
|
||||
|
||||
try:
|
||||
cl.login(INSTAGRAM_USERNAME, INSTAGRAM_PASSWORD)
|
||||
cl.dump_settings(SESSION_FILE)
|
||||
logging.info("Login successful, session saved.")
|
||||
except BadCredentials as bc:
|
||||
raise RuntimeError("Incorrect Instagram username or password.") from bc
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Login failed: {e}") from e
|
||||
|
||||
|
||||
@app.get("/v1/media/by/id")
|
||||
def get_media_by_id(id: str):
|
||||
"""Fetch post details by media ID"""
|
||||
logging.info(f"Fetching media by ID: {id}")
|
||||
try:
|
||||
media = cl.media_info(id)
|
||||
return media.model_dump()
|
||||
except Exception as e:
|
||||
logging.warning(f"Media not found for ID {id}: {e}")
|
||||
raise HTTPException(status_code=404, detail="Post not found") from e
|
||||
|
||||
|
||||
@app.get("/v1/media/by/code")
|
||||
def get_media_by_code(code: str):
|
||||
"""Fetch post details by shortcode"""
|
||||
logging.info(f"Fetching media by shortcode: {code}")
|
||||
try:
|
||||
media_id = cl.media_pk_from_code(code)
|
||||
media = cl.media_info(media_id)
|
||||
return media.model_dump()
|
||||
except Exception as e:
|
||||
logging.warning(f"Media not found for code {code}: {e}")
|
||||
raise HTTPException(status_code=404, detail="Post not found") from e
|
||||
|
||||
|
||||
@app.get("/v2/user/tag/medias")
|
||||
def get_user_tagged_medias(user_id: str, page_id: str = None):
|
||||
logging.info(f"Fetching tagged medias for user_id={user_id} page_id={page_id}")
|
||||
try:
|
||||
# Placeholder for now
|
||||
items, next_page_id = [], None
|
||||
return {"response": {"items": items}, "next_page_id": next_page_id}
|
||||
except Exception as e:
|
||||
logging.warning(f"Tagged media not found for {user_id}: {e}")
|
||||
raise HTTPException(status_code=404, detail="Tagged media not found") from e
|
||||
|
||||
|
||||
@app.get("/v1/user/highlights")
|
||||
def get_user_highlights(user_id: str):
|
||||
logging.info(f"Fetching highlights list for user_id={user_id}")
|
||||
try:
|
||||
highlights = cl.user_highlights(user_id)
|
||||
return [h.model_dump() for h in highlights]
|
||||
except Exception as e:
|
||||
logging.warning(f"Highlights not found for {user_id}: {e}")
|
||||
raise HTTPException(status_code=404, detail="No highlights found") from e
|
||||
|
||||
|
||||
@app.get("/v2/highlight/by/id")
|
||||
def get_highlight_by_id(id: str):
|
||||
logging.info(f"Fetching highlight details for id={id}")
|
||||
try:
|
||||
highlight = cl.highlight_info(id)
|
||||
return {"response": {"reels": {f"highlight:{id}": highlight.model_dump()}}}
|
||||
except Exception as e:
|
||||
logging.warning(f"Highlight not found for id {id}: {e}")
|
||||
raise HTTPException(status_code=404, detail="Highlight not found") from e
|
||||
|
||||
|
||||
@app.get("/v1/user/stories/by/username")
|
||||
def get_stories(username: str):
|
||||
logging.info(f"Fetching stories for username={username}")
|
||||
try:
|
||||
user_id = cl.user_id_from_username(username)
|
||||
stories = cl.user_stories(user_id)
|
||||
return [story.model_dump() for story in stories]
|
||||
except Exception as e:
|
||||
logging.warning(f"Stories not found for {username}: {e}")
|
||||
raise HTTPException(status_code=404, detail="Stories not found") from e
|
||||
|
||||
|
||||
@app.get("/v2/user/by/username")
|
||||
def get_user_by_username(username: str):
|
||||
logging.info(f"Fetching user profile for username={username}")
|
||||
try:
|
||||
user = cl.user_info_by_username(username)
|
||||
return {"user": user.model_dump()}
|
||||
except Exception as e:
|
||||
logging.warning(f"User not found: {username}: {e}")
|
||||
raise HTTPException(status_code=404, detail="User not found") from e
|
||||
|
||||
|
||||
@app.get("/v1/user/medias/chunk")
|
||||
def get_user_medias(user_id: str, end_cursor: str = None):
|
||||
logging.info(f"Fetching paginated medias for user_id={user_id}, end_cursor={end_cursor}")
|
||||
try:
|
||||
posts, next_cursor = cl.user_medias_paginated(user_id, end_cursor=end_cursor)
|
||||
return [[post.model_dump() for post in posts], next_cursor]
|
||||
except Exception as e:
|
||||
logging.warning(f"No posts found for user_id={user_id}: {e}")
|
||||
raise HTTPException(status_code=404, detail="No posts found") from e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
1301
scripts/settings/package-lock.json
generated
1301
scripts/settings/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@mui/icons-material": "^7.1.1",
|
||||
"@mui/material": "^7.1.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^10.0.0",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
//
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
Stack,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
|
||||
import { parseDocument, Document, YAMLSeq, YAMLMap, Scalar } from 'yaml'
|
||||
import StepCard from './StepCard';
|
||||
@@ -204,7 +204,7 @@ function ModuleTypes({ stepType, setEnabledModules, enabledModules, configValues
|
||||
{stepType}
|
||||
</Typography>
|
||||
<Typography variant="body1" >
|
||||
Select the <a href="<a href={`https://auto-archiver.readthedocs.io/en/latest/modules/${stepType.slice(0,-1)}.html`}" target="_blank">{stepType}</a> you wish to enable. Drag to reorder.
|
||||
Select the <a href={`https://auto-archiver.readthedocs.io/en/latest/modules/${stepType.slice(0,-1)}.html`} target="_blank">{stepType}</a> you wish to enable. Drag to reorder.
|
||||
</Typography>
|
||||
</Box>
|
||||
{showError ? <Typography variant="body1" color="error" >Only one {stepType.slice(0,-1)} can be enabled at a time.</Typography> : null}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
build: {
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
// minify: false,
|
||||
// sourcemap: true,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,10 +12,9 @@ Then run this script to create a new session file.
|
||||
You will need to provide your phone number and a 2FA code the first time you run this script.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
from telethon.sync import TelegramClient
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
|
||||
# Create a
|
||||
@@ -25,5 +24,4 @@ SESSION_FILE = "secrets/anon-insta"
|
||||
|
||||
os.makedirs("secrets", exist_ok=True)
|
||||
with TelegramClient(SESSION_FILE, API_ID, API_HASH) as client:
|
||||
logger.success(f"New session file created: {SESSION_FILE}.session")
|
||||
|
||||
logger.success(f"new session file created: {SESSION_FILE}.session")
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
""" Entry point for the auto_archiver package. """
|
||||
"""Entry point for the auto_archiver package."""
|
||||
|
||||
from auto_archiver.core.orchestrator import ArchivingOrchestrator
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
for _ in ArchivingOrchestrator()._command_line_run(sys.argv[1:]): pass
|
||||
for _ in ArchivingOrchestrator()._command_line_run(sys.argv[1:]):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
""" Core modules to handle things such as orchestration, metadata and configs..
|
||||
"""Core modules to handle things such as orchestration, metadata and configs.."""
|
||||
|
||||
"""
|
||||
from .metadata import Metadata
|
||||
from .media import Media
|
||||
from .base_module import BaseModule
|
||||
@@ -14,4 +13,4 @@ from .enricher import Enricher
|
||||
from .feeder import Feeder
|
||||
from .storage import Storage
|
||||
from .extractor import Extractor
|
||||
from .formatter import Formatter
|
||||
from .formatter import Formatter
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Any, Type, TYPE_CHECKING
|
||||
from typing import Mapping, Any, TYPE_CHECKING
|
||||
from abc import ABC
|
||||
from copy import deepcopy, copy
|
||||
from copy import deepcopy
|
||||
from tempfile import TemporaryDirectory
|
||||
from auto_archiver.utils import url as UrlUtil
|
||||
from auto_archiver.core.consts import MODULE_TYPES as CONF_MODULE_TYPES
|
||||
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .module import ModuleFactory
|
||||
|
||||
class BaseModule(ABC):
|
||||
|
||||
class BaseModule(ABC):
|
||||
"""
|
||||
Base module class. All modules should inherit from this class.
|
||||
|
||||
@@ -46,14 +45,13 @@ class BaseModule(ABC):
|
||||
|
||||
@property
|
||||
def storages(self) -> list:
|
||||
return self.config.get('storages', [])
|
||||
return self.config.get("storages", [])
|
||||
|
||||
def config_setup(self, config: dict):
|
||||
|
||||
# this is important. Each instance is given its own deepcopied config, so modules cannot
|
||||
# change values to affect other modules
|
||||
config = deepcopy(config)
|
||||
authentication = deepcopy(config.pop('authentication', {}))
|
||||
authentication = deepcopy(config.pop("authentication", {}))
|
||||
|
||||
self.authentication = authentication
|
||||
self.config = config
|
||||
@@ -61,18 +59,28 @@ class BaseModule(ABC):
|
||||
setattr(self, key, val)
|
||||
|
||||
def setup(self):
|
||||
# For any additional setup required by modules, e.g. autehntication
|
||||
# For any additional setup required by modules outside of the configs in the manifesst,
|
||||
# e.g. authentication
|
||||
pass
|
||||
|
||||
def auth_for_site(self, site: str, extract_cookies=True) -> Mapping[str, Any]:
|
||||
"""
|
||||
Returns the authentication information for a given site. This is used to authenticate
|
||||
with a site before extracting data. The site should be the domain of the site, e.g. 'twitter.com'
|
||||
|
||||
|
||||
:param site: the domain of the site to get authentication information for
|
||||
:param extract_cookies: whether or not to extract cookies from the given browser/file and return the cookie jar (disabling can speed up processing if you don't actually need the cookies jar).
|
||||
|
||||
:returns: authdict dict of login information for the given site
|
||||
:returns: authdict dict -> {
|
||||
"username": str,
|
||||
"password": str,
|
||||
"api_key": str,
|
||||
"api_secret": str,
|
||||
"cookie": str,
|
||||
"cookies_file": str,
|
||||
"cookies_from_browser": str,
|
||||
"cookies_jar": CookieJar
|
||||
}
|
||||
|
||||
**Global options:**\n
|
||||
* cookies_from_browser: str - the name of the browser to extract cookies from (e.g. 'chrome', 'firefox' - uses ytdlp under the hood to extract\n
|
||||
@@ -86,16 +94,15 @@ class BaseModule(ABC):
|
||||
* cookie: str - a cookie string to use for login (specific to this site)\n
|
||||
* cookies_file: str - the path to a cookies file to use for login (specific to this site)\n
|
||||
* cookies_from_browser: str - the name of the browser to extract cookies from (specitic for this site)\n
|
||||
|
||||
"""
|
||||
# TODO: think about if/how we can deal with sites that have multiple domains (main one is x.com/twitter.com)
|
||||
# for now the user must enter them both, like "x.com,twitter.com" in their config. Maybe we just hard-code?
|
||||
|
||||
site = UrlUtil.domain_for_url(site).lstrip("www.")
|
||||
domain = UrlUtil.domain_for_url(site).removeprefix("www.")
|
||||
# add the 'www' version of the site to the list of sites to check
|
||||
authdict = {}
|
||||
|
||||
|
||||
for to_try in [site, f"www.{site}"]:
|
||||
for to_try in [site, domain, f"www.{domain}"]:
|
||||
if to_try in self.authentication:
|
||||
authdict.update(self.authentication[to_try])
|
||||
break
|
||||
@@ -103,18 +110,21 @@ class BaseModule(ABC):
|
||||
# do a fuzzy string match just to print a warning - don't use it since it's insecure
|
||||
if not authdict:
|
||||
for key in self.authentication.keys():
|
||||
if key in site or site in key:
|
||||
logger.debug(f"Could not find exact authentication information for site '{site}'. \
|
||||
if key in domain or domain in key:
|
||||
logger.debug(
|
||||
f"Could not find exact authentication information for '{domain}'. \
|
||||
did find information for '{key}' which is close, is this what you meant? \
|
||||
If so, edit your authentication settings to make sure it exactly matches.")
|
||||
If so, edit your authentication settings to make sure it exactly matches."
|
||||
)
|
||||
|
||||
def get_ytdlp_cookiejar(args):
|
||||
import yt_dlp
|
||||
from yt_dlp import parse_options
|
||||
|
||||
logger.debug(f"Extracting cookies from settings: {args[1]}")
|
||||
# parse_options returns a named tuple as follows, we only need the ydl_options part
|
||||
# collections.namedtuple('ParsedOptions', ('parser', 'options', 'urls', 'ydl_opts'))
|
||||
ytdlp_opts = getattr(parse_options(args), 'ydl_opts')
|
||||
ytdlp_opts = getattr(parse_options(args), "ydl_opts")
|
||||
return yt_dlp.YoutubeDL(ytdlp_opts).cookiejar
|
||||
|
||||
get_cookiejar_options = None
|
||||
@@ -125,22 +135,21 @@ If so, edit your authentication settings to make sure it exactly matches.")
|
||||
# 3. cookies_from_browser setting in global config
|
||||
# 4. cookies_file setting in global config
|
||||
|
||||
if 'cookies_from_browser' in authdict:
|
||||
get_cookiejar_options = ['--cookies-from-browser', authdict['cookies_from_browser']]
|
||||
elif 'cookies_file' in authdict:
|
||||
get_cookiejar_options = ['--cookies', authdict['cookies_file']]
|
||||
elif 'cookies_from_browser' in self.authentication:
|
||||
authdict['cookies_from_browser'] = self.authentication['cookies_from_browser']
|
||||
get_cookiejar_options = ['--cookies-from-browser', self.authentication['cookies_from_browser']]
|
||||
elif 'cookies_file' in self.authentication:
|
||||
authdict['cookies_file'] = self.authentication['cookies_file']
|
||||
get_cookiejar_options = ['--cookies', self.authentication['cookies_file']]
|
||||
if "cookies_from_browser" in authdict:
|
||||
get_cookiejar_options = ["--cookies-from-browser", authdict["cookies_from_browser"]]
|
||||
elif "cookies_file" in authdict:
|
||||
get_cookiejar_options = ["--cookies", authdict["cookies_file"]]
|
||||
elif "cookies_from_browser" in self.authentication:
|
||||
authdict["cookies_from_browser"] = self.authentication["cookies_from_browser"]
|
||||
get_cookiejar_options = ["--cookies-from-browser", self.authentication["cookies_from_browser"]]
|
||||
elif "cookies_file" in self.authentication:
|
||||
authdict["cookies_file"] = self.authentication["cookies_file"]
|
||||
get_cookiejar_options = ["--cookies", self.authentication["cookies_file"]]
|
||||
|
||||
|
||||
if get_cookiejar_options:
|
||||
authdict['cookies_jar'] = get_ytdlp_cookiejar(get_cookiejar_options)
|
||||
authdict["cookies_jar"] = get_ytdlp_cookiejar(get_cookiejar_options)
|
||||
|
||||
return authdict
|
||||
|
||||
|
||||
def repr(self):
|
||||
return f"Module<'{self.display_name}' (config: {self.config[self.name]})>"
|
||||
return f"Module<'{self.display_name}' (config: {self.config[self.name]})>"
|
||||
|
||||
@@ -6,26 +6,28 @@ flexible setup in various environments.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from ruamel.yaml import YAML, CommentedMap, add_representer
|
||||
from ruamel.yaml import YAML, CommentedMap
|
||||
import json
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from copy import deepcopy
|
||||
from auto_archiver.core.consts import MODULE_TYPES
|
||||
|
||||
from typing import Any, List, Type, Tuple
|
||||
|
||||
_yaml: YAML = YAML()
|
||||
|
||||
DEFAULT_CONFIG_FILE = "secrets/orchestration.yaml"
|
||||
|
||||
EMPTY_CONFIG = _yaml.load("""
|
||||
EMPTY_CONFIG = _yaml.load(
|
||||
"""
|
||||
# Auto Archiver Configuration
|
||||
|
||||
# Steps are the modules that will be run in the order they are defined
|
||||
steps:""" + "".join([f"\n {module}s: []" for module in MODULE_TYPES]) + \
|
||||
"""
|
||||
steps:"""
|
||||
+ "".join([f"\n {module}s: []" for module in MODULE_TYPES])
|
||||
+ """
|
||||
|
||||
# Global configuration
|
||||
|
||||
@@ -52,50 +54,54 @@ authentication: {}
|
||||
logging:
|
||||
level: INFO
|
||||
|
||||
""")
|
||||
"""
|
||||
)
|
||||
# note: 'logging' is explicitly added above in order to better format the config file
|
||||
|
||||
|
||||
# Arg Parse Actions/Classes
|
||||
class AuthenticationJsonParseAction(argparse.Action):
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
|
||||
try:
|
||||
auth_dict = json.loads(values)
|
||||
setattr(namespace, self.dest, auth_dict)
|
||||
except json.JSONDecodeError as e:
|
||||
raise argparse.ArgumentTypeError(f"Invalid JSON input for argument '{self.dest}': {e}")
|
||||
raise argparse.ArgumentTypeError(f"Invalid JSON input for argument '{self.dest}': {e}") from e
|
||||
|
||||
def load_from_file(path):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
with open(path, "r") as f:
|
||||
try:
|
||||
auth_dict = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
f.seek(0)
|
||||
# maybe it's yaml, try that
|
||||
auth_dict = _yaml.load(f)
|
||||
if auth_dict.get('authentication'):
|
||||
auth_dict = auth_dict['authentication']
|
||||
auth_dict['load_from_file'] = path
|
||||
if auth_dict.get("authentication"):
|
||||
auth_dict = auth_dict["authentication"]
|
||||
auth_dict["load_from_file"] = path
|
||||
return auth_dict
|
||||
except:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if isinstance(auth_dict, dict) and auth_dict.get('from_file'):
|
||||
auth_dict = load_from_file(auth_dict['from_file'])
|
||||
if isinstance(auth_dict, dict) and auth_dict.get("from_file"):
|
||||
auth_dict = load_from_file(auth_dict["from_file"])
|
||||
elif isinstance(auth_dict, str):
|
||||
# if it's a string
|
||||
auth_dict = load_from_file(auth_dict)
|
||||
|
||||
|
||||
if not isinstance(auth_dict, dict):
|
||||
raise argparse.ArgumentTypeError("Authentication must be a dictionary of site names and their authentication methods")
|
||||
global_options = ['cookies_from_browser', 'cookies_file', 'load_from_file']
|
||||
raise argparse.ArgumentTypeError(
|
||||
"Authentication must be a dictionary of site names and their authentication methods"
|
||||
)
|
||||
global_options = ["cookies_from_browser", "cookies_file", "load_from_file"]
|
||||
for key, auth in auth_dict.items():
|
||||
if key in global_options:
|
||||
continue
|
||||
if not isinstance(key, str) or not isinstance(auth, dict):
|
||||
raise argparse.ArgumentTypeError(f"Authentication must be a dictionary of site names and their authentication methods. Valid global configs are {global_options}")
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"Authentication must be a dictionary of site names and their authentication methods. Valid global configs are {global_options}"
|
||||
)
|
||||
|
||||
setattr(namespace, self.dest, auth_dict)
|
||||
|
||||
@@ -106,14 +112,13 @@ class UniqueAppendAction(argparse.Action):
|
||||
if value not in getattr(namespace, self.dest):
|
||||
getattr(namespace, self.dest).append(value)
|
||||
|
||||
class DefaultValidatingParser(argparse.ArgumentParser):
|
||||
|
||||
class DefaultValidatingParser(argparse.ArgumentParser):
|
||||
def error(self, message):
|
||||
"""
|
||||
Override of error to format a nicer looking error message using logger
|
||||
"""
|
||||
logger.error("Problem with configuration file (tip: use --help to see the available options):")
|
||||
logger.error(message)
|
||||
logger.error(f"Problem with configuration file (tip: use --help to see the available options): \n{message}")
|
||||
self.exit(2)
|
||||
|
||||
def parse_known_args(self, args=None, namespace=None):
|
||||
@@ -130,14 +135,15 @@ class DefaultValidatingParser(argparse.ArgumentParser):
|
||||
try:
|
||||
self._check_value(action, action.default)
|
||||
except argparse.ArgumentError as e:
|
||||
logger.error(f"You have an invalid setting in your configuration file ({action.dest}):")
|
||||
logger.error(e)
|
||||
logger.error(f"You have an invalid setting in your configuration file ({action.dest}):\n {e}")
|
||||
exit()
|
||||
|
||||
return super().parse_known_args(args, namespace)
|
||||
|
||||
|
||||
# Config Utils
|
||||
|
||||
|
||||
def to_dot_notation(yaml_conf: CommentedMap | dict) -> dict:
|
||||
dotdict = {}
|
||||
|
||||
@@ -151,6 +157,7 @@ def to_dot_notation(yaml_conf: CommentedMap | dict) -> dict:
|
||||
process_subdict(yaml_conf)
|
||||
return dotdict
|
||||
|
||||
|
||||
def from_dot_notation(dotdict: dict) -> dict:
|
||||
normal_dict = {}
|
||||
|
||||
@@ -171,9 +178,11 @@ def from_dot_notation(dotdict: dict) -> dict:
|
||||
def is_list_type(value):
|
||||
return isinstance(value, list) or isinstance(value, tuple) or isinstance(value, set)
|
||||
|
||||
|
||||
def is_dict_type(value):
|
||||
return isinstance(value, dict) or isinstance(value, CommentedMap)
|
||||
|
||||
|
||||
def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap:
|
||||
yaml_dict: CommentedMap = deepcopy(yaml_dict)
|
||||
|
||||
@@ -184,7 +193,7 @@ def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap:
|
||||
yaml_subdict[key] = value
|
||||
continue
|
||||
|
||||
if key == 'steps':
|
||||
if key == "steps":
|
||||
for module_type, modules in value.items():
|
||||
# overwrite the 'steps' from the config file with the ones from the CLI
|
||||
yaml_subdict[key][module_type] = modules
|
||||
@@ -199,6 +208,7 @@ def merge_dicts(dotdict: dict, yaml_dict: CommentedMap) -> CommentedMap:
|
||||
update_dict(from_dot_notation(dotdict), yaml_dict)
|
||||
return yaml_dict
|
||||
|
||||
|
||||
def read_yaml(yaml_filename: str) -> CommentedMap:
|
||||
config = None
|
||||
try:
|
||||
@@ -212,20 +222,26 @@ def read_yaml(yaml_filename: str) -> CommentedMap:
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# TODO: make this tidier/find a way to notify of which keys should not be stored
|
||||
|
||||
|
||||
def store_yaml(config: CommentedMap, yaml_filename: str) -> None:
|
||||
config_to_save = deepcopy(config)
|
||||
|
||||
## if the save path is the default location (secrets) then create the 'secrets' folder
|
||||
if os.path.dirname(yaml_filename) == "secrets":
|
||||
os.makedirs("secrets", exist_ok=True)
|
||||
|
||||
auth_dict = config_to_save.get("authentication", {})
|
||||
if auth_dict and auth_dict.get('load_from_file'):
|
||||
if auth_dict and auth_dict.get("load_from_file"):
|
||||
# remove all other values from the config, don't want to store it in the config file
|
||||
auth_dict = {"load_from_file": auth_dict["load_from_file"]}
|
||||
|
||||
config_to_save.pop('urls', None)
|
||||
config_to_save.pop("urls", None)
|
||||
with open(yaml_filename, "w", encoding="utf-8") as outf:
|
||||
_yaml.dump(config_to_save, outf)
|
||||
|
||||
|
||||
def is_valid_config(config: CommentedMap) -> bool:
|
||||
return config and config != EMPTY_CONFIG
|
||||
return config and config != EMPTY_CONFIG
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
class SetupError(ValueError):
|
||||
pass
|
||||
|
||||
MODULE_TYPES = [
|
||||
'feeder',
|
||||
'extractor',
|
||||
'enricher',
|
||||
'database',
|
||||
'storage',
|
||||
'formatter'
|
||||
]
|
||||
|
||||
MODULE_TYPES = ["feeder", "extractor", "enricher", "database", "storage", "formatter"]
|
||||
|
||||
MANIFEST_FILE = "__manifest__.py"
|
||||
|
||||
DEFAULT_MANIFEST = {
|
||||
'name': '', # the display name of the module
|
||||
'author': 'Bellingcat', # creator of the module, leave this as Bellingcat or set your own name!
|
||||
'type': [], # the type of the module, can be one or more of MODULE_TYPES
|
||||
'requires_setup': True, # whether or not this module requires additional setup such as setting API Keys or installing additional software
|
||||
'description': '', # a description of the module
|
||||
'dependencies': {}, # external dependencies, e.g. python packages or binaries, in dictionary format
|
||||
'entry_point': '', # the entry point for the module, in the format 'module_name::ClassName'. This can be left blank to use the default entry point of module_name::ModuleName
|
||||
'version': '1.0', # the version of the module
|
||||
'configs': {} # any configuration options this module has, these will be exposed to the user in the config file or via the command line
|
||||
}
|
||||
"name": "", # the display name of the module
|
||||
"author": "Bellingcat", # creator of the module, leave this as Bellingcat or set your own name!
|
||||
"type": [], # the type of the module, can be one or more of MODULE_TYPES
|
||||
"requires_setup": True, # whether or not this module requires additional setup such as setting API Keys or installing additional software
|
||||
"description": "", # a description of the module
|
||||
"dependencies": {}, # external dependencies, e.g. python packages or binaries, in dictionary format
|
||||
"entry_point": "", # the entry point for the module, in the format 'module_name::ClassName'. This can be left blank to use the default entry point of module_name::ModuleName
|
||||
"version": "1.0", # the version of the module
|
||||
"configs": {}, # any configuration options this module has, these will be exposed to the user in the config file or via the command line
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Database module for the auto-archiver that defines the interface for implementing database modules
|
||||
in the media archiving framework.
|
||||
in the media archiving framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,6 +9,7 @@ from typing import Union
|
||||
|
||||
from auto_archiver.core import Metadata, BaseModule
|
||||
|
||||
|
||||
class Database(BaseModule):
|
||||
"""
|
||||
Base class for implementing database modules in the media archiving framework.
|
||||
@@ -20,7 +21,7 @@ class Database(BaseModule):
|
||||
"""signals the DB that the given item archival has started"""
|
||||
pass
|
||||
|
||||
def failed(self, item: Metadata, reason:str) -> None:
|
||||
def failed(self, item: Metadata, reason: str) -> None:
|
||||
"""update DB accordingly for failure"""
|
||||
pass
|
||||
|
||||
@@ -34,6 +35,6 @@ class Database(BaseModule):
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def done(self, item: Metadata, cached: bool=False) -> None:
|
||||
def done(self, item: Metadata, cached: bool = False) -> None:
|
||||
"""archival result ready - should be saved to DB"""
|
||||
pass
|
||||
|
||||
@@ -8,13 +8,15 @@ the archiving step and before storage or formatting.
|
||||
|
||||
Enrichers are optional but highly useful for making the archived data more powerful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from abc import abstractmethod
|
||||
from auto_archiver.core import Metadata, BaseModule
|
||||
|
||||
|
||||
class Enricher(BaseModule):
|
||||
"""Base classes and utilities for enrichers in the Auto Archiver system.
|
||||
|
||||
|
||||
Enricher modules must implement the `enrich` method to define their behavior.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
""" The `extractor` module defines the base functionality for implementing extractors in the media archiving framework.
|
||||
This class provides common utility methods and a standard interface for extractors.
|
||||
"""The `extractor` module defines the base functionality for implementing extractors in the media archiving framework.
|
||||
This class provides common utility methods and a standard interface for extractors.
|
||||
|
||||
Factory method to initialize an extractor instance based on its name.
|
||||
Factory method to initialize an extractor instance based on its name.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from contextlib import suppress
|
||||
import mimetypes
|
||||
import os
|
||||
import mimetypes
|
||||
import requests
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from retrying import retry
|
||||
import re
|
||||
|
||||
from auto_archiver.core import Metadata, BaseModule
|
||||
from auto_archiver.utils.url import get_media_url_best_quality
|
||||
|
||||
|
||||
class Extractor(BaseModule):
|
||||
@@ -39,7 +39,7 @@ class Extractor(BaseModule):
|
||||
Used to clean unnecessary URL parameters OR unfurl redirect links
|
||||
"""
|
||||
return url
|
||||
|
||||
|
||||
def match_link(self, url: str) -> re.Match:
|
||||
"""
|
||||
Returns a match object if the given URL matches the valid_url pattern or False/None if not.
|
||||
@@ -58,7 +58,7 @@ class Extractor(BaseModule):
|
||||
"""
|
||||
if self.valid_url:
|
||||
return self.match_link(url) is not None
|
||||
|
||||
|
||||
return True
|
||||
|
||||
def _guess_file_type(self, path: str) -> str:
|
||||
@@ -72,18 +72,31 @@ class Extractor(BaseModule):
|
||||
return ""
|
||||
|
||||
@retry(wait_random_min=500, wait_random_max=3500, stop_max_attempt_number=5)
|
||||
def download_from_url(self, url: str, to_filename: str = None, verbose=True) -> str:
|
||||
def download_from_url(self, url: str, to_filename: str = None, verbose=True, try_best_quality=False) -> str:
|
||||
"""
|
||||
downloads a URL to provided filename, or inferred from URL, returns local filename
|
||||
downloads a URL to provided filename, or inferred from URL, returns local filename
|
||||
Warning: if try_best_quality is True, it will return a tuple of (filename, best_quality_url) if the download was successful.
|
||||
"""
|
||||
if any(url.startswith(x) for x in ["blob:", "data:"]):
|
||||
return None, url if try_best_quality else None
|
||||
|
||||
if try_best_quality:
|
||||
with suppress(Exception):
|
||||
# Attempt to download the original URL
|
||||
best_quality_url = get_media_url_best_quality(url)
|
||||
orig_download = self.download_from_url(best_quality_url, to_filename, verbose)
|
||||
if orig_download:
|
||||
return orig_download, best_quality_url
|
||||
|
||||
if not to_filename:
|
||||
to_filename = url.split('/')[-1].split('?')[0]
|
||||
to_filename = url.split("/")[-1].split("?")[0]
|
||||
if len(to_filename) > 64:
|
||||
to_filename = to_filename[-64:]
|
||||
to_filename = os.path.join(self.tmp_dir, to_filename)
|
||||
if verbose: logger.debug(f"downloading {url[0:50]=} {to_filename=}")
|
||||
if verbose:
|
||||
logger.debug(f"Downloading {to_filename=}")
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36'
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36"
|
||||
}
|
||||
try:
|
||||
d = requests.get(url, stream=True, headers=headers, timeout=30)
|
||||
@@ -91,25 +104,29 @@ class Extractor(BaseModule):
|
||||
|
||||
# get mimetype from the response headers
|
||||
if not mimetypes.guess_type(to_filename)[0]:
|
||||
content_type = d.headers.get('Content-Type') or self._guess_file_type(url)
|
||||
content_type = d.headers.get("Content-Type") or self._guess_file_type(url)
|
||||
extension = mimetypes.guess_extension(content_type)
|
||||
if extension:
|
||||
to_filename += extension
|
||||
|
||||
with open(to_filename, 'wb') as f:
|
||||
with open(to_filename, "wb") as f:
|
||||
for chunk in d.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
if try_best_quality:
|
||||
return to_filename, url
|
||||
return to_filename
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch the Media URL: {e}")
|
||||
if try_best_quality:
|
||||
return None, url
|
||||
|
||||
@abstractmethod
|
||||
def download(self, item: Metadata) -> Metadata | False:
|
||||
"""
|
||||
Downloads the media from the given URL and returns a Metadata object with the downloaded media.
|
||||
|
||||
|
||||
If the URL is not supported or the download fails, this method should return False.
|
||||
|
||||
"""
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
The feeder base module defines the interface for implementing feeders in the media archiving framework.
|
||||
The feeder base module defines the interface for implementing feeders in the media archiving framework.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -7,8 +7,8 @@ from abc import abstractmethod
|
||||
from auto_archiver.core import Metadata
|
||||
from auto_archiver.core import BaseModule
|
||||
|
||||
class Feeder(BaseModule):
|
||||
|
||||
class Feeder(BaseModule):
|
||||
"""
|
||||
Base class for implementing feeders in the media archiving framework.
|
||||
|
||||
@@ -19,7 +19,7 @@ class Feeder(BaseModule):
|
||||
def __iter__(self) -> Metadata:
|
||||
"""
|
||||
Returns an iterator (use `yield`) over the items to be archived.
|
||||
|
||||
|
||||
These should be instances of Metadata, typically created with Metadata().set_url(url).
|
||||
"""
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -12,7 +12,7 @@ from auto_archiver.core import Metadata, Media, BaseModule
|
||||
class Formatter(BaseModule):
|
||||
"""
|
||||
Base class for implementing formatters in the media archiving framework.
|
||||
|
||||
|
||||
Subclasses must implement the `format` method to define their behavior.
|
||||
"""
|
||||
|
||||
@@ -21,4 +21,4 @@ class Formatter(BaseModule):
|
||||
"""
|
||||
Formats a Metadata object into a user-viewable format (e.g. HTML) and stores it if needed.
|
||||
"""
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -6,12 +6,12 @@ nested media retrieval, and type validation.
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses_json import dataclass_json, config
|
||||
import mimetypes
|
||||
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
|
||||
@dataclass_json # annotation order matters
|
||||
@@ -21,14 +21,14 @@ class Media:
|
||||
Represents a media file with associated properties and storage details.
|
||||
|
||||
Attributes:
|
||||
- filename: The file path of the media.
|
||||
- key: An optional identifier for the media.
|
||||
- filename: The file path of the media as saved locally (temporarily, before uploading to the storage).
|
||||
- urls: A list of URLs where the media is stored or accessible.
|
||||
- properties: Additional metadata or transformations for the media.
|
||||
- _mimetype: The media's mimetype (e.g., image/jpeg, video/mp4).
|
||||
"""
|
||||
|
||||
filename: str
|
||||
key: str = None
|
||||
_key: str = None
|
||||
urls: List[str] = field(default_factory=list)
|
||||
properties: dict = field(default_factory=dict)
|
||||
_mimetype: str = None # eg: image/jpeg
|
||||
@@ -47,19 +47,20 @@ class Media:
|
||||
for any_media in self.all_inner_media(include_self=True):
|
||||
s.store(any_media, url, metadata=metadata)
|
||||
|
||||
def all_inner_media(self, include_self=False):
|
||||
def all_inner_media(self, include_self=False) -> Iterator[Media]:
|
||||
"""Retrieves all media, including nested media within properties or transformations on original media.
|
||||
This function returns a generator for all the inner media.
|
||||
|
||||
"""
|
||||
if include_self: yield self
|
||||
if include_self:
|
||||
yield self
|
||||
for prop in self.properties.values():
|
||||
if isinstance(prop, Media):
|
||||
if isinstance(prop, Media):
|
||||
for inner_media in prop.all_inner_media(include_self=True):
|
||||
yield inner_media
|
||||
if isinstance(prop, list):
|
||||
for prop_media in prop:
|
||||
if isinstance(prop_media, Media):
|
||||
if isinstance(prop_media, Media):
|
||||
for inner_media in prop_media.all_inner_media(include_self=True):
|
||||
yield inner_media
|
||||
|
||||
@@ -67,6 +68,10 @@ class Media:
|
||||
# checks if the media is already stored in the given storage
|
||||
return len(self.urls) > 0 and len(self.urls) == len(in_storage.config["steps"]["storages"])
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return self._key
|
||||
|
||||
def set(self, key: str, value: Any) -> Media:
|
||||
self.properties[key] = value
|
||||
return self
|
||||
@@ -81,7 +86,7 @@ class Media:
|
||||
@property # getter .mimetype
|
||||
def mimetype(self) -> str:
|
||||
if not self.filename or len(self.filename) == 0:
|
||||
logger.warning(f"cannot get mimetype from media without filename: {self}")
|
||||
logger.warning(f"Cannot get mimetype from media without filename: {self}")
|
||||
return ""
|
||||
if not self._mimetype:
|
||||
self._mimetype = mimetypes.guess_type(self.filename)[0]
|
||||
@@ -110,15 +115,16 @@ class Media:
|
||||
# checks for video streams with ffmpeg, or min file size for a video
|
||||
# self.is_video() should be used together with this method
|
||||
try:
|
||||
streams = ffmpeg.probe(self.filename, select_streams='v')['streams']
|
||||
logger.warning(f"STREAMS FOR {self.filename} {streams}")
|
||||
streams = ffmpeg.probe(self.filename, select_streams="v")["streams"]
|
||||
logger.debug(f"Streams for {self.filename}: {streams}")
|
||||
return any(s.get("duration_ts", 0) > 0 for s in streams)
|
||||
except Error: return False # ffmpeg errors when reading bad files
|
||||
except Error:
|
||||
return False # ffmpeg errors when reading bad files
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"{e}: {traceback.format_exc()}")
|
||||
try:
|
||||
fsize = os.path.getsize(self.filename)
|
||||
return fsize > 20_000
|
||||
except: pass
|
||||
except Exception as e:
|
||||
pass
|
||||
return True
|
||||
|
||||
@@ -13,14 +13,15 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
from typing import Any, List, Union, Dict
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses_json import dataclass_json, config
|
||||
from dataclasses_json import dataclass_json
|
||||
import datetime
|
||||
from urllib.parse import urlparse
|
||||
from dateutil.parser import parse as parse_dt
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from .media import Media
|
||||
|
||||
|
||||
@dataclass_json # annotation order matters
|
||||
@dataclass
|
||||
class Metadata:
|
||||
@@ -40,19 +41,23 @@ class Metadata:
|
||||
- If `True`, this instance's values are overwritten by `right`.
|
||||
- If `False`, the inverse applies.
|
||||
"""
|
||||
if not right: return self
|
||||
if not right:
|
||||
return self
|
||||
if overwrite_left:
|
||||
if right.status and len(right.status):
|
||||
self.status = right.status
|
||||
self._context.update(right._context)
|
||||
for k, v in right.metadata.items():
|
||||
assert k not in self.metadata or type(v) == type(self.get(k))
|
||||
if type(v) not in [dict, list, set] or k not in self.metadata:
|
||||
assert k not in self.metadata or type(v) is type(self.get(k))
|
||||
if not isinstance(v, (dict, list, set)) or k not in self.metadata:
|
||||
self.set(k, v)
|
||||
else: # key conflict
|
||||
if type(v) in [dict, set]: self.set(k, self.get(k) | v)
|
||||
elif type(v) == list: self.set(k, self.get(k) + v)
|
||||
if isinstance(v, (dict, set)):
|
||||
self.set(k, self.get(k) | v)
|
||||
elif type(v) is list:
|
||||
self.set(k, self.get(k) + v)
|
||||
self.media.extend(right.media)
|
||||
|
||||
else: # invert and do same logic
|
||||
return right.merge(self)
|
||||
return self
|
||||
@@ -69,7 +74,7 @@ class Metadata:
|
||||
|
||||
def append(self, key: str, val: Any) -> Metadata:
|
||||
if key not in self.metadata:
|
||||
self.metadata[key] = []
|
||||
self.metadata[key] = []
|
||||
self.metadata[key] = val
|
||||
return self
|
||||
|
||||
@@ -80,24 +85,26 @@ class Metadata:
|
||||
return self.metadata.get(key, default)
|
||||
|
||||
def success(self, context: str = None) -> Metadata:
|
||||
if context: self.status = f"{context}: success"
|
||||
else: self.status = "success"
|
||||
if context:
|
||||
self.status = f"{context}: success"
|
||||
else:
|
||||
self.status = "success"
|
||||
return self
|
||||
|
||||
def is_success(self) -> bool:
|
||||
return "success" in self.status
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
meaningfull_ids = set(self.metadata.keys()) - set(["_processed_at", "url", "total_bytes", "total_size", "archive_duration_seconds"])
|
||||
meaningfull_ids = set(self.metadata.keys()) - set(
|
||||
["_processed_at", "url", "original_url", "total_bytes", "total_size", "archive_duration_seconds"]
|
||||
)
|
||||
return not self.is_success() and len(self.media) == 0 and len(meaningfull_ids) == 0
|
||||
|
||||
@property # getter .netloc
|
||||
def netloc(self) -> str:
|
||||
return urlparse(self.get_url()).netloc
|
||||
|
||||
|
||||
# custom getter/setters
|
||||
|
||||
# custom getter/setters
|
||||
|
||||
def set_url(self, url: str) -> Metadata:
|
||||
assert type(url) is str and len(url) > 0, "invalid URL"
|
||||
@@ -120,36 +127,43 @@ class Metadata:
|
||||
return self.get("title")
|
||||
|
||||
def set_timestamp(self, timestamp: datetime.datetime) -> Metadata:
|
||||
if type(timestamp) == str:
|
||||
if isinstance(timestamp, str):
|
||||
timestamp = parse_dt(timestamp)
|
||||
assert type(timestamp) == datetime.datetime, "set_timestamp expects a datetime instance"
|
||||
assert isinstance(timestamp, datetime.datetime), "set_timestamp expects a datetime instance"
|
||||
return self.set("timestamp", timestamp)
|
||||
|
||||
def get_timestamp(self, utc=True, iso=True) -> datetime.datetime:
|
||||
def get_timestamp(self, utc=True, iso=True) -> datetime.datetime | str | None:
|
||||
ts = self.get("timestamp")
|
||||
if not ts: return
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
if type(ts) == str: ts = datetime.datetime.fromisoformat(ts)
|
||||
if type(ts) == float: ts = datetime.datetime.fromtimestamp(ts)
|
||||
if utc: ts = ts.replace(tzinfo=datetime.timezone.utc)
|
||||
if iso: return ts.isoformat()
|
||||
return ts
|
||||
if isinstance(ts, str):
|
||||
ts = datetime.datetime.fromisoformat(ts)
|
||||
elif isinstance(ts, float):
|
||||
ts = datetime.datetime.fromtimestamp(ts)
|
||||
if utc:
|
||||
ts = ts.replace(tzinfo=datetime.timezone.utc)
|
||||
return ts.isoformat() if iso else ts
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to parse timestamp {ts}: {e}")
|
||||
return
|
||||
return None
|
||||
|
||||
def add_media(self, media: Media, id: str = None) -> Metadata:
|
||||
# adds a new media, optionally including an id
|
||||
if media is None: return
|
||||
if media is None:
|
||||
return
|
||||
if id is not None:
|
||||
assert not len([1 for m in self.media if m.get("id") == id]), f"cannot add 2 pieces of media with the same id {id}"
|
||||
assert not len([1 for m in self.media if m.get("id") == id]), (
|
||||
f"cannot add 2 pieces of media with the same id {id}"
|
||||
)
|
||||
media.set("id", id)
|
||||
self.media.append(media)
|
||||
return media
|
||||
|
||||
def get_media_by_id(self, id: str, default=None) -> Media:
|
||||
for m in self.media:
|
||||
if m.get("id") == id: return m
|
||||
if m.get("id") == id:
|
||||
return m
|
||||
return default
|
||||
|
||||
def remove_duplicate_media_by_hash(self) -> None:
|
||||
@@ -159,23 +173,30 @@ class Metadata:
|
||||
with open(filename, "rb") as f:
|
||||
while True:
|
||||
buf = f.read(chunksize)
|
||||
if not buf: break
|
||||
if not buf:
|
||||
break
|
||||
hash_algo.update(buf)
|
||||
return hash_algo.hexdigest()
|
||||
|
||||
media_hashes = set()
|
||||
new_media = []
|
||||
for m in self.media:
|
||||
if not m.filename:
|
||||
new_media.append(m)
|
||||
continue
|
||||
h = m.get("hash")
|
||||
if not h: h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename)
|
||||
if len(h) and h in media_hashes: continue
|
||||
if not h:
|
||||
h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename)
|
||||
if len(h) and h in media_hashes:
|
||||
continue
|
||||
media_hashes.add(h)
|
||||
new_media.append(m)
|
||||
self.media = new_media
|
||||
|
||||
def get_first_image(self, default=None) -> Media:
|
||||
for m in self.media:
|
||||
if "image" in m.mimetype: return m
|
||||
if "image" in m.mimetype:
|
||||
return m
|
||||
return default
|
||||
|
||||
def set_final_media(self, final: Media) -> Metadata:
|
||||
@@ -193,22 +214,25 @@ class Metadata:
|
||||
def __str__(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def choose_most_complete(results: List[Metadata]) -> Metadata:
|
||||
# returns the most complete result from a list of results
|
||||
# prioritizes results with more media, then more metadata
|
||||
if len(results) == 0: return None
|
||||
if len(results) == 1: return results[0]
|
||||
if len(results) == 0:
|
||||
return None
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
most_complete = results[0]
|
||||
for r in results[1:]:
|
||||
if len(r.media) > len(most_complete.media): most_complete = r
|
||||
elif len(r.media) == len(most_complete.media) and len(r.metadata) > len(most_complete.metadata): most_complete = r
|
||||
if len(r.media) > len(most_complete.media):
|
||||
most_complete = r
|
||||
elif len(r.media) == len(most_complete.media) and len(r.metadata) > len(most_complete.metadata):
|
||||
most_complete = r
|
||||
return most_complete
|
||||
|
||||
def set_context(self, key: str, val: Any) -> Metadata:
|
||||
self._context[key] = val
|
||||
return self
|
||||
|
||||
|
||||
def get_context(self, key: str, default: Any = None) -> Any:
|
||||
return self._context.get(key, default)
|
||||
return self._context.get(key, default)
|
||||
|
||||
@@ -3,10 +3,12 @@ Defines the Step abstract base class, which acts as a blueprint for steps in the
|
||||
by handling user configuration, validating the steps properties, and implementing dynamic instantiation.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import subprocess
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, TYPE_CHECKING
|
||||
from typing import List, TYPE_CHECKING, Type
|
||||
import shutil
|
||||
import ast
|
||||
import copy
|
||||
@@ -14,9 +16,9 @@ import sys
|
||||
from importlib.util import find_spec
|
||||
import os
|
||||
from os.path import join
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
import auto_archiver
|
||||
from auto_archiver.core.consts import DEFAULT_MANIFEST, MANIFEST_FILE
|
||||
from auto_archiver.core.consts import DEFAULT_MANIFEST, MANIFEST_FILE, SetupError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base_module import BaseModule
|
||||
@@ -24,17 +26,17 @@ if TYPE_CHECKING:
|
||||
|
||||
HAS_SETUP_PATHS = False
|
||||
|
||||
class ModuleFactory:
|
||||
|
||||
class ModuleFactory:
|
||||
def __init__(self):
|
||||
self._lazy_modules = {}
|
||||
|
||||
def setup_paths(self, paths: list[str]) -> None:
|
||||
"""
|
||||
Sets up the paths for the modules to be loaded from
|
||||
|
||||
|
||||
This is necessary for the modules to be imported correctly
|
||||
|
||||
|
||||
"""
|
||||
global HAS_SETUP_PATHS
|
||||
|
||||
@@ -46,34 +48,36 @@ class ModuleFactory:
|
||||
|
||||
# see odoo/module/module.py -> initialize_sys_path
|
||||
if path not in auto_archiver.modules.__path__:
|
||||
if HAS_SETUP_PATHS == True:
|
||||
logger.warning(f"You are attempting to re-initialise the module paths with: '{path}' for a 2nd time. \
|
||||
if HAS_SETUP_PATHS:
|
||||
logger.warning(
|
||||
f"You are attempting to re-initialise the module paths with: '{path}' for a 2nd time. \
|
||||
This could lead to unexpected behaviour. It is recommended to only use a single modules path. \
|
||||
If you wish to load modules from different paths then load a 2nd python interpreter (e.g. using multiprocessing).")
|
||||
auto_archiver.modules.__path__.append(path)
|
||||
If you wish to load modules from different paths then load a 2nd python interpreter (e.g. using multiprocessing)."
|
||||
)
|
||||
auto_archiver.modules.__path__.append(path)
|
||||
|
||||
# sort based on the length of the path, so that the longest path is last in the list
|
||||
auto_archiver.modules.__path__ = sorted(auto_archiver.modules.__path__, key=len, reverse=True)
|
||||
|
||||
HAS_SETUP_PATHS = True
|
||||
|
||||
def get_module(self, module_name: str, config: dict) -> BaseModule:
|
||||
def get_module(self, module_name: str, config: dict) -> Type[BaseModule]:
|
||||
"""
|
||||
Gets and sets up a module using the provided config
|
||||
|
||||
|
||||
This will actually load and instantiate the module, and load all its dependencies (i.e. not lazy)
|
||||
|
||||
|
||||
"""
|
||||
return self.get_module_lazy(module_name).load(config)
|
||||
|
||||
def get_module_lazy(self, module_name: str, suppress_warnings: bool = False) -> LazyBaseModule:
|
||||
"""
|
||||
Lazily loads a module, returning a LazyBaseModule
|
||||
|
||||
|
||||
This has all the information about the module, but does not load the module itself or its dependencies
|
||||
|
||||
|
||||
To load an actual module, call .setup() on a lazy module
|
||||
|
||||
|
||||
"""
|
||||
if module_name in self._lazy_modules:
|
||||
return self._lazy_modules[module_name]
|
||||
@@ -81,13 +85,18 @@ class ModuleFactory:
|
||||
available = self.available_modules(limit_to_modules=[module_name], suppress_warnings=suppress_warnings)
|
||||
if not available:
|
||||
message = f"Module '{module_name}' not found. Are you sure it's installed/exists?"
|
||||
if 'archiver' in module_name:
|
||||
message += f" Did you mean {module_name.replace('archiver', 'extractor')}?"
|
||||
if "archiver" in module_name:
|
||||
message += f" Did you mean '{module_name.replace('archiver', 'extractor')}'?"
|
||||
elif "gsheet" in module_name:
|
||||
message += " Did you mean 'gsheet_feeder_db'?"
|
||||
elif "atlos" in module_name:
|
||||
message += " Did you mean 'atlos_feeder_db_storage'?"
|
||||
raise IndexError(message)
|
||||
return available[0]
|
||||
|
||||
def available_modules(self, limit_to_modules: List[str]= [], suppress_warnings: bool = False) -> List[LazyBaseModule]:
|
||||
|
||||
def available_modules(
|
||||
self, limit_to_modules: List[str] = [], suppress_warnings: bool = False
|
||||
) -> List[LazyBaseModule]:
|
||||
# search through all valid 'modules' paths. Default is 'modules' in the current directory
|
||||
|
||||
# see odoo/modules/module.py -> get_modules
|
||||
@@ -119,7 +128,7 @@ class ModuleFactory:
|
||||
self._lazy_modules[possible_module] = lazy_module
|
||||
|
||||
all_modules.append(lazy_module)
|
||||
|
||||
|
||||
if not suppress_warnings:
|
||||
for module in limit_to_modules:
|
||||
if not any(module == m.name for m in all_modules):
|
||||
@@ -127,15 +136,16 @@ class ModuleFactory:
|
||||
|
||||
return all_modules
|
||||
|
||||
|
||||
@dataclass
|
||||
class LazyBaseModule:
|
||||
|
||||
"""
|
||||
A lazy module class, which only loads the manifest and does not load the module itself.
|
||||
|
||||
This is useful for getting information about a module without actually loading it.
|
||||
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
path: str
|
||||
@@ -152,30 +162,30 @@ class LazyBaseModule:
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self.manifest['type']
|
||||
return self.manifest["type"]
|
||||
|
||||
@property
|
||||
def entry_point(self):
|
||||
if not self._entry_point and not self.manifest['entry_point']:
|
||||
if not self._entry_point and not self.manifest["entry_point"]:
|
||||
# try to create the entry point from the module name
|
||||
self._entry_point = f"{self.name}::{self.name.replace('_', ' ').title().replace(' ', '')}"
|
||||
return self._entry_point
|
||||
|
||||
@property
|
||||
def dependencies(self) -> dict:
|
||||
return self.manifest['dependencies']
|
||||
|
||||
return self.manifest["dependencies"]
|
||||
|
||||
@property
|
||||
def configs(self) -> dict:
|
||||
return self.manifest['configs']
|
||||
|
||||
return self.manifest["configs"]
|
||||
|
||||
@property
|
||||
def requires_setup(self) -> bool:
|
||||
return self.manifest['requires_setup']
|
||||
|
||||
return self.manifest["requires_setup"]
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.manifest['name']
|
||||
return self.manifest["name"]
|
||||
|
||||
@property
|
||||
def manifest(self) -> dict:
|
||||
@@ -189,40 +199,38 @@ class LazyBaseModule:
|
||||
try:
|
||||
manifest.update(ast.literal_eval(f.read()))
|
||||
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError) as e:
|
||||
raise ValueError(f"Error loading manifest from file {self.path}/{MANIFEST_FILE}: {e}")
|
||||
|
||||
raise ValueError(f"Error loading manifest from file {self.path}/{MANIFEST_FILE}: {e}") from e
|
||||
|
||||
self._manifest = manifest
|
||||
self._entry_point = manifest['entry_point']
|
||||
self.description = manifest['description']
|
||||
self.version = manifest['version']
|
||||
self._entry_point = manifest["entry_point"]
|
||||
self.description = manifest["description"]
|
||||
self.version = manifest["version"]
|
||||
|
||||
return manifest
|
||||
|
||||
def load(self, config) -> BaseModule:
|
||||
|
||||
if self._instance:
|
||||
return self._instance
|
||||
|
||||
# check external dependencies are installed
|
||||
def check_deps(deps, check):
|
||||
for dep in deps:
|
||||
if not len(dep):
|
||||
# clear out any empty strings that a user may have erroneously added
|
||||
continue
|
||||
if not check(dep):
|
||||
logger.error(f"Module '{self.name}' requires external dependency '{dep}' which is not available/setup. \
|
||||
Have you installed the required dependencies for the '{self.name}' module? See the README for more information.")
|
||||
exit(1)
|
||||
for dep in filter(lambda d: len(d.strip()) > 0, deps):
|
||||
if not check(dep.strip()):
|
||||
logger.error(
|
||||
f"Module '{self.name}' requires external dependency '{dep}' which is not available/setup. \
|
||||
Have you installed the required dependencies for the '{self.name}' module? See the documentation for more information."
|
||||
)
|
||||
raise SetupError()
|
||||
|
||||
def check_python_dep(dep):
|
||||
# first check if it's a module:
|
||||
try:
|
||||
m = self.module_factory.get_module_lazy(dep, suppress_warnings=True)
|
||||
try:
|
||||
# we must now load this module and set it up with the config
|
||||
# we must now load this module and set it up with the config
|
||||
m.load(config)
|
||||
return True
|
||||
except:
|
||||
except Exception:
|
||||
logger.error(f"Unable to setup module '{dep}' for use in module '{self.name}'")
|
||||
return False
|
||||
except IndexError:
|
||||
@@ -231,13 +239,26 @@ class LazyBaseModule:
|
||||
|
||||
return find_spec(dep)
|
||||
|
||||
check_deps(self.dependencies.get('python', []), check_python_dep)
|
||||
check_deps(self.dependencies.get('bin', []), lambda dep: shutil.which(dep))
|
||||
def check_bin_dep(dep):
|
||||
dep_exists = shutil.which(dep)
|
||||
|
||||
if dep == "docker":
|
||||
if os.environ.get("RUNNING_IN_DOCKER"):
|
||||
# this is only for the WACZ enricher, which requires docker
|
||||
# if we're already running in docker then we don't need docker
|
||||
return True
|
||||
|
||||
# check if docker daemon is running
|
||||
return dep_exists and subprocess.run(["docker", "ps", "-q"]).returncode == 0
|
||||
|
||||
return dep_exists
|
||||
|
||||
check_deps(self.dependencies.get("python", []), check_python_dep)
|
||||
check_deps(self.dependencies.get("bin", []), check_bin_dep)
|
||||
|
||||
logger.debug(f"Loading module '{self.display_name}'...")
|
||||
|
||||
for qualname in [self.name, f'auto_archiver.modules.{self.name}']:
|
||||
for qualname in [self.name, f"auto_archiver.modules.{self.name}"]:
|
||||
try:
|
||||
# first import the whole module, to make sure it's working properly
|
||||
__import__(qualname)
|
||||
@@ -246,28 +267,29 @@ class LazyBaseModule:
|
||||
pass
|
||||
|
||||
# then import the file for the entry point
|
||||
file_name, class_name = self.entry_point.split('::')
|
||||
sub_qualname = f'{qualname}.{file_name}'
|
||||
file_name, class_name = self.entry_point.split("::")
|
||||
sub_qualname = f"{qualname}.{file_name}"
|
||||
|
||||
__import__(f'{qualname}.{file_name}', fromlist=[self.entry_point])
|
||||
__import__(f"{qualname}.{file_name}", fromlist=[self.entry_point])
|
||||
# finally, get the class instance
|
||||
instance: BaseModule = getattr(sys.modules[sub_qualname], class_name)()
|
||||
|
||||
# save the instance for future easy loading
|
||||
self._instance = instance
|
||||
|
||||
# set the name, display name and module factory
|
||||
instance.name = self.name
|
||||
instance.display_name = self.display_name
|
||||
instance.module_factory = self.module_factory
|
||||
|
||||
# merge the default config with the user config
|
||||
default_config = dict((k, v['default']) for k, v in self.configs.items() if 'default' in v)
|
||||
|
||||
config[self.name] = default_config | config.get(self.name, {})
|
||||
# merge the default config with the user config
|
||||
default_config = dict((k, v["default"]) for k, v in self.configs.items() if "default" in v)
|
||||
|
||||
config[self.name] = default_config | config.get(self.name, {})
|
||||
instance.config_setup(config)
|
||||
instance.setup()
|
||||
|
||||
# save the instance for future easy loading
|
||||
self._instance = instance
|
||||
return instance
|
||||
|
||||
def __repr__(self):
|
||||
return f"Module<'{self.display_name}' ({self.name})>"
|
||||
return f"Module<'{self.display_name}' ({self.name})>"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
""" Orchestrates all archiving steps, including feeding items,
|
||||
archiving them with specific archivers, enrichment, storage,
|
||||
formatting, database operations and clean up.
|
||||
"""Orchestrates all archiving steps, including feeding items,
|
||||
archiving them with specific archivers, enrichment, storage,
|
||||
formatting, database operations and clean up.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from packaging import version
|
||||
from typing import Generator, Union, List, Type, TYPE_CHECKING
|
||||
import argparse
|
||||
import os
|
||||
@@ -14,26 +15,35 @@ import traceback
|
||||
from copy import copy
|
||||
|
||||
from rich_argparse import RichHelpFormatter
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import format_for_human_readable_console, logger
|
||||
import requests
|
||||
|
||||
from auto_archiver.utils.misc import random_str
|
||||
|
||||
from .metadata import Metadata, Media
|
||||
from auto_archiver.version import __version__
|
||||
from .config import read_yaml, store_yaml, to_dot_notation, merge_dicts, is_valid_config, \
|
||||
DefaultValidatingParser, UniqueAppendAction, AuthenticationJsonParseAction, DEFAULT_CONFIG_FILE
|
||||
from .config import (
|
||||
read_yaml,
|
||||
store_yaml,
|
||||
to_dot_notation,
|
||||
merge_dicts,
|
||||
is_valid_config,
|
||||
DefaultValidatingParser,
|
||||
UniqueAppendAction,
|
||||
AuthenticationJsonParseAction,
|
||||
DEFAULT_CONFIG_FILE,
|
||||
)
|
||||
from .module import ModuleFactory, LazyBaseModule
|
||||
from . import validators, Feeder, Extractor, Database, Storage, Formatter, Enricher
|
||||
from .consts import MODULE_TYPES
|
||||
from auto_archiver.utils.url import check_url_or_raise
|
||||
from .consts import MODULE_TYPES, SetupError
|
||||
from auto_archiver.utils.url import check_url_or_raise, clean
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base_module import BaseModule
|
||||
from .module import LazyBaseModule
|
||||
|
||||
class SetupError(ValueError):
|
||||
pass
|
||||
class ArchivingOrchestrator:
|
||||
|
||||
class ArchivingOrchestrator:
|
||||
# instance variables
|
||||
module_factory: ModuleFactory
|
||||
setup_finished: bool
|
||||
@@ -63,30 +73,63 @@ class ArchivingOrchestrator:
|
||||
epilog="Check the code at https://github.com/bellingcat/auto-archiver",
|
||||
formatter_class=RichHelpFormatter,
|
||||
)
|
||||
parser.add_argument('--help', '-h', action='store_true', dest='help', help='show a full help message and exit')
|
||||
parser.add_argument('--version', action='version', version=__version__)
|
||||
parser.add_argument('--config', action='store', dest="config_file", help='the filename of the YAML configuration file (defaults to \'config.yaml\')', default=DEFAULT_CONFIG_FILE)
|
||||
parser.add_argument('--mode', action='store', dest='mode', type=str, choices=['simple', 'full'], help='the mode to run the archiver in', default='simple')
|
||||
parser.add_argument("--help", "-h", action="store_true", dest="help", help="show a full help message and exit")
|
||||
parser.add_argument("--version", action="version", version=__version__)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
action="store",
|
||||
dest="config_file",
|
||||
help="the filename of the YAML configuration file (defaults to 'config.yaml')",
|
||||
default=DEFAULT_CONFIG_FILE,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
action="store",
|
||||
dest="mode",
|
||||
type=str,
|
||||
choices=["simple", "full"],
|
||||
help="the mode to run the archiver in",
|
||||
default="simple",
|
||||
)
|
||||
# override the default 'help' so we can inject all the configs and show those
|
||||
parser.add_argument('-s', '--store', dest='store', default=False, help='Store the created config in the config file', action=argparse.BooleanOptionalAction)
|
||||
parser.add_argument('--module_paths', dest='module_paths', nargs='+', default=[], help='additional paths to search for modules', action=UniqueAppendAction)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--store",
|
||||
dest="store",
|
||||
default=False,
|
||||
help="Store the created config in the config file",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module_paths",
|
||||
dest="module_paths",
|
||||
nargs="+",
|
||||
default=[],
|
||||
help="additional paths to search for modules",
|
||||
action=UniqueAppendAction,
|
||||
)
|
||||
|
||||
self.basic_parser = parser
|
||||
return parser
|
||||
|
||||
|
||||
def check_steps(self, config):
|
||||
for module_type in MODULE_TYPES:
|
||||
if not config['steps'].get(f"{module_type}s", []):
|
||||
if module_type == 'feeder' or module_type == 'formatter' and config['steps'].get(f"{module_type}"):
|
||||
raise SetupError(f"It appears you have '{module_type}' set under 'steps' in your configuration file, but as of version 0.13.0 of Auto Archiver, you must use '{module_type}s'. Change this in your configuration file and try again. \
|
||||
Here's how that would look: \n\nsteps:\n {module_type}s:\n - [your_{module_type}_name_here]\n {'extractors:...' if module_type == 'feeder' else '...'}\n")
|
||||
if module_type == 'extractor' and config['steps'].get('archivers'):
|
||||
raise SetupError(f"As of version 0.13.0 of Auto Archiver, the 'archivers' step name has been changed to 'extractors'. Change this in your configuration file and try again. \
|
||||
Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_here]\n enrichers:...\n")
|
||||
raise SetupError(f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)")
|
||||
if not config["steps"].get(f"{module_type}s", []):
|
||||
if (module_type == "feeder" or module_type == "formatter") and config["steps"].get(f"{module_type}"):
|
||||
raise SetupError(
|
||||
f"It appears you have '{module_type}' set under 'steps' in your configuration file, but as of version 0.13.0 of Auto Archiver, you must use '{module_type}s'. Change this in your configuration file and try again. \
|
||||
Here's how that would look: \n\nsteps:\n {module_type}s:\n - [your_{module_type}_name_here]\n {'extractors:...' if module_type == 'feeder' else '...'}\n"
|
||||
)
|
||||
if module_type == "extractor" and config["steps"].get("archivers"):
|
||||
raise SetupError(
|
||||
"As of version 0.13.0 of Auto Archiver, the 'archivers' step name has been changed to 'extractors'. Change this in your configuration file and try again. \
|
||||
Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_here]\n enrichers:...\n"
|
||||
)
|
||||
raise SetupError(
|
||||
f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)"
|
||||
)
|
||||
|
||||
def setup_complete_parser(self, basic_config: dict, yaml_config: dict, unused_args: list[str]) -> None:
|
||||
|
||||
# modules parser to get the overridden 'steps' values
|
||||
modules_parser = argparse.ArgumentParser(
|
||||
add_help=False,
|
||||
@@ -94,7 +137,9 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
self.add_modules_args(modules_parser)
|
||||
cli_modules, unused_args = modules_parser.parse_known_args(unused_args)
|
||||
for module_type in MODULE_TYPES:
|
||||
yaml_config['steps'][f"{module_type}s"] = getattr(cli_modules, f"{module_type}s", []) or yaml_config['steps'].get(f"{module_type}s", [])
|
||||
yaml_config["steps"][f"{module_type}s"] = getattr(cli_modules, f"{module_type}s", []) or yaml_config[
|
||||
"steps"
|
||||
].get(f"{module_type}s", [])
|
||||
|
||||
parser = DefaultValidatingParser(
|
||||
add_help=False,
|
||||
@@ -117,30 +162,32 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
enabled_modules = []
|
||||
# first loads the modules from the config file, then from the command line
|
||||
for module_type in MODULE_TYPES:
|
||||
enabled_modules.extend(yaml_config['steps'].get(f"{module_type}s", []))
|
||||
enabled_modules.extend(yaml_config["steps"].get(f"{module_type}s", []))
|
||||
|
||||
# clear out duplicates, but keep the order
|
||||
enabled_modules = list(dict.fromkeys(enabled_modules))
|
||||
avail_modules = self.module_factory.available_modules(limit_to_modules=enabled_modules, suppress_warnings=True)
|
||||
avail_modules = self.module_factory.available_modules(
|
||||
limit_to_modules=enabled_modules, suppress_warnings=True
|
||||
)
|
||||
self.add_individual_module_args(avail_modules, parser)
|
||||
elif basic_config.mode == 'simple':
|
||||
elif basic_config.mode == "simple":
|
||||
simple_modules = [module for module in self.module_factory.available_modules() if not module.requires_setup]
|
||||
self.add_individual_module_args(simple_modules, parser)
|
||||
|
||||
# add them to the config
|
||||
for module in simple_modules:
|
||||
for module_type in module.type:
|
||||
yaml_config['steps'].setdefault(f"{module_type}s", []).append(module.name)
|
||||
yaml_config["steps"].setdefault(f"{module_type}s", []).append(module.name)
|
||||
else:
|
||||
# load all modules, they're not using the 'simple' mode
|
||||
all_modules = self.module_factory.available_modules()
|
||||
# add all the modules to the steps
|
||||
for module in all_modules:
|
||||
for module_type in module.type:
|
||||
yaml_config['steps'].setdefault(f"{module_type}s", []).append(module.name)
|
||||
yaml_config["steps"].setdefault(f"{module_type}s", []).append(module.name)
|
||||
|
||||
self.add_individual_module_args(all_modules, parser)
|
||||
|
||||
|
||||
parser.set_defaults(**to_dot_notation(yaml_config))
|
||||
|
||||
# reload the parser with the new arguments, now that we have them
|
||||
@@ -166,43 +213,84 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
store_yaml(config, basic_config.config_file)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def add_modules_args(self, parser: argparse.ArgumentParser = None):
|
||||
if not parser:
|
||||
parser = self.parser
|
||||
|
||||
# Module loading from the command line
|
||||
for module_type in MODULE_TYPES:
|
||||
parser.add_argument(f'--{module_type}s', dest=f'{module_type}s', nargs='+', help=f'the {module_type}s to use', default=[], action=UniqueAppendAction)
|
||||
parser.add_argument(
|
||||
f"--{module_type}s",
|
||||
dest=f"{module_type}s",
|
||||
nargs="+",
|
||||
help=f"the {module_type}s to use",
|
||||
default=[],
|
||||
action=UniqueAppendAction,
|
||||
)
|
||||
|
||||
def add_additional_args(self, parser: argparse.ArgumentParser = None):
|
||||
if not parser:
|
||||
parser = self.parser
|
||||
|
||||
parser.add_argument('--authentication', dest='authentication', help='A dictionary of sites and their authentication methods \
|
||||
parser.add_argument(
|
||||
"--authentication",
|
||||
dest="authentication",
|
||||
help="A dictionary of sites and their authentication methods \
|
||||
(token, username etc.) that extractors can use to log into \
|
||||
a website. If passing this on the command line, use a JSON string. \
|
||||
You may also pass a path to a valid JSON/YAML file which will be parsed.',
|
||||
default={},
|
||||
nargs="?",
|
||||
action=AuthenticationJsonParseAction)
|
||||
You may also pass a path to a valid JSON/YAML file which will be parsed.",
|
||||
default={},
|
||||
nargs="?",
|
||||
action=AuthenticationJsonParseAction,
|
||||
)
|
||||
|
||||
# logging arguments
|
||||
parser.add_argument('--logging.level', action='store', dest='logging.level', choices=['INFO', 'DEBUG', 'ERROR', 'WARNING'], help='the logging level to use', default='INFO', type=str.upper)
|
||||
parser.add_argument('--logging.file', action='store', dest='logging.file', help='the logging file to write to', default=None)
|
||||
parser.add_argument('--logging.rotation', action='store', dest='logging.rotation', help='the logging rotation to use', default=None)
|
||||
parser.add_argument(
|
||||
"--logging.level",
|
||||
action="store",
|
||||
dest="logging.level",
|
||||
choices=["INFO", "DEBUG", "ERROR", "WARNING"],
|
||||
help="the logging level to use for the standard output and file logging",
|
||||
default="INFO",
|
||||
type=str.upper,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--logging.file", action="store", dest="logging.file", help="the logging file to write to", default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--logging.rotation",
|
||||
action="store",
|
||||
dest="logging.rotation",
|
||||
help="the logging rotation to use",
|
||||
default=None,
|
||||
)
|
||||
|
||||
def add_individual_module_args(self, modules: list[LazyBaseModule] = None, parser: argparse.ArgumentParser = None) -> None:
|
||||
parser.add_argument(
|
||||
"--logging.each_level_in_separate_file",
|
||||
action="store",
|
||||
dest="logging.each_level_in_separate_file",
|
||||
help="if set, writes each logging level to a separate file (ignores --logging.level), you must also set --logging.file. Each level will have a dedicate logs file matching your <file>.debug, <file>.info, etc.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
def add_individual_module_args(
|
||||
self, modules: list[LazyBaseModule] = None, parser: argparse.ArgumentParser = None
|
||||
) -> None:
|
||||
if not modules:
|
||||
modules = self.module_factory.available_modules()
|
||||
|
||||
|
||||
for module in modules:
|
||||
if module.name == 'cli_feeder':
|
||||
if module.name == "cli_feeder":
|
||||
# special case. For the CLI feeder, allow passing URLs directly on the command line without setting --cli_feeder.urls=
|
||||
parser.add_argument('urls', nargs='*', default=[], help='URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml')
|
||||
parser.add_argument(
|
||||
"urls",
|
||||
nargs="*",
|
||||
default=[],
|
||||
help="URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml",
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
if not module.configs:
|
||||
# this module has no configs, don't show anything in the help
|
||||
# (TODO: do we want to show something about this module though, like a description?)
|
||||
@@ -211,21 +299,21 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
group = parser.add_argument_group(module.display_name or module.name, f"{module.description[:100]}...")
|
||||
|
||||
for name, kwargs in module.configs.items():
|
||||
if not kwargs.get('metavar', None):
|
||||
if not kwargs.get("metavar", None):
|
||||
# make a nicer metavar, metavar is what's used in the help, e.g. --cli_feeder.urls [METAVAR]
|
||||
kwargs['metavar'] = name.upper()
|
||||
kwargs["metavar"] = name.upper()
|
||||
|
||||
if kwargs.get('required', False):
|
||||
if kwargs.get("required", False):
|
||||
# required args shouldn't have a 'default' value, remove it
|
||||
kwargs.pop('default', None)
|
||||
kwargs.pop("default", None)
|
||||
|
||||
kwargs.pop('cli_set', None)
|
||||
should_store = kwargs.pop('should_store', False)
|
||||
kwargs['dest'] = f"{module.name}.{kwargs.pop('dest', name)}"
|
||||
kwargs.pop("cli_set", None)
|
||||
should_store = kwargs.pop("should_store", False)
|
||||
kwargs["dest"] = f"{module.name}.{kwargs.pop('dest', name)}"
|
||||
try:
|
||||
kwargs['type'] = getattr(validators, kwargs.get('type', '__invalid__'))
|
||||
kwargs["type"] = getattr(validators, kwargs.get("type", "__invalid__"))
|
||||
except AttributeError:
|
||||
kwargs['type'] = __builtins__.get(kwargs.get('type'), str)
|
||||
kwargs["type"] = __builtins__.get(kwargs.get("type"), str)
|
||||
arg = group.add_argument(f"--{module.name}.{name}", **kwargs)
|
||||
arg.should_store = should_store
|
||||
|
||||
@@ -240,12 +328,11 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
self.basic_parser.exit()
|
||||
|
||||
def setup_logging(self, config):
|
||||
logging_config = config["logging"]
|
||||
|
||||
logging_config = config['logging']
|
||||
|
||||
if logging_config.get('enabled', True) is False:
|
||||
if logging_config.get("enabled", True) is False:
|
||||
# disabled logging settings, they're set on a higher level
|
||||
logger.disable('auto_archiver')
|
||||
logger.disable("auto_archiver")
|
||||
return
|
||||
|
||||
# setup loguru logging
|
||||
@@ -255,48 +342,87 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
pass
|
||||
|
||||
# add other logging info
|
||||
if self.logger_id is None: # note - need direct comparison to None since need to consider falsy value 0
|
||||
self.logger_id = logger.add(sys.stderr, level=logging_config['level'])
|
||||
if log_file := logging_config['file']:
|
||||
logger.add(log_file) if not logging_config['rotation'] else logger.add(log_file, rotation=logging_config['rotation'])
|
||||
if self.logger_id is None: # note - need direct comparison to None since need to consider falsy value 0
|
||||
use_level = logging_config["level"]
|
||||
self.logger_id = logger.add(
|
||||
sys.stderr,
|
||||
level=use_level,
|
||||
catch=True,
|
||||
format="<level>{extra[serialized]}</level>"
|
||||
if logging_config.get("format", "").lower() == "json"
|
||||
else format_for_human_readable_console(),
|
||||
)
|
||||
|
||||
rotation = logging_config["rotation"]
|
||||
log_file = logging_config["file"]
|
||||
|
||||
if logging_config.get("each_level_in_separate_file"):
|
||||
assert logging_config["file"], (
|
||||
"You must set --logging.file if you want to use --logging.each_level_in_separate_file"
|
||||
)
|
||||
for i, level in enumerate(["DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR"], start=1):
|
||||
logger.add(
|
||||
f"{log_file}.{i}_{level.lower()}",
|
||||
filter=lambda rec, lvl=level: rec["level"].name == lvl,
|
||||
rotation=rotation,
|
||||
format="{extra[serialized]}",
|
||||
)
|
||||
elif log_file:
|
||||
logger.add(log_file, rotation=rotation, level=use_level, format="{extra[serialized]}")
|
||||
|
||||
def install_modules(self, modules_by_type):
|
||||
"""
|
||||
Traverses all modules in 'steps' and loads them into the orchestrator, storing them in the
|
||||
Traverses all modules in 'steps' and loads them into the orchestrator, storing them in the
|
||||
orchestrator's attributes (self.feeders, self.extractors etc.). If no modules of a certain type
|
||||
are loaded, the program will exit with an error message.
|
||||
"""
|
||||
|
||||
invalid_modules = []
|
||||
for module_type in MODULE_TYPES:
|
||||
|
||||
step_items = []
|
||||
modules_to_load = modules_by_type[f"{module_type}s"]
|
||||
if not modules_to_load:
|
||||
raise SetupError(f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)")
|
||||
raise SetupError(
|
||||
f"No {module_type}s were configured. Make sure to set at least one {module_type} in your configuration file or on the command line (using --{module_type}s)"
|
||||
)
|
||||
|
||||
def check_steps_ok():
|
||||
if not len(step_items):
|
||||
if len(modules_to_load):
|
||||
logger.error(f"Unable to load any {module_type}s. Tried the following, but none were available: {modules_to_load}")
|
||||
raise SetupError(f"NO {module_type.upper()}S LOADED. Please check your configuration and try again.")
|
||||
|
||||
logger.error(
|
||||
f"Unable to load any {module_type}s. Tried the following, but none were available: {modules_to_load}"
|
||||
)
|
||||
raise SetupError(
|
||||
f"NO {module_type.upper()}S LOADED. Please check your configuration and try again."
|
||||
)
|
||||
|
||||
if (module_type == 'feeder' or module_type == 'formatter') and len(step_items) > 1:
|
||||
raise SetupError(f"Only one {module_type} is allowed, found {len(step_items)} {module_type}s. Please remove one of the following from your configuration file: {modules_to_load}")
|
||||
if (module_type == "feeder" or module_type == "formatter") and len(step_items) > 1:
|
||||
raise SetupError(
|
||||
f"Only one {module_type} is allowed, found {len(step_items)} {module_type}s. Please remove one of the following from your configuration file: {modules_to_load}"
|
||||
)
|
||||
|
||||
for module in modules_to_load:
|
||||
|
||||
if module in invalid_modules:
|
||||
continue
|
||||
|
||||
# check to make sure that we're trying to load it as the correct type - i.e. make sure the user hasn't put it under the wrong 'step'
|
||||
lazy_module: LazyBaseModule = self.module_factory.get_module_lazy(module)
|
||||
if module_type not in lazy_module.type:
|
||||
types = ",".join(f"'{t}'" for t in lazy_module.type)
|
||||
raise SetupError(
|
||||
f"Configuration Error: Module '{module}' is not a {module_type}, but has the types: {types}. Please check you set this module up under the right step in your orchestration file."
|
||||
)
|
||||
|
||||
loaded_module = None
|
||||
try:
|
||||
loaded_module: BaseModule = self.module_factory.get_module(module, self.config)
|
||||
loaded_module: BaseModule = lazy_module.load(self.config)
|
||||
except (KeyboardInterrupt, Exception) as e:
|
||||
logger.error(f"Error during setup of modules: {e}\n{traceback.format_exc()}")
|
||||
if loaded_module and module_type == 'extractor':
|
||||
loaded_module.cleanup()
|
||||
if not isinstance(e, KeyboardInterrupt) and not isinstance(e, SetupError):
|
||||
logger.error(f"Error during setup of modules: {e}\n{traceback.format_exc()}")
|
||||
|
||||
# access the _instance here because loaded_module may not return if there's an error
|
||||
if lazy_module._instance and module_type == "extractor":
|
||||
lazy_module._instance.cleanup()
|
||||
raise e
|
||||
|
||||
if not loaded_module:
|
||||
@@ -310,11 +436,13 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
|
||||
def load_config(self, config_file: str) -> dict:
|
||||
if not os.path.exists(config_file) and config_file != DEFAULT_CONFIG_FILE:
|
||||
logger.error(f"The configuration file {config_file} was not found. Make sure the file exists and try again, or run without the --config file to use the default settings.")
|
||||
logger.error(
|
||||
f"The configuration file {config_file} was not found. Make sure the file exists and try again, or run without the --config file to use the default settings."
|
||||
)
|
||||
raise FileNotFoundError(f"Configuration file {config_file} not found")
|
||||
|
||||
return read_yaml(config_file)
|
||||
|
||||
|
||||
def setup_config(self, args: list) -> dict:
|
||||
"""
|
||||
Sets up the configuration file, merging the default config with the user's config
|
||||
@@ -337,49 +465,51 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
yaml_config = self.load_config(basic_config.config_file)
|
||||
|
||||
return self.setup_complete_parser(basic_config, yaml_config, unused_args)
|
||||
|
||||
|
||||
def check_for_updates(self):
|
||||
response = requests.get("https://pypi.org/pypi/auto-archiver/json").json()
|
||||
latest_version = response['info']['version']
|
||||
latest_version = version.parse(response["info"]["version"])
|
||||
current_version = version.parse(__version__)
|
||||
# check version compared to current version
|
||||
if latest_version != __version__:
|
||||
if os.environ.get('RUNNING_IN_DOCKER'):
|
||||
if latest_version > current_version:
|
||||
if os.environ.get("RUNNING_IN_DOCKER"):
|
||||
update_cmd = "`docker pull bellingcat/auto-archiver:latest`"
|
||||
else:
|
||||
update_cmd = "`pip install --upgrade auto-archiver`"
|
||||
logger.warning("")
|
||||
logger.warning("********* IMPORTANT: UPDATE AVAILABLE ********")
|
||||
logger.warning(f"A new version of auto-archiver is available (v{latest_version}, you have {__version__})")
|
||||
logger.warning(f"Make sure to update to the latest version using: {update_cmd}")
|
||||
logger.warning("")
|
||||
logger.warning(
|
||||
f"\n********* IMPORTANT: UPDATE AVAILABLE ********\nA new version of auto-archiver is available (v{latest_version}, you have v{current_version})\nMake sure to update to the latest version using: {update_cmd}\n"
|
||||
)
|
||||
|
||||
|
||||
def setup(self, args: list):
|
||||
"""
|
||||
Function to configure all setup of the orchestrator: setup configs and load modules.
|
||||
|
||||
|
||||
This method should only ever be called once
|
||||
"""
|
||||
|
||||
self.check_for_updates()
|
||||
|
||||
if self.setup_finished:
|
||||
logger.warning("The `setup_config()` function should only ever be run once. \
|
||||
logger.warning(
|
||||
"The `setup_config()` function should only ever be run once. \
|
||||
If you need to re-run the setup, please re-instantiate a new instance of the orchestrator. \
|
||||
For code implementatations, you should call .setup_config() once then you may call .feed() \
|
||||
multiple times to archive multiple URLs.")
|
||||
multiple times to archive multiple URLs."
|
||||
)
|
||||
return
|
||||
|
||||
self.setup_basic_parser()
|
||||
self.config = self.setup_config(args)
|
||||
|
||||
logger.info(f"======== Welcome to the AUTO ARCHIVER ({__version__}) ==========")
|
||||
self.install_modules(self.config['steps'])
|
||||
self.install_modules(self.config["steps"])
|
||||
|
||||
# log out the modules that were loaded
|
||||
for module_type in MODULE_TYPES:
|
||||
logger.info(f"{module_type.upper()}S: " + ", ".join(m.display_name for m in getattr(self, f"{module_type}s")))
|
||||
|
||||
logger.info(
|
||||
f"{module_type.upper()}S: " + ", ".join(m.display_name for m in getattr(self, f"{module_type}s"))
|
||||
)
|
||||
|
||||
self.setup_finished = True
|
||||
|
||||
def _command_line_run(self, args: list) -> Generator[Metadata]:
|
||||
@@ -387,9 +517,9 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
This is the main entry point for the orchestrator, when run from the command line.
|
||||
|
||||
:param args: list of arguments to pass to the orchestrator - these are the command line args
|
||||
|
||||
|
||||
You should not call this method from code implementations.
|
||||
|
||||
|
||||
This method sets up the configuration, loads the modules, and runs the feed.
|
||||
If you wish to make code invocations yourself, you should use the 'setup' and 'feed' methods separately.
|
||||
To test configurations, without loading any modules you can also first call 'setup_configs'
|
||||
@@ -398,7 +528,7 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
self.setup(args)
|
||||
return self.feed()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.error(f"{e}: {traceback.format_exc()}")
|
||||
exit(1)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
@@ -407,14 +537,15 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
e.cleanup()
|
||||
|
||||
def feed(self) -> Generator[Metadata]:
|
||||
|
||||
url_count = 0
|
||||
for feeder in self.feeders:
|
||||
for item in feeder:
|
||||
yield self.feed_item(item)
|
||||
url_count += 1
|
||||
with logger.contextualize(url=item.get_url(), trace=random_str(12)):
|
||||
logger.info("Started processing")
|
||||
yield self.feed_item(item)
|
||||
url_count += 1
|
||||
|
||||
logger.success(f"Processed {url_count} URL(s)")
|
||||
logger.info(f"Processed {url_count} URL(s)")
|
||||
self.cleanup()
|
||||
|
||||
def feed_item(self, item: Metadata) -> Metadata:
|
||||
@@ -432,15 +563,15 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
return self.archive(item)
|
||||
except KeyboardInterrupt:
|
||||
# catches keyboard interruptions to do a clean exit
|
||||
logger.warning(f"caught interrupt on {item=}")
|
||||
logger.warning("Caught interrupt")
|
||||
for d in self.databases:
|
||||
d.aborted(item)
|
||||
self.cleanup()
|
||||
exit()
|
||||
except Exception as e:
|
||||
logger.error(f'Got unexpected error on item {item}: {e}\n{traceback.format_exc()}')
|
||||
logger.error(f"Got unexpected error: {e}\n{traceback.format_exc()}")
|
||||
for d in self.databases:
|
||||
if type(e) == AssertionError:
|
||||
if isinstance(e, AssertionError):
|
||||
d.failed(item, str(e))
|
||||
else:
|
||||
d.failed(item, reason="unexpected error")
|
||||
@@ -453,29 +584,31 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
|
||||
def archive(self, result: Metadata) -> Union[Metadata, None]:
|
||||
"""
|
||||
Runs the archiving process for a single URL
|
||||
1. Each archiver can sanitize its own URLs
|
||||
2. Check for cached results in Databases, and signal start to the databases
|
||||
3. Call Archivers until one succeeds
|
||||
4. Call Enrichers
|
||||
5. Store all downloaded/generated media
|
||||
6. Call selected Formatter and store formatted if needed
|
||||
Runs the archiving process for a single URL
|
||||
1. Each archiver can sanitize its own URLs
|
||||
2. Check for cached results in Databases, and signal start to the databases
|
||||
3. Call Archivers until one succeeds
|
||||
4. Call Enrichers
|
||||
5. Store all downloaded/generated media
|
||||
6. Call selected Formatter and store formatted if needed
|
||||
"""
|
||||
|
||||
original_url = result.get_url().strip()
|
||||
try:
|
||||
check_url_or_raise(original_url)
|
||||
except ValueError as e:
|
||||
logger.error(f"Error archiving URL {original_url}: {e}")
|
||||
logger.error(f"Error archiving: {e}")
|
||||
raise e
|
||||
|
||||
# 1 - sanitize - each archiver is responsible for cleaning/expanding its own URLs
|
||||
url = original_url
|
||||
url = clean(original_url)
|
||||
for a in self.extractors:
|
||||
url = a.sanitize_url(url)
|
||||
|
||||
result.set_url(url)
|
||||
if original_url != url: result.set("original_url", original_url)
|
||||
if original_url != url:
|
||||
logger.debug(f"Sanitized URL to {url}")
|
||||
result.set("original_url", original_url)
|
||||
|
||||
# 2 - notify start to DBs, propagate already archived if feature enabled in DBs
|
||||
cached_result = None
|
||||
@@ -486,25 +619,28 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
if cached_result:
|
||||
logger.debug("Found previously archived entry")
|
||||
for d in self.databases:
|
||||
try: d.done(cached_result, cached=True)
|
||||
try:
|
||||
d.done(cached_result, cached=True)
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR database {d.name}: {e}: {traceback.format_exc()}")
|
||||
logger.error(f"Database {d.name}: {e}: {traceback.format_exc()}")
|
||||
return cached_result
|
||||
|
||||
# 3 - call extractors until one succeeds
|
||||
for a in self.extractors:
|
||||
logger.info(f"Trying extractor {a.name} for {url}")
|
||||
logger.info(f"Trying extractor {a.name}")
|
||||
try:
|
||||
result.merge(a.download(result))
|
||||
if result.is_success(): break
|
||||
if result.is_success():
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR archiver {a.name}: {e}: {traceback.format_exc()}")
|
||||
logger.error(f"Extractor {a.name}: {e}: {traceback.format_exc()}")
|
||||
|
||||
# 4 - call enrichers to work with archived content
|
||||
for e in self.enrichers:
|
||||
try: e.enrich(result)
|
||||
try:
|
||||
e.enrich(result)
|
||||
except Exception as exc:
|
||||
logger.error(f"ERROR enricher {e.name}: {exc}: {traceback.format_exc()}")
|
||||
logger.error(f"Enricher {e.name}: {exc}: {traceback.format_exc()}")
|
||||
|
||||
# 5 - store all downloaded/generated media
|
||||
result.store(storages=self.storages)
|
||||
@@ -520,12 +656,12 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
|
||||
# signal completion to databases and archivers
|
||||
for d in self.databases:
|
||||
try: d.done(result)
|
||||
try:
|
||||
d.done(result)
|
||||
except Exception as e:
|
||||
logger.error(f"ERROR database {d.name}: {e}: {traceback.format_exc()}")
|
||||
logger.error(f"Database {d.name}: {e}: {traceback.format_exc()}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def setup_authentication(self, config: dict) -> dict:
|
||||
"""
|
||||
@@ -534,7 +670,7 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
Split up strings into multiple sites if they are comma separated
|
||||
"""
|
||||
|
||||
authentication = config.get('authentication', {})
|
||||
authentication = config.get("authentication", {})
|
||||
|
||||
# extract out concatenated sites
|
||||
for key, val in copy(authentication).items():
|
||||
@@ -543,8 +679,8 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
|
||||
site = site.strip()
|
||||
authentication[site] = val
|
||||
del authentication[key]
|
||||
|
||||
config['authentication'] = authentication
|
||||
|
||||
config["authentication"] = authentication
|
||||
return config
|
||||
|
||||
# Helper Properties
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
"""
|
||||
Base module for Storage modules – modular components that store media objects in various locations.
|
||||
|
||||
If you are looking to implement a new storage module, you should subclass the `Storage` class and
|
||||
implement the `get_cdn_url` and `uploadf` methods.
|
||||
|
||||
Your module **must** also have two config variables 'path_generator' and 'filename_generator' which
|
||||
determine how the key is generated for the media object. The 'path_generator' and 'filename_generator'
|
||||
variables can be set to one of the following values:
|
||||
- 'flat': A flat structure with no subfolders
|
||||
- 'url': A structure based on the URL of the media object
|
||||
- 'random': A random structure
|
||||
|
||||
The 'filename_generator' variable can be set to one of the following values:
|
||||
- 'random': A random string
|
||||
- 'static': A replicable strategy such as a hash
|
||||
|
||||
If you don't want to use this naming convention, you can override the `set_key` method in your subclass.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -7,7 +24,7 @@ from abc import abstractmethod
|
||||
from typing import IO
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from slugify import slugify
|
||||
|
||||
from auto_archiver.utils.misc import random_str
|
||||
@@ -15,18 +32,19 @@ from auto_archiver.utils.misc import random_str
|
||||
from auto_archiver.core import Media, BaseModule, Metadata
|
||||
from auto_archiver.modules.hash_enricher.hash_enricher import HashEnricher
|
||||
|
||||
|
||||
class Storage(BaseModule):
|
||||
|
||||
"""
|
||||
Base class for implementing storage modules in the media archiving framework.
|
||||
|
||||
Subclasses must implement the `get_cdn_url` and `uploadf` methods to define their behavior.
|
||||
"""
|
||||
|
||||
def store(self, media: Media, url: str, metadata: Metadata=None) -> None:
|
||||
if media.is_stored(in_storage=self):
|
||||
def store(self, media: Media, url: str, metadata: Metadata = None) -> None:
|
||||
if media.is_stored(in_storage=self):
|
||||
logger.debug(f"{media.key} already stored, skipping")
|
||||
return
|
||||
|
||||
self.set_key(media, url, metadata)
|
||||
self.upload(media, metadata=metadata)
|
||||
media.add_url(self.get_cdn_url(media))
|
||||
@@ -42,42 +60,55 @@ class Storage(BaseModule):
|
||||
def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool:
|
||||
"""
|
||||
Uploads (or saves) a file to the storage service/location.
|
||||
|
||||
This method should not be called directly, but instead through the 'store' method,
|
||||
which sets up the media for storage.
|
||||
"""
|
||||
pass
|
||||
|
||||
def upload(self, media: Media, **kwargs) -> bool:
|
||||
logger.debug(f'[{self.__class__.__name__}] storing file {media.filename} with key {media.key}')
|
||||
with open(media.filename, 'rb') as f:
|
||||
"""
|
||||
Uploads a media object to the storage service.
|
||||
|
||||
This method should not be called directly, but instead be called through the 'store' method,
|
||||
which sets up the media for storage.
|
||||
"""
|
||||
logger.debug(f"[{self.__class__.__name__}] storing file {media.filename} with key {media.key}")
|
||||
with open(media.filename, "rb") as f:
|
||||
return self.uploadf(f, media, **kwargs)
|
||||
|
||||
def set_key(self, media: Media, url, metadata: Metadata) -> None:
|
||||
def set_key(self, media: Media, url: str, metadata: Metadata) -> None:
|
||||
"""takes the media and optionally item info and generates a key"""
|
||||
if media.key is not None and len(media.key) > 0: return
|
||||
folder = metadata.get_context('folder', '')
|
||||
|
||||
if media.key is not None and len(media.key) > 0:
|
||||
# media key is already set
|
||||
return
|
||||
|
||||
folder = metadata.get_context("folder", "")
|
||||
filename, ext = os.path.splitext(media.filename)
|
||||
|
||||
# Handle path_generator logic
|
||||
path_generator = self.config.get("path_generator", "url")
|
||||
path_generator = self.path_generator
|
||||
if path_generator == "flat":
|
||||
path = ""
|
||||
filename = slugify(filename) # Ensure filename is slugified
|
||||
elif path_generator == "url":
|
||||
path = slugify(url)
|
||||
path = slugify(url)[:70]
|
||||
elif path_generator == "random":
|
||||
path = self.config.get("random_path", random_str(24), True)
|
||||
path = random_str(24)
|
||||
else:
|
||||
raise ValueError(f"Invalid path_generator: {path_generator}")
|
||||
|
||||
# Handle filename_generator logic
|
||||
filename_generator = self.config.get("filename_generator", "random")
|
||||
filename_generator = self.filename_generator
|
||||
if filename_generator == "random":
|
||||
filename = random_str(24)
|
||||
elif filename_generator == "static":
|
||||
# load the hash_enricher module
|
||||
he = self.module_factory.get_module(HashEnricher, self.config)
|
||||
he: HashEnricher = self.module_factory.get_module("hash_enricher", self.config)
|
||||
hd = he.calculate_hash(media.filename)
|
||||
filename = hd[:24]
|
||||
else:
|
||||
raise ValueError(f"Invalid filename_generator: {filename_generator}")
|
||||
|
||||
media.key = os.path.join(folder, path, f"{filename}{ext}")
|
||||
key = os.path.join(folder, path, f"{filename}{ext}")
|
||||
media._key = key
|
||||
|
||||
@@ -3,10 +3,6 @@ from pathlib import Path
|
||||
import argparse
|
||||
import json
|
||||
|
||||
def example_validator(value):
|
||||
if "example" not in value:
|
||||
raise argparse.ArgumentTypeError(f"{value} is not a valid value for this argument")
|
||||
return value
|
||||
|
||||
def positive_number(value):
|
||||
if value < 0:
|
||||
@@ -19,5 +15,6 @@ def valid_file(value):
|
||||
raise argparse.ArgumentTypeError(f"File '{value}' does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
def json_loader(cli_val):
|
||||
return json.loads(cli_val)
|
||||
return json.loads(cli_val)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "Antibot Extractor/Enricher",
|
||||
"type": ["extractor", "enricher"],
|
||||
"requires_setup": False,
|
||||
"dependencies": {"python": ["loguru", "seleniumbase", "yt_dlp"], "bin": ["ffmpeg"]},
|
||||
"configs": {
|
||||
"save_to_pdf": {
|
||||
"default": False,
|
||||
"type": "bool",
|
||||
"help": "save a PDF snapshot of the page.",
|
||||
},
|
||||
"max_download_images": {
|
||||
"default": 50,
|
||||
"help": "maximum number of images to download from the page (0 = no download, inf = no limit).",
|
||||
},
|
||||
"max_download_videos": {
|
||||
"default": 50,
|
||||
"help": "maximum number of videos to download from the page (0 = no download, inf = no limit).",
|
||||
},
|
||||
"user_data_dir": {
|
||||
"default": "secrets/antibot_user_data",
|
||||
"help": "Path to the user data directory for the webdriver. This is used to persist browser state, such as cookies and local storage. If you use the docker deployment, this path will be appended with `_docker` that is because the folder cannot be shared between the host and the container due to user permissions.",
|
||||
},
|
||||
"detect_auth_wall": {
|
||||
"default": True,
|
||||
"type": "bool",
|
||||
"help": "detect if the page is behind an authentication wall (e.g. login required) and skip it. disable if you want to archive pages where logins are required.",
|
||||
},
|
||||
"proxy": {
|
||||
"default": None,
|
||||
"help": "proxy to use for the webdriver, Format: 'SERVER:PORT' or 'USER:PASS@SERVER:PORT'",
|
||||
},
|
||||
},
|
||||
"autodoc_dropins": True,
|
||||
"description": """
|
||||
Uses a browser controlled by SeleniumBase to capture HTML, media, and screenshots/PDFs of a web page, by bypassing anti-bot measures like Cloudflare's Turnstile or Google Recaptcha.
|
||||
|
||||
> ⚠️ Still in trial development, please report any issues or suggestions via [GitHub Issues](https://github.com/bellingcat/auto-archiver/issues).
|
||||
|
||||
### Features
|
||||
- Extracts the HTML source code of the page.
|
||||
- Takes full-page screenshots of web pages.
|
||||
- Takes full-page PDF snapshots of web pages.
|
||||
- Downloads images and videos from the page, excluding specified file extensions.
|
||||
|
||||
### Notes
|
||||
- Using a proxy affects Cloudflare Turnstile captcha handling, so it is recommended to use a proxy only if necessary.
|
||||
|
||||
### Dropins
|
||||
This module uses sub-modules called Dropins for specific sites that allow it to handle anti-bot measures and custom Login flows. You don't need to include the dropins in your configuration, but you do need to add authentication credentials if you want to overcome login walls on those sites, see detailed instructions for each Dropin below.
|
||||
|
||||
""",
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import base64
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from urllib.parse import urljoin
|
||||
import glob
|
||||
import importlib.util
|
||||
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
import selenium
|
||||
from seleniumbase import SB
|
||||
|
||||
from auto_archiver.core import Extractor, Enricher, Metadata, Media
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropins.default import DefaultDropin
|
||||
from auto_archiver.utils.misc import random_str
|
||||
from auto_archiver.utils.url import is_relevant_url
|
||||
from auto_archiver.utils.deletion_detection import detect_deletion, flag_as_deleted
|
||||
|
||||
|
||||
class AntibotExtractorEnricher(Extractor, Enricher):
|
||||
def setup(self) -> None:
|
||||
self.agent = "cool"
|
||||
if "linux" in sys.platform or "win32" in sys.platform:
|
||||
self.agent = None # Use the default UserAgent
|
||||
|
||||
# parse configuration options
|
||||
if self.max_download_images == "inf":
|
||||
self.max_download_images = math.inf
|
||||
else:
|
||||
self.max_download_images = int(self.max_download_images)
|
||||
|
||||
if self.max_download_videos == "inf":
|
||||
self.max_download_videos = math.inf
|
||||
else:
|
||||
self.max_download_videos = int(self.max_download_videos)
|
||||
|
||||
self._prepare_user_data_dir()
|
||||
|
||||
self.dropins = self.load_dropins()
|
||||
|
||||
def load_dropins(self):
|
||||
dropins = []
|
||||
|
||||
# TODO: add user-configurable drop-ins via config like generic_extractor
|
||||
dropins_dir = os.path.join(os.path.dirname(__file__), "dropins")
|
||||
for file_path in glob.glob(os.path.join(dropins_dir, "*.py")):
|
||||
if os.path.basename(file_path).startswith("_"):
|
||||
continue # skip __init__.py or private modules
|
||||
module_name = f"auto_archiver.modules.antibot_extractor_enricher.dropins.{os.path.splitext(os.path.basename(file_path))[0]}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
for attr in dir(module):
|
||||
obj = getattr(module, attr)
|
||||
if getattr(obj, "__module__", None) != module.__name__:
|
||||
continue # Skip imported modules/classes/functions
|
||||
if isinstance(obj, type) and issubclass(obj, Dropin):
|
||||
dropins.append(obj)
|
||||
logger.debug(f"Loaded drop-in classes: {', '.join([d.__name__ for d in dropins])}")
|
||||
return dropins
|
||||
|
||||
def sanitize_url(self, url: str) -> str:
|
||||
for dropin in self.dropins:
|
||||
if dropin.suitable(url):
|
||||
return dropin.sanitize_url(url)
|
||||
return url
|
||||
|
||||
def download(self, item: Metadata) -> Metadata:
|
||||
result = Metadata()
|
||||
result.merge(item)
|
||||
if self.enrich(result):
|
||||
result.status = "antibot"
|
||||
return result
|
||||
return False
|
||||
|
||||
def _prepare_user_data_dir(self):
|
||||
if self.user_data_dir:
|
||||
in_docker = os.environ.get("RUNNING_IN_DOCKER")
|
||||
if in_docker:
|
||||
self.user_data_dir = self.user_data_dir.rstrip(os.path.sep) + "_docker"
|
||||
os.makedirs(self.user_data_dir, exist_ok=True)
|
||||
|
||||
def enrich(self, to_enrich: Metadata, custom_data_dir: bool = True) -> bool:
|
||||
if to_enrich.get_media_by_id("html_source_code"):
|
||||
logger.info("Antibot has already been executed, skipping.")
|
||||
return True
|
||||
using_user_data_dir = self.user_data_dir if custom_data_dir else None
|
||||
url = to_enrich.get_url()
|
||||
|
||||
# Use xvfb in Docker environments where no display is available
|
||||
use_xvfb = bool(os.environ.get("RUNNING_IN_DOCKER"))
|
||||
|
||||
try:
|
||||
with SB(
|
||||
uc=True,
|
||||
agent=self.agent,
|
||||
headed=None,
|
||||
user_data_dir=using_user_data_dir,
|
||||
proxy=self.proxy,
|
||||
xvfb=use_xvfb,
|
||||
) as sb:
|
||||
logger.info(f"Selenium browser is up with agent {self.agent}, opening url...")
|
||||
sb.uc_open_with_reconnect(url, 4)
|
||||
|
||||
logger.debug("Handling CAPTCHAs for...")
|
||||
sb.uc_gui_handle_cf()
|
||||
sb.uc_gui_click_rc() # NB: using handle instead of click breaks some sites like reddit, for now we separate here but can have dropins deciding this in the future
|
||||
|
||||
dropin = self._get_suitable_dropin(url, sb)
|
||||
if not dropin.open_page(url):
|
||||
# Check for deletion indicators
|
||||
page_title = sb.get_title()
|
||||
html_source = sb.get_page_source()
|
||||
deletion_info = detect_deletion(html_content=html_source, page_title=page_title, url=url)
|
||||
if deletion_info:
|
||||
flag_as_deleted(to_enrich, deletion_info)
|
||||
return to_enrich
|
||||
logger.warning("Failed to open drop-in page (not detected as deleted)")
|
||||
return False
|
||||
|
||||
if self.detect_auth_wall and (dropin.hit_auth_wall() and self._hit_auth_wall(sb)):
|
||||
logger.warning("Skipping since auth wall or CAPTCHA was detected")
|
||||
return False
|
||||
|
||||
sb.wait_for_ready_state_complete()
|
||||
sb.sleep(1) # margin for the page to load completely
|
||||
|
||||
page_title = sb.get_title()
|
||||
html_source = sb.get_page_source()
|
||||
|
||||
# Check if the page indicates content was deleted
|
||||
deletion_info = detect_deletion(html_content=html_source, page_title=page_title, url=url)
|
||||
if deletion_info:
|
||||
flag_as_deleted(to_enrich, deletion_info)
|
||||
|
||||
to_enrich.set_title(page_title)
|
||||
self._enrich_html_source_code(sb, to_enrich)
|
||||
|
||||
self._enrich_full_page_screenshot(sb, to_enrich)
|
||||
if self.save_to_pdf:
|
||||
self._enrich_full_page_pdf(sb, to_enrich)
|
||||
|
||||
downloaded_images, downloaded_videos = dropin.add_extra_media(to_enrich)
|
||||
|
||||
self._enrich_download_media(
|
||||
sb,
|
||||
to_enrich,
|
||||
js_css_selector=dropin.js_for_image_css_selectors(),
|
||||
max_media=self.max_download_images - downloaded_images,
|
||||
)
|
||||
self._enrich_download_media(
|
||||
sb,
|
||||
to_enrich,
|
||||
js_css_selector=dropin.js_for_video_css_selectors(),
|
||||
max_media=self.max_download_videos - downloaded_videos,
|
||||
)
|
||||
logger.info("Completed")
|
||||
|
||||
return to_enrich
|
||||
except selenium.common.exceptions.SessionNotCreatedException as e:
|
||||
if custom_data_dir: # the retry logic only works once
|
||||
logger.error(
|
||||
f"Session not created error: {e}. Please remove the user_data_dir {self.user_data_dir} and try again, will retry without user data dir though."
|
||||
)
|
||||
return self.enrich(to_enrich, custom_data_dir=False)
|
||||
raise e # re-raise
|
||||
except Exception as e:
|
||||
logger.error(f"Runtime error: {e}: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def _get_suitable_dropin(self, url: str, sb: SB):
|
||||
"""
|
||||
Returns a suitable drop-in for the given URL.
|
||||
This method checks if the URL is suitable for any of the registered drop-ins.
|
||||
"""
|
||||
for dropin in self.dropins:
|
||||
if dropin.suitable(url):
|
||||
logger.debug(f"Using drop-in {dropin.__name__}")
|
||||
return dropin(sb, self)
|
||||
|
||||
return DefaultDropin(sb, self)
|
||||
|
||||
def _hit_auth_wall(self, sb: SB) -> bool:
|
||||
"""
|
||||
Tries to detect if the currently loaded page is an auth/login wall.
|
||||
Returns True if login is likely required.
|
||||
"""
|
||||
# TODO: improve this detection logic, currently it is very basic and may not cover all cases
|
||||
|
||||
# Common URL patterns
|
||||
current_url = sb.get_current_url().lower()
|
||||
if any(kw in current_url for kw in ["login", "signin", "signup", "register", "captcha"]):
|
||||
return True
|
||||
|
||||
# Common visible text markers
|
||||
login_keywords = [
|
||||
"sign up or log in",
|
||||
"log in to continue",
|
||||
"sign in to continue",
|
||||
"login required",
|
||||
"please log in",
|
||||
"please sign up",
|
||||
"please sign in",
|
||||
"login to access",
|
||||
"sign up to access",
|
||||
"register to access",
|
||||
"captcha verification",
|
||||
]
|
||||
for word in login_keywords + [w.capitalize() for w in login_keywords]:
|
||||
if sb.is_text_visible(word):
|
||||
return True
|
||||
|
||||
# Common title markers
|
||||
title = sb.get_title().lower()
|
||||
if any(
|
||||
kw in title
|
||||
for kw in [
|
||||
"just a moment...",
|
||||
"tiktok - make your day",
|
||||
"um momento...",
|
||||
"log in",
|
||||
"sign in",
|
||||
"sign up",
|
||||
"register",
|
||||
"captcha",
|
||||
"verification required",
|
||||
"access denied",
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
# Common form fields
|
||||
elements = [
|
||||
"input[type='password']",
|
||||
"input[type='email']",
|
||||
"input[type='username']",
|
||||
"input[type='phone']",
|
||||
"input[name='username']",
|
||||
"input[name='email']",
|
||||
"input[name='password']",
|
||||
"input[name='login']",
|
||||
]
|
||||
if any(sb.is_element_visible(el) for el in elements):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@logger.catch
|
||||
def _enrich_html_source_code(self, sb: SB, to_enrich: Metadata):
|
||||
"""
|
||||
Enriches the HTML source code of the Metadata object.
|
||||
This method is called by the enrich method.
|
||||
"""
|
||||
source = sb.get_page_source()
|
||||
|
||||
html_filename = os.path.join(self.tmp_dir, f"source{random_str(6)}.html")
|
||||
with open(html_filename, "w", encoding="utf-8") as f:
|
||||
f.write(source)
|
||||
|
||||
to_enrich.add_media(Media(filename=html_filename), id="html_source_code")
|
||||
|
||||
@logger.catch
|
||||
def _enrich_full_page_screenshot(self, sb: SB, to_enrich: Metadata):
|
||||
"""
|
||||
Enriches the full page screenshot of the Metadata object.
|
||||
This method is called by the enrich method.
|
||||
"""
|
||||
start_size = sb.get_window_size()
|
||||
w, h = start_size["width"], start_size["height"]
|
||||
|
||||
x = max(sb.execute_script("return document.documentElement.scrollWidth"), w)
|
||||
y = min(max(sb.execute_script("return document.documentElement.scrollHeight"), h), 25_000)
|
||||
logger.debug(f"Setting window size to {x}x{y} for full page screenshot.")
|
||||
sb.set_window_size(x, y)
|
||||
|
||||
screen_filename = os.path.join(self.tmp_dir, f"screenshot{random_str(6)}.png")
|
||||
sb.save_screenshot(screen_filename)
|
||||
|
||||
to_enrich.add_media(Media(filename=screen_filename), id="screenshot")
|
||||
|
||||
@logger.catch
|
||||
def _enrich_full_page_pdf(self, sb: SB, to_enrich: Metadata):
|
||||
"""
|
||||
Enriches the full page PDF of the Metadata object.
|
||||
This method is called by the enrich method.
|
||||
"""
|
||||
result = sb.driver.execute_cdp_cmd("Page.printToPDF", {"printBackground": True, "landscape": False})
|
||||
|
||||
pdf_data = base64.b64decode(result["data"])
|
||||
|
||||
pdf_filename = os.path.join(self.tmp_dir, f"pdf{random_str(6)}.pdf")
|
||||
with open(pdf_filename, "wb") as f:
|
||||
f.write(pdf_data)
|
||||
|
||||
to_enrich.add_media(Media(filename=pdf_filename), id="pdf")
|
||||
|
||||
@logger.catch
|
||||
def _enrich_download_media(self, sb: SB, to_enrich: Metadata, js_css_selector: str, max_media: int):
|
||||
"""
|
||||
Downloads media from the page and adds them to the Metadata object.
|
||||
This method is called by the enrich method.
|
||||
"""
|
||||
if max_media == 0:
|
||||
return
|
||||
url = to_enrich.get_url()
|
||||
all_urls = set()
|
||||
logger.debug(f"Extracting media for {js_css_selector=}")
|
||||
|
||||
try:
|
||||
sources = sb.execute_script(js_css_selector)
|
||||
except selenium.common.exceptions.JavascriptException as e:
|
||||
logger.error(f"Error executing JavaScript selector {js_css_selector}: {e}")
|
||||
return
|
||||
|
||||
# js_for_css_selectors
|
||||
for src in sources:
|
||||
if len(all_urls) >= max_media:
|
||||
logger.debug(f"Reached max download limit of {max_media} images/videos.")
|
||||
break
|
||||
if not is_relevant_url(src):
|
||||
continue
|
||||
full_src = urljoin(url, src)
|
||||
if full_src not in all_urls:
|
||||
filename, full_src = self.download_from_url(full_src, try_best_quality=True)
|
||||
if not filename:
|
||||
continue
|
||||
all_urls.add(full_src)
|
||||
to_enrich.add_media(Media(filename=filename, properties={"url": full_src}))
|
||||
1
src/auto_archiver/modules/antibot_extractor_enricher/captcha_services/.gitignore
vendored
Normal file
1
src/auto_archiver/modules/antibot_extractor_enricher/captcha_services/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.py
|
||||
173
src/auto_archiver/modules/antibot_extractor_enricher/dropin.py
Normal file
173
src/auto_archiver/modules/antibot_extractor_enricher/dropin.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
from typing import Mapping
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from seleniumbase import SB
|
||||
import yt_dlp
|
||||
|
||||
from auto_archiver.core import Extractor, Media, Metadata
|
||||
from auto_archiver.utils.misc import ydl_entry_to_filename
|
||||
|
||||
|
||||
class Dropin:
|
||||
"""
|
||||
A class to handle drop-in functionality for the antibot extractor enricher module.
|
||||
This class is designed to be a base class for drop-ins that can handle specific websites.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def documentation() -> Mapping[str, str]:
|
||||
"""
|
||||
Each Dropin should auto-document itself with this method.
|
||||
Return dictionary can include:
|
||||
- 'name': A string representing the name of the dropin.
|
||||
- 'description': A string describing the functionality of the dropin.
|
||||
- 'site': A string representing the site this dropin is for.
|
||||
- 'authentication': A dictionary with authentication example for the site.
|
||||
|
||||
"""
|
||||
return {}
|
||||
|
||||
def __init__(self, sb: SB, extractor: Extractor):
|
||||
"""
|
||||
Initialize the Dropin with the given SeleniumBase instance.
|
||||
|
||||
:param sb: An instance of the SeleniumBase class that this drop-in will use.
|
||||
:param extractor: An instance of the Extractor class that this drop-in will use.
|
||||
"""
|
||||
self.sb: SB = sb
|
||||
self.extractor: Extractor = extractor
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
"""
|
||||
Check if the URL is suitable for processing with this dropin.
|
||||
:param url: The URL to check.
|
||||
:return: True if the URL is suitable for processing, False otherwise.
|
||||
"""
|
||||
raise NotImplementedError("This method should be implemented in the subclass")
|
||||
|
||||
@staticmethod
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""
|
||||
Used to clean URLs before processing them.
|
||||
"""
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def images_selectors() -> str:
|
||||
"""
|
||||
CSS selector to find images in the HTML page
|
||||
"""
|
||||
return "img"
|
||||
|
||||
@staticmethod
|
||||
def video_selectors() -> str:
|
||||
"""
|
||||
CSS selector to find videos in the HTML page.
|
||||
"""
|
||||
return "video, source"
|
||||
|
||||
def js_for_image_css_selectors(self) -> str:
|
||||
"""
|
||||
A configurable JS script that receives a css selector from the dropin itself and returns an array of Image elements according to the selection.
|
||||
|
||||
You can overwrite this instead of `images_selector` for more control over scraped images.
|
||||
"""
|
||||
if not self.images_selectors():
|
||||
return "return [];"
|
||||
safe_selector = json.dumps(self.images_selectors())
|
||||
return f"""
|
||||
return Array.from(document.querySelectorAll({safe_selector})).map(el => el.src || el.href).filter(Boolean);
|
||||
"""
|
||||
|
||||
def js_for_video_css_selectors(self) -> str:
|
||||
"""
|
||||
A configurable JS script that receives a css selector from the dropin itself and returns an array of Video elements according to the selection.
|
||||
|
||||
You can overwrite this instead of `video_selector` for more control over scraped videos.
|
||||
"""
|
||||
if not self.video_selectors():
|
||||
return "return [];"
|
||||
safe_selector = json.dumps(self.video_selectors())
|
||||
return f"""
|
||||
return Array.from(document.querySelectorAll({safe_selector})).map(el => el.src || el.href).filter(Boolean);
|
||||
"""
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
"""
|
||||
Make sure the page is opened, even if it requires authentication, captcha solving, etc.
|
||||
:param url: The URL to open.
|
||||
:return: True if success, False otherwise.
|
||||
"""
|
||||
raise NotImplementedError("This method should be implemented in the subclass")
|
||||
|
||||
def add_extra_media(self, to_enrich: Metadata) -> tuple[int, int]:
|
||||
"""
|
||||
Extract image and/or video data from the currently open post with SeleniumBase. Media is added to the `to_enrich` Metadata object.
|
||||
:return: A tuple (number of Images added, number of Videos added).
|
||||
"""
|
||||
return 0, 0
|
||||
|
||||
def hit_auth_wall(self) -> bool:
|
||||
"""
|
||||
Custom check to see if the current page is behind an authentication wall, if True is returned the default global auth wall detector is used instead. If false, no auth wall is detected and the page is considered open.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _get_username_password(self, site) -> tuple[str, str]:
|
||||
"""
|
||||
Get the username and password for the site from the extractor's auth data.
|
||||
:return: A tuple (username, password).
|
||||
"""
|
||||
auth = self.extractor.auth_for_site(site)
|
||||
username = auth.get("username", "")
|
||||
password = auth.get("password", "")
|
||||
if not username or not password:
|
||||
raise ValueError(f"{site} authentication requires a username and password.")
|
||||
return username, password
|
||||
|
||||
def _download_videos_with_ytdlp(self, video_urls: list[str], to_enrich: Metadata) -> int:
|
||||
"""
|
||||
Download videos using yt-dlp.
|
||||
:param video_urls: List of video URLs to download.
|
||||
:return: The number of videos downloaded.
|
||||
"""
|
||||
if type(self.extractor.max_download_videos) is int:
|
||||
video_urls = video_urls[: self.extractor.max_download_videos]
|
||||
|
||||
if not video_urls:
|
||||
return 0
|
||||
|
||||
ydl_options = [
|
||||
"-o",
|
||||
os.path.join(self.extractor.tmp_dir, "%(id)s.%(ext)s"),
|
||||
"--quiet",
|
||||
"--no-playlist",
|
||||
"--no-write-subs",
|
||||
"--no-write-auto-subs",
|
||||
"--postprocessor-args",
|
||||
"ffmpeg:-bitexact",
|
||||
"--max-filesize",
|
||||
"1000M", # Limit to 1GB per video
|
||||
]
|
||||
*_, validated_options = yt_dlp.parse_options(ydl_options)
|
||||
downloaded = 0
|
||||
with yt_dlp.YoutubeDL(validated_options) as ydl:
|
||||
for url in video_urls:
|
||||
try:
|
||||
logger.debug(f"Downloading video from url: {url}")
|
||||
info = ydl.extract_info(url, download=True)
|
||||
filename = ydl_entry_to_filename(ydl, info)
|
||||
if not filename: # Failed to download video.
|
||||
continue
|
||||
media = Media(filename)
|
||||
for x in ["duration", "original_url", "fulltitle", "description", "upload_date"]:
|
||||
if x in info:
|
||||
media.set(x, info[x])
|
||||
to_enrich.add_media(media)
|
||||
downloaded += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed: {e} {traceback.format_exc()}")
|
||||
return downloaded
|
||||
@@ -0,0 +1,14 @@
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
|
||||
|
||||
class DefaultDropin(Dropin):
|
||||
"""
|
||||
A default fallback drop-in class for handling generic cases in the antibot extractor enricher module.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
return False
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Mapping
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
|
||||
|
||||
class LinkedinDropin(Dropin):
|
||||
"""
|
||||
A class to handle LinkedIn drop-in functionality for the antibot extractor enricher module.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def documentation() -> Mapping[str, str]:
|
||||
return {
|
||||
"name": "Linkedin Dropin",
|
||||
"description": "Handles LinkedIn pages/posts and requires authentication to access most content but will still be useful without it. The first time you login to a new IP, LinkedIn may require an email verification code, you can do a manual login first and then it won't ask for it again.",
|
||||
"site": "linkedin.com",
|
||||
"authentication": {
|
||||
"linkedin.com": {
|
||||
"username": "email address or phone number",
|
||||
"password": "password",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
notifications_css_selector = 'a[href*="linkedin.com/notifications"]'
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
return "linkedin.com" in url
|
||||
|
||||
def js_for_image_css_selectors(self) -> str:
|
||||
get_all_css = "main img:not([src*='profile-displayphoto']):not([src*='profile-framedphoto'])"
|
||||
get_first_css = (
|
||||
"main img[src*='profile-framedphoto'], main img[src*='profile-displayphoto'], main img[src*='company-logo']"
|
||||
)
|
||||
|
||||
return f"""
|
||||
const all = Array.from(document.querySelectorAll("{get_all_css}")).map(el => el.src || el.href).filter(Boolean);
|
||||
const profile = document.querySelector("{get_first_css}");
|
||||
return all.concat(profile?.src || profile?.href || []).filter(Boolean);
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def video_selectors() -> str:
|
||||
# usually videos are from blob: but running the generic extractor should handle that
|
||||
return "main video"
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
if not self.sb.is_element_present(self.notifications_css_selector):
|
||||
self._login()
|
||||
if url != self.sb.get_current_url():
|
||||
self.sb.open(url)
|
||||
return True
|
||||
|
||||
@logger.catch
|
||||
def _login(self) -> bool:
|
||||
if self.sb.is_text_visible("Sign in to view more content"):
|
||||
self.sb.click_link_text("Sign in", timeout=2)
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
else:
|
||||
self.sb.open("https://www.linkedin.com/login")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
|
||||
username, password = self._get_username_password("linkedin.com")
|
||||
logger.debug("Logging in to Linkedin with username: {}", username)
|
||||
self.sb.type("#username", username)
|
||||
self.sb.type("#password", password)
|
||||
self.sb.click_if_visible("#password-visibility-toggle", timeout=0.5)
|
||||
self.sb.click("button[type='submit']")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
# TODO: on suspicious login, LinkedIn may require an email verification code
|
||||
|
||||
if not self.sb.is_element_present(self.notifications_css_selector):
|
||||
self.sb.click_if_visible('button[aria-label="Dismiss"]', timeout=0.5)
|
||||
@@ -0,0 +1,92 @@
|
||||
from contextlib import suppress
|
||||
from typing import Mapping
|
||||
from auto_archiver.core.metadata import Metadata
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
|
||||
class RedditDropin(Dropin):
|
||||
"""
|
||||
A class to handle Reddit drop-in functionality for the antibot extractor enricher module.
|
||||
"""
|
||||
|
||||
def documentation() -> Mapping[str, str]:
|
||||
return {
|
||||
"name": "Reddit Dropin",
|
||||
"description": "Handles Reddit posts and works without authentication until Reddit flags your IP, so authentication is advised.",
|
||||
"site": "reddit.com",
|
||||
"authentication": {
|
||||
"reddit.com": {
|
||||
"username": "email address or username",
|
||||
"password": "password",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
return "reddit.com" in url
|
||||
|
||||
@staticmethod
|
||||
def images_selectors() -> str:
|
||||
return "shreddit-post img"
|
||||
|
||||
@staticmethod
|
||||
def video_selectors() -> str:
|
||||
return "shreddit-post video, shreddit-post source"
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
if self.sb.is_text_visible("You've been blocked by network security."):
|
||||
self._login()
|
||||
if url != self.sb.get_current_url():
|
||||
self.sb.open(url)
|
||||
return True
|
||||
|
||||
@logger.catch
|
||||
def _login(self):
|
||||
self.sb.click_link_text("Log in")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
self._close_cookies_banner()
|
||||
|
||||
username, password = self._get_username_password("reddit.com")
|
||||
logger.debug("Logging in to Reddit with username: {}", username)
|
||||
|
||||
self.sb.type("#login-username", username)
|
||||
self.sb.type("#login-password", password)
|
||||
|
||||
elem = self.sb.find_element("button.login")
|
||||
self.sb.execute_script("arguments[0].scrollIntoView(true);", elem)
|
||||
self.sb.slow_click("button.login")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
|
||||
if "https://www.reddit.com/login/" in self.sb.get_current_url():
|
||||
self.sb.sleep(5)
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
|
||||
if self.sb.is_text_visible("You've been blocked by network security."):
|
||||
self.sb.click_link_text("Log in")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
if self.sb.is_text_visible("Welcome back"):
|
||||
logger.debug("Login successful")
|
||||
self.sb.click_if_visible("this link")
|
||||
|
||||
def _close_cookies_banner(self):
|
||||
with suppress(Exception): # selenium.common.exceptions.JavascriptException
|
||||
self.sb.execute_script("""
|
||||
document
|
||||
.querySelector("reddit-cookie-banner")
|
||||
.shadowRoot.querySelector("faceplate-dialog")
|
||||
.querySelector("#accept-all-cookies-button button")
|
||||
.click()
|
||||
""")
|
||||
|
||||
@logger.catch
|
||||
def add_extra_media(self, to_enrich: Metadata) -> tuple[int, int]:
|
||||
filtered_urls = self.sb.execute_script(rf"""
|
||||
return [...document.querySelectorAll("{self.video_selectors()}")]
|
||||
.map(el => el.src || el.href)
|
||||
.filter(url => url && /\.(m3u8|mpd|ism)$/.test(url));
|
||||
""")
|
||||
logger.debug("Found {} video URLs", len(filtered_urls))
|
||||
return 0, self._download_videos_with_ytdlp(filtered_urls, to_enrich)
|
||||
@@ -0,0 +1,56 @@
|
||||
from contextlib import suppress
|
||||
from typing import Mapping
|
||||
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
|
||||
|
||||
class TikTokDropin(Dropin):
|
||||
"""
|
||||
A class to handle TikTok drop-in functionality for the antibot extractor enricher module.
|
||||
|
||||
"""
|
||||
|
||||
def documentation() -> Mapping[str, str]:
|
||||
return {
|
||||
"name": "TikTok Dropin",
|
||||
"description": "Handles TikTok posts and works without authentication.\nNOTE: This dropin is highly susceptible to TikTok's bot detection mechanisms and may not work reliably if you reuse the same IP. The GenericExtractor is recommended for TikTok posts, as it handles video/image download more reliable. In the future we plan to implement better anti captcha measures for this dropin.",
|
||||
"site": "tiktok.com",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
return "tiktok.com" in url
|
||||
|
||||
@staticmethod
|
||||
def images_selectors() -> str:
|
||||
return '[data-e2e="detail-photo"] img'
|
||||
|
||||
@staticmethod
|
||||
def video_selectors() -> str:
|
||||
return None # TikTok videos should be handled by the generic extractor
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
self._close_cookies_banner()
|
||||
# TODO: implement login logic
|
||||
if url != self.sb.get_current_url():
|
||||
return False
|
||||
if self.sb.is_text_visible("Video currently unavailable"):
|
||||
logger.debug("Video may have been removed or is private.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def hit_auth_wall(self) -> bool:
|
||||
return False # TikTok does not require authentication for public posts
|
||||
|
||||
def _close_cookies_banner(self):
|
||||
with suppress(Exception): # selenium.common.exceptions.JavascriptException
|
||||
self.sb.execute_script("""
|
||||
document
|
||||
.querySelector("tiktok-cookie-banner")
|
||||
.shadowRoot.querySelector("faceplate-dialog")
|
||||
.querySelector("button")
|
||||
.click()
|
||||
""")
|
||||
self.sb.click_if_visible("Skip")
|
||||
@@ -0,0 +1,92 @@
|
||||
import re
|
||||
from typing import Mapping
|
||||
|
||||
from auto_archiver.core.metadata import Metadata
|
||||
from auto_archiver.modules.antibot_extractor_enricher.dropin import Dropin
|
||||
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
|
||||
class VkDropin(Dropin):
|
||||
"""
|
||||
A class to handle VK drop-in functionality for the antibot extractor enricher module.
|
||||
"""
|
||||
|
||||
WALL_PATTERN = re.compile(r"(wall.{0,1}\d+_\d+)")
|
||||
VIDEO_PATTERN = re.compile(r"(video.{0,1}\d+_\d+(?:_\w+)?)")
|
||||
CLIP_PATTERN = re.compile(r"(clip.{0,1}\d+_\d+)")
|
||||
PHOTO_PATTERN = re.compile(r"(photo.{0,1}\d+_\d+)")
|
||||
|
||||
def documentation() -> Mapping[str, str]:
|
||||
return {
|
||||
"name": "VKontakte Dropin",
|
||||
"description": "Handles VKontakte posts and works without authentication for some content.",
|
||||
"site": "vk.com",
|
||||
"authentication": {
|
||||
"vk.com": {
|
||||
"username": "phone number with country code",
|
||||
"password": "password",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def suitable(url: str) -> bool:
|
||||
return "vk.com" in url
|
||||
|
||||
@staticmethod
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""
|
||||
Transforms modal URLs like 'https://vk.com/page_name?w=wall-123456_7890' to 'https://vk.com/wall-123456_7890'
|
||||
"""
|
||||
for pattern in [VkDropin.WALL_PATTERN, VkDropin.VIDEO_PATTERN, VkDropin.CLIP_PATTERN, VkDropin.PHOTO_PATTERN]:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return f"https://vk.com/{match.group(1)}"
|
||||
return url
|
||||
|
||||
def open_page(self, url) -> bool:
|
||||
if self.sb.is_text_visible("Sign in to VK"):
|
||||
if self._login():
|
||||
self.sb.open(url)
|
||||
return True
|
||||
|
||||
@logger.catch
|
||||
def _login(self) -> bool:
|
||||
# TODO: test method, because current tests work without a login
|
||||
self.sb.open("https://vk.com")
|
||||
self.sb.wait_for_ready_state_complete()
|
||||
if "/feed" in self.sb.get_current_url():
|
||||
logger.debug("Already logged in to VK.")
|
||||
return True
|
||||
|
||||
# need to login
|
||||
username, password = self._get_username_password("vk.com")
|
||||
logger.debug("Logging in to VK with username: {}", username)
|
||||
|
||||
self.sb.click('[data-testid="enter-another-way"]', timeout=10)
|
||||
self.sb.clear('input[name="login"][type="tel"]', by="css selector", timeout=10)
|
||||
self.sb.type('input[name="login"][type="tel"]', username, by="css selector", timeout=10)
|
||||
self.sb.click('button[type="submit"]')
|
||||
|
||||
# TODO: handle captcha if it appears
|
||||
# if sb.is_element_visible("img.vkc__CaptchaPopup__image"):
|
||||
# captcha_url = sb.get_attribute("img.vkc__CaptchaPopup__image", "src")
|
||||
# print("CAPTCHA detected:", captcha_url)
|
||||
# image_url = sb.get_attribute("img[alt*='captcha']", "src")
|
||||
# solution = solve_captcha(image_url)
|
||||
# sb.type("input#captcha-text, input[name='captcha']", solution)
|
||||
# sb.click("button[type='submit']")
|
||||
|
||||
self.sb.type('input[name="password"]', password, timeout=15)
|
||||
self.sb.click('button[type="submit"]')
|
||||
self.sb.wait_for_ready_state_complete(timeout=10)
|
||||
self.sb.wait_for_element("body", timeout=10)
|
||||
# self.sb.sleep(2)
|
||||
return "/feed" in self.sb.get_current_url()
|
||||
|
||||
@logger.catch
|
||||
def add_extra_media(self, to_enrich: Metadata) -> tuple[int, int]:
|
||||
video_urls = [v.get_attribute("href") for v in self.sb.find_elements('a[href*="/video-"]')]
|
||||
|
||||
return 0, self._download_videos_with_ytdlp(video_urls, to_enrich)
|
||||
@@ -1 +1 @@
|
||||
from .api_db import AAApiDb
|
||||
from .api_db import AAApiDb
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"required": True,
|
||||
"help": "API endpoint where calls are made to",
|
||||
},
|
||||
"api_token": {"default": None,
|
||||
"help": "API Bearer token."},
|
||||
"api_token": {"default": None, "help": "API Bearer token."},
|
||||
"public": {
|
||||
"default": False,
|
||||
"type": "bool",
|
||||
@@ -24,9 +23,9 @@
|
||||
"help": "which group of users have access to the archive in case public=false as author",
|
||||
},
|
||||
"use_api_cache": {
|
||||
"default": True,
|
||||
"default": False,
|
||||
"type": "bool",
|
||||
"help": "if False then the API database will be queried prior to any archiving operations and stop if the link has already been archived",
|
||||
"help": "if True then the API database will be queried prior to any archiving operations and stop if the link has already been archived",
|
||||
},
|
||||
"store_results": {
|
||||
"default": True,
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Union
|
||||
|
||||
import os
|
||||
import requests
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from auto_archiver.core import Database
|
||||
from auto_archiver.core import Metadata
|
||||
@@ -12,10 +12,11 @@ class AAApiDb(Database):
|
||||
"""Connects to auto-archiver-api instance"""
|
||||
|
||||
def fetch(self, item: Metadata) -> Union[Metadata, bool]:
|
||||
""" query the database for the existence of this item.
|
||||
Helps avoid re-archiving the same URL multiple times.
|
||||
"""query the database for the existence of this item.
|
||||
Helps avoid re-archiving the same URL multiple times.
|
||||
"""
|
||||
if not self.use_api_cache: return
|
||||
if not self.use_api_cache:
|
||||
return
|
||||
|
||||
params = {"url": item.get_url(), "limit": 15}
|
||||
headers = {"Authorization": f"Bearer {self.api_token}", "accept": "application/json"}
|
||||
@@ -32,22 +33,25 @@ class AAApiDb(Database):
|
||||
|
||||
def done(self, item: Metadata, cached: bool = False) -> None:
|
||||
"""archival result ready - should be saved to DB"""
|
||||
if not self.store_results: return
|
||||
if cached:
|
||||
logger.debug(f"skipping saving archive of {item.get_url()} to the AA API because it was cached")
|
||||
if not self.store_results:
|
||||
return
|
||||
logger.debug(f"saving archive of {item.get_url()} to the AA API.")
|
||||
if cached:
|
||||
logger.debug("Skipping saving archive to AA API because it was cached")
|
||||
return
|
||||
logger.debug("Saving archive to the AA API.")
|
||||
|
||||
payload = {
|
||||
'author_id': self.author_id,
|
||||
'url': item.get_url(),
|
||||
'public': self.public,
|
||||
'group_id': self.group_id,
|
||||
'tags': list(self.tags),
|
||||
'result': item.to_json(),
|
||||
"author_id": self.author_id,
|
||||
"url": item.get_url(),
|
||||
"public": self.public,
|
||||
"group_id": self.group_id,
|
||||
"tags": list(self.tags),
|
||||
"result": item.to_json(),
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {self.api_token}"}
|
||||
response = requests.post(os.path.join(self.api_endpoint, "interop/submit-archive"), json=payload, headers=headers)
|
||||
response = requests.post(
|
||||
os.path.join(self.api_endpoint, "interop/submit-archive"), json=payload, headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
logger.success(f"AA API: {response.json()}")
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .atlos_feeder_db_storage import AtlosFeederDbStorage
|
||||
from .atlos_feeder_db_storage import AtlosFeederDbStorage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Atlos Feeder Database Storage",
|
||||
"type": ["feeder", "database", "storage"],
|
||||
"entry_point": "atlos_feeder_db_storage::AtlosFeederDbStorage",
|
||||
"entry_point": "atlos_feeder_db_storage::AtlosFeederDbStorage",
|
||||
"requires_setup": True,
|
||||
"dependencies": {
|
||||
"python": ["loguru", "requests"],
|
||||
@@ -15,7 +15,7 @@
|
||||
"atlos_url": {
|
||||
"default": "https://platform.atlos.org",
|
||||
"help": "The URL of your Atlos instance (e.g., https://platform.atlos.org), without a trailing slash.",
|
||||
"type": "str"
|
||||
"type": "str",
|
||||
},
|
||||
},
|
||||
"description": """
|
||||
@@ -42,5 +42,5 @@
|
||||
- Requires an Atlos account with a project and a valid API token for authentication.
|
||||
- Ensures only unprocessed, visible, and ready-to-archive URLs are returned.
|
||||
- Feches any media items within an Atlos project, regardless of separation into incidents.
|
||||
"""
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import os
|
||||
from typing import IO, Iterator, Optional, Union
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from auto_archiver.core import Database, Feeder, Media, Metadata, Storage
|
||||
from auto_archiver.utils import calculate_file_hash
|
||||
|
||||
|
||||
class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
|
||||
def setup(self) -> requests.Session:
|
||||
"""create and return a persistent session."""
|
||||
self.session = requests.Session()
|
||||
@@ -18,9 +17,7 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
def _get(self, endpoint: str, params: Optional[dict] = None) -> dict:
|
||||
"""Wrapper for GET requests to the Atlos API."""
|
||||
url = f"{self.atlos_url}{endpoint}"
|
||||
response = self.session.get(
|
||||
url, headers={"Authorization": f"Bearer {self.api_token}"}, params=params
|
||||
)
|
||||
response = self.session.get(url, headers={"Authorization": f"Bearer {self.api_token}"}, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -69,13 +66,13 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
"""Mark an item as failed in Atlos, if the ID exists."""
|
||||
atlos_id = item.metadata.get("atlos_id")
|
||||
if not atlos_id:
|
||||
logger.info(f"Item {item.get_url()} has no Atlos ID, skipping")
|
||||
logger.info("No Atlos ID available, skipping")
|
||||
return
|
||||
self._post(
|
||||
f"/api/v2/source_material/metadata/{atlos_id}/auto_archiver",
|
||||
json={"metadata": {"processed": True, "status": "error", "error": reason}},
|
||||
)
|
||||
logger.info(f"Stored failure for {item.get_url()} (ID {atlos_id}) on Atlos: {reason}")
|
||||
logger.info(f"Stored failure ID {atlos_id} on Atlos: {reason}")
|
||||
|
||||
def fetch(self, item: Metadata) -> Union[Metadata, bool]:
|
||||
"""check and fetch if the given item has been archived already, each
|
||||
@@ -85,16 +82,13 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
def _process_metadata(self, item: Metadata) -> dict:
|
||||
"""Process metadata for storage on Atlos. Will convert any datetime
|
||||
objects to ISO format."""
|
||||
return {
|
||||
k: v.isoformat() if hasattr(v, "isoformat") else v
|
||||
for k, v in item.metadata.items()
|
||||
}
|
||||
return {k: v.isoformat() if hasattr(v, "isoformat") else v for k, v in item.metadata.items()}
|
||||
|
||||
def done(self, item: Metadata, cached: bool = False) -> None:
|
||||
"""Mark an item as successfully archived in Atlos."""
|
||||
atlos_id = item.metadata.get("atlos_id")
|
||||
if not atlos_id:
|
||||
logger.info(f"Item {item.get_url()} has no Atlos ID, skipping")
|
||||
logger.info("Item has no Atlos ID, skipping")
|
||||
return
|
||||
self._post(
|
||||
f"/api/v2/source_material/metadata/{atlos_id}/auto_archiver",
|
||||
@@ -106,7 +100,7 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
}
|
||||
},
|
||||
)
|
||||
logger.info(f"Stored success for {item.get_url()} (ID {atlos_id}) on Atlos")
|
||||
logger.info(f"Stored success ID {atlos_id} on Atlos")
|
||||
|
||||
# ! Atlos Module - Storage Methods
|
||||
|
||||
@@ -129,10 +123,7 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
|
||||
# Check whether the media has already been uploaded
|
||||
source_material = self._get(f"/api/v2/source_material/{atlos_id}")["result"]
|
||||
existing_media = [
|
||||
artifact.get("file_hash_sha256")
|
||||
for artifact in source_material.get("artifacts", [])
|
||||
]
|
||||
existing_media = [artifact.get("file_hash_sha256") for artifact in source_material.get("artifacts", [])]
|
||||
if media_hash in existing_media:
|
||||
logger.info(f"{media.filename} with SHA256 {media_hash} already uploaded to Atlos")
|
||||
return True
|
||||
@@ -150,4 +141,3 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
|
||||
def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool:
|
||||
"""Upload a file-like object; not implemented."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
{
|
||||
'name': 'Command Line Feeder',
|
||||
'type': ['feeder'],
|
||||
'entry_point': 'cli_feeder::CLIFeeder',
|
||||
'requires_setup': False,
|
||||
'description': 'Feeds URLs to orchestrator from the command line',
|
||||
'configs': {
|
||||
'urls': {
|
||||
'default': None,
|
||||
'help': 'URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml',
|
||||
"name": "Command Line Feeder",
|
||||
"type": ["feeder"],
|
||||
"entry_point": "cli_feeder::CLIFeeder",
|
||||
"requires_setup": False,
|
||||
"configs": {
|
||||
"urls": {
|
||||
"default": None,
|
||||
"help": "URL(s) to archive, either a single URL or a list of urls, should not come from config.yaml",
|
||||
},
|
||||
},
|
||||
'description': """
|
||||
"description": """
|
||||
The Command Line Feeder is the default enabled feeder for the Auto Archiver. It allows you to pass URLs directly to the orchestrator from the command line
|
||||
without the need to specify any additional configuration or command line arguments:
|
||||
|
||||
@@ -20,4 +19,4 @@ You can pass multiple URLs by separating them with a space. The URLs will be pro
|
||||
|
||||
`auto-archiver --feeder cli_feeder -- https://example.com/1/ https://example.com/2/`
|
||||
""",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
from loguru import logger
|
||||
|
||||
from auto_archiver.core.feeder import Feeder
|
||||
from auto_archiver.core.metadata import Metadata
|
||||
from auto_archiver.core.consts import SetupError
|
||||
|
||||
|
||||
class CLIFeeder(Feeder):
|
||||
|
||||
def setup(self) -> None:
|
||||
self.urls = self.config['urls']
|
||||
self.urls = self.config["urls"]
|
||||
if not self.urls:
|
||||
raise ValueError("No URLs provided. Please provide at least one URL via the command line, or set up an alternative feeder. Use --help for more information.")
|
||||
raise SetupError(
|
||||
"No URLs provided. Please provide at least one URL via the command line, or set up an alternative feeder. Use --help for more information."
|
||||
)
|
||||
|
||||
def __iter__(self) -> Metadata:
|
||||
urls = self.config['urls']
|
||||
urls = self.config["urls"]
|
||||
for url in urls:
|
||||
logger.debug(f"Processing {url}")
|
||||
m = Metadata().set_url(url)
|
||||
m.set_context("folder", "cli")
|
||||
yield m
|
||||
|
||||
logger.success(f"Processed {len(urls)} URL(s)")
|
||||
@@ -1 +1 @@
|
||||
from .console_db import ConsoleDb
|
||||
from .console_db import ConsoleDb
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from auto_archiver.core import Database
|
||||
from auto_archiver.core import Metadata
|
||||
@@ -6,18 +6,18 @@ from auto_archiver.core import Metadata
|
||||
|
||||
class ConsoleDb(Database):
|
||||
"""
|
||||
Outputs results to the console
|
||||
Outputs results to the console
|
||||
"""
|
||||
|
||||
def started(self, item: Metadata) -> None:
|
||||
logger.info(f"STARTED {item}")
|
||||
|
||||
def failed(self, item: Metadata, reason:str) -> None:
|
||||
def failed(self, item: Metadata, reason: str) -> None:
|
||||
logger.error(f"FAILED {item}: {reason}")
|
||||
|
||||
def aborted(self, item: Metadata) -> None:
|
||||
logger.warning(f"ABORTED {item}")
|
||||
|
||||
def done(self, item: Metadata, cached: bool=False) -> None:
|
||||
def done(self, item: Metadata, cached: bool = False) -> None:
|
||||
"""archival result ready - should be saved to DB"""
|
||||
logger.success(f"DONE {item}")
|
||||
logger.success(f"DONE {item}")
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .csv_db import CSVDb
|
||||
from .csv_db import CSVDb
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
"name": "CSV Database",
|
||||
"type": ["database"],
|
||||
"requires_setup": False,
|
||||
"dependencies": {"python": ["loguru"]
|
||||
},
|
||||
'entry_point': 'csv_db::CSVDb',
|
||||
"dependencies": {"python": ["loguru"]},
|
||||
"entry_point": "csv_db::CSVDb",
|
||||
"configs": {
|
||||
"csv_file": {"default": "db.csv", "help": "CSV file name to save metadata to"},
|
||||
},
|
||||
"csv_file": {"default": "db.csv", "help": "CSV file name to save metadata to"},
|
||||
},
|
||||
"description": """
|
||||
Handles exporting archival results to a CSV file.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
from csv import DictWriter
|
||||
from dataclasses import asdict
|
||||
|
||||
@@ -9,14 +9,15 @@ from auto_archiver.core import Metadata
|
||||
|
||||
class CSVDb(Database):
|
||||
"""
|
||||
Outputs results to a CSV file
|
||||
Outputs results to a CSV file
|
||||
"""
|
||||
|
||||
def done(self, item: Metadata, cached: bool=False) -> None:
|
||||
def done(self, item: Metadata, cached: bool = False) -> None:
|
||||
"""archival result ready - should be saved to DB"""
|
||||
logger.success(f"DONE {item}")
|
||||
is_empty = not os.path.isfile(self.csv_file) or os.path.getsize(self.csv_file) == 0
|
||||
with open(self.csv_file, "a", encoding="utf-8") as outf:
|
||||
writer = DictWriter(outf, fieldnames=asdict(Metadata()))
|
||||
if is_empty: writer.writeheader()
|
||||
if is_empty:
|
||||
writer.writeheader()
|
||||
writer.writerow(asdict(item))
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .csv_feeder import CSVFeeder
|
||||
from .csv_feeder import CSVFeeder
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
{
|
||||
"name": "CSV Feeder",
|
||||
"type": ["feeder"],
|
||||
"requires_setup": False,
|
||||
"dependencies": {
|
||||
"python": ["loguru"],
|
||||
"bin": [""]
|
||||
},
|
||||
'requires_setup': True,
|
||||
'entry_point': "csv_feeder::CSVFeeder",
|
||||
"dependencies": {"python": ["loguru"], "bin": [""]},
|
||||
"requires_setup": True,
|
||||
"entry_point": "csv_feeder::CSVFeeder",
|
||||
"configs": {
|
||||
"files": {
|
||||
"default": None,
|
||||
"help": "Path to the input file(s) to read the URLs from, comma separated. \
|
||||
"files": {
|
||||
"default": None,
|
||||
"help": "Path to the input file(s) to read the URLs from, comma separated. \
|
||||
Input files should be formatted with one URL per line",
|
||||
"required": True,
|
||||
"type": "valid_file",
|
||||
"nargs": "+",
|
||||
},
|
||||
"column": {
|
||||
"default": None,
|
||||
"help": "Column number or name to read the URLs from, 0-indexed",
|
||||
}
|
||||
"required": True,
|
||||
"type": "valid_file",
|
||||
"nargs": "+",
|
||||
},
|
||||
"column": {
|
||||
"default": None,
|
||||
"help": "Column number or name to read the URLs from, 0-indexed",
|
||||
},
|
||||
},
|
||||
"description": """
|
||||
Reads URLs from CSV files and feeds them into the archiving process.
|
||||
|
||||
@@ -33,5 +29,5 @@
|
||||
### Setup
|
||||
- Input files should be formatted with one URL per line, with or without a header row.
|
||||
- If you have a header row, you can specify the column number or name to read URLs from using the 'column' config option.
|
||||
"""
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
import csv
|
||||
|
||||
from auto_archiver.core import Feeder
|
||||
from auto_archiver.core import Metadata
|
||||
from auto_archiver.utils import url_or_none
|
||||
|
||||
|
||||
class CSVFeeder(Feeder):
|
||||
|
||||
column = None
|
||||
|
||||
|
||||
def __iter__(self) -> Metadata:
|
||||
for file in self.files:
|
||||
with open(file, "r") as f:
|
||||
@@ -20,9 +19,11 @@ class CSVFeeder(Feeder):
|
||||
try:
|
||||
url_column = first_row.index(url_column)
|
||||
except ValueError:
|
||||
logger.error(f"Column {url_column} not found in header row: {first_row}. Did you set the 'column' config correctly?")
|
||||
logger.error(
|
||||
f"Column {url_column} not found in header row: {first_row}. Did you set the 'column' config correctly?"
|
||||
)
|
||||
return
|
||||
elif not(url_or_none(first_row[url_column])):
|
||||
elif not (url_or_none(first_row[url_column])):
|
||||
# it's a header row, but we've been given a column number already
|
||||
logger.debug(f"Skipping header row: {first_row}")
|
||||
else:
|
||||
@@ -34,5 +35,4 @@ class CSVFeeder(Feeder):
|
||||
logger.warning(f"Not a valid URL in row: {row}, skipping")
|
||||
continue
|
||||
url = row[url_column]
|
||||
logger.debug(f"Processing {url}")
|
||||
yield Metadata().set_url(url)
|
||||
yield Metadata().set_url(url)
|
||||
|
||||
@@ -1 +1 @@
|
||||
from .gdrive_storage import GDriveStorage
|
||||
from .gdrive_storage import GDriveStorage
|
||||
|
||||
@@ -19,14 +19,21 @@
|
||||
},
|
||||
"filename_generator": {
|
||||
"default": "static",
|
||||
"help": "how to name stored files: 'random' creates a random string; 'static' uses a replicable strategy such as a hash.",
|
||||
"help": "how to name stored files: 'random' creates a random string; 'static' uses a hash, with the settings of the 'hash_enricher' module (defaults to SHA256 if not enabled).",
|
||||
"choices": ["random", "static"],
|
||||
},
|
||||
"root_folder_id": {"required": True,
|
||||
"help": "root google drive folder ID to use as storage, found in URL: 'https://drive.google.com/drive/folders/FOLDER_ID'"},
|
||||
"oauth_token": {"default": None,
|
||||
"help": "JSON filename with Google Drive OAuth token: check auto-archiver repository scripts folder for create_update_gdrive_oauth_token.py. NOTE: storage used will count towards owner of GDrive folder, therefore it is best to use oauth_token_filename over service_account."},
|
||||
"service_account": {"default": "secrets/service_account.json", "help": "service account JSON file path, same as used for Google Sheets. NOTE: storage used will count towards the developer account."},
|
||||
"root_folder_id": {
|
||||
"required": True,
|
||||
"help": "root google drive folder ID to use as storage, found in URL: 'https://drive.google.com/drive/folders/FOLDER_ID'",
|
||||
},
|
||||
"oauth_token": {
|
||||
"default": None,
|
||||
"help": "JSON filename with Google Drive OAuth token: check auto-archiver repository scripts folder for create_update_gdrive_oauth_token.py. NOTE: storage used will count towards owner of GDrive folder, therefore it is best to use oauth_token_filename over service_account.",
|
||||
},
|
||||
"service_account": {
|
||||
"default": "secrets/service_account.json",
|
||||
"help": "service account JSON file path, same as used for Google Sheets. NOTE: storage used will count towards the developer account.",
|
||||
},
|
||||
},
|
||||
"description": """
|
||||
|
||||
@@ -94,5 +101,5 @@ This module integrates Google Drive as a storage backend, enabling automatic fol
|
||||
https://davemateer.com/2022/04/28/google-drive-with-python#tokens
|
||||
|
||||
|
||||
"""
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -9,18 +8,15 @@ from google.oauth2 import service_account
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
from loguru import logger
|
||||
from auto_archiver.utils.custom_logger import logger
|
||||
|
||||
from auto_archiver.core import Media
|
||||
from auto_archiver.core import Storage
|
||||
|
||||
|
||||
|
||||
|
||||
class GDriveStorage(Storage):
|
||||
|
||||
def setup(self) -> None:
|
||||
self.scopes = ['https://www.googleapis.com/auth/drive']
|
||||
self.scopes = ["https://www.googleapis.com/auth/drive"]
|
||||
# Initialize Google Drive service
|
||||
self._setup_google_drive_service()
|
||||
|
||||
@@ -37,25 +33,25 @@ class GDriveStorage(Storage):
|
||||
|
||||
def _initialize_with_oauth_token(self):
|
||||
"""Initialize Google Drive service with OAuth token."""
|
||||
with open(self.oauth_token, 'r') as stream:
|
||||
with open(self.oauth_token, "r") as stream:
|
||||
creds_json = json.load(stream)
|
||||
creds_json['refresh_token'] = creds_json.get("refresh_token", "")
|
||||
creds_json["refresh_token"] = creds_json.get("refresh_token", "")
|
||||
|
||||
creds = Credentials.from_authorized_user_info(creds_json, self.scopes)
|
||||
if not creds.valid and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
with open(self.oauth_token, 'w') as token_file:
|
||||
with open(self.oauth_token, "w") as token_file:
|
||||
logger.debug("Saving refreshed OAuth token.")
|
||||
token_file.write(creds.to_json())
|
||||
elif not creds.valid:
|
||||
raise ValueError("Invalid OAuth token. Please regenerate the token.")
|
||||
|
||||
return build('drive', 'v3', credentials=creds)
|
||||
return build("drive", "v3", credentials=creds)
|
||||
|
||||
def _initialize_with_service_account(self):
|
||||
"""Initialize Google Drive service with service account."""
|
||||
creds = service_account.Credentials.from_service_account_file(self.service_account, scopes=self.scopes)
|
||||
return build('drive', 'v3', credentials=creds)
|
||||
return build("drive", "v3", credentials=creds)
|
||||
|
||||
def get_cdn_url(self, media: Media) -> str:
|
||||
"""
|
||||
@@ -66,7 +62,7 @@ class GDriveStorage(Storage):
|
||||
parent_id, folder_id = self.root_folder_id, None
|
||||
path_parts = media.key.split(os.path.sep)
|
||||
filename = path_parts[-1]
|
||||
logger.info(f"looking for folders for {path_parts[0:-1]} before getting url for {filename=}")
|
||||
logger.info(f"Looking for folders for {path_parts[0:-1]} before getting url for {filename=}")
|
||||
for folder in path_parts[0:-1]:
|
||||
folder_id = self._get_id_from_parent_and_name(parent_id, folder, use_mime_type=True, raise_on_missing=True)
|
||||
parent_id = folder_id
|
||||
@@ -74,12 +70,12 @@ class GDriveStorage(Storage):
|
||||
file_id = self._get_id_from_parent_and_name(folder_id, filename, raise_on_missing=True)
|
||||
if not file_id:
|
||||
#
|
||||
logger.info(f"file {filename} not found in folder {folder_id}")
|
||||
logger.info(f"File {filename} not found in folder {folder_id}")
|
||||
return None
|
||||
return f"https://drive.google.com/file/d/{file_id}/view?usp=sharing"
|
||||
|
||||
def upload(self, media: Media, **kwargs) -> bool:
|
||||
logger.debug(f'[{self.__class__.__name__}] storing file {media.filename} with key {media.key}')
|
||||
logger.debug(f"[{self.__class__.__name__}] storing file {media.filename} with key {media.key}")
|
||||
"""
|
||||
1. for each sub-folder in the path check if exists or create
|
||||
2. upload file to root_id/other_paths.../filename
|
||||
@@ -87,7 +83,7 @@ class GDriveStorage(Storage):
|
||||
parent_id, upload_to = self.root_folder_id, None
|
||||
path_parts = media.key.split(os.path.sep)
|
||||
filename = path_parts[-1]
|
||||
logger.info(f"checking folders {path_parts[0:-1]} exist (or creating) before uploading {filename=}")
|
||||
logger.info(f"Checking folders {path_parts[0:-1]} exist (or creating) before uploading {filename=}")
|
||||
for folder in path_parts[0:-1]:
|
||||
upload_to = self._get_id_from_parent_and_name(parent_id, folder, use_mime_type=True, raise_on_missing=False)
|
||||
if upload_to is None:
|
||||
@@ -95,25 +91,35 @@ class GDriveStorage(Storage):
|
||||
parent_id = upload_to
|
||||
|
||||
# upload file to gd
|
||||
logger.debug(f'uploading {filename=} to folder id {upload_to}')
|
||||
file_metadata = {
|
||||
'name': [filename],
|
||||
'parents': [upload_to]
|
||||
}
|
||||
media = MediaFileUpload(media.filename, resumable=True)
|
||||
gd_file = self.service.files().create(supportsAllDrives=True, body=file_metadata, media_body=media, fields='id').execute()
|
||||
logger.debug(f'uploadf: uploaded file {gd_file["id"]} successfully in folder={upload_to}')
|
||||
logger.debug(f"Uploading {filename=} to folder id {upload_to}")
|
||||
file_metadata = {"name": [filename], "parents": [upload_to]}
|
||||
try:
|
||||
media = MediaFileUpload(media.filename, resumable=True)
|
||||
gd_file = (
|
||||
self.service.files()
|
||||
.create(supportsAllDrives=True, body=file_metadata, media_body=media, fields="id")
|
||||
.execute()
|
||||
)
|
||||
logger.debug(f"Uploadf: uploaded file {gd_file['id']} successfully in folder={upload_to}")
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"GD uploadf: file not found {media.filename=} - {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"GD uploadf: error uploading {media.filename=} to {upload_to} - {e}")
|
||||
|
||||
# must be implemented even if unused
|
||||
def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool: pass
|
||||
def uploadf(self, file: IO[bytes], key: str, **kwargs: dict) -> bool:
|
||||
pass
|
||||
|
||||
def _get_id_from_parent_and_name(self, parent_id: str,
|
||||
name: str,
|
||||
retries: int = 1,
|
||||
sleep_seconds: int = 10,
|
||||
use_mime_type: bool = False,
|
||||
raise_on_missing: bool = True,
|
||||
use_cache=False):
|
||||
def _get_id_from_parent_and_name(
|
||||
self,
|
||||
parent_id: str,
|
||||
name: str,
|
||||
retries: int = 1,
|
||||
sleep_seconds: int = 10,
|
||||
use_mime_type: bool = False,
|
||||
raise_on_missing: bool = True,
|
||||
use_cache=False,
|
||||
):
|
||||
"""
|
||||
Retrieves the id of a folder or file from its @name and the @parent_id folder
|
||||
Optionally does multiple @retries and sleeps @sleep_seconds between them
|
||||
@@ -127,39 +133,46 @@ class GDriveStorage(Storage):
|
||||
self.api_cache = getattr(self, "api_cache", {})
|
||||
cache_key = f"{parent_id}_{name}_{use_mime_type}"
|
||||
if cache_key in self.api_cache:
|
||||
logger.debug(f"cache hit for {cache_key=}")
|
||||
logger.debug(f"Cache hit for {cache_key=}")
|
||||
return self.api_cache[cache_key]
|
||||
|
||||
# API logic
|
||||
debug_header: str = f"[searching {name=} in {parent_id=}]"
|
||||
query_string = f"'{parent_id}' in parents and name = '{name}' and trashed = false "
|
||||
if use_mime_type:
|
||||
query_string += f" and mimeType='application/vnd.google-apps.folder' "
|
||||
query_string += " and mimeType='application/vnd.google-apps.folder' "
|
||||
|
||||
for attempt in range(retries):
|
||||
results = self.service.files().list(
|
||||
# both below for Google Shared Drives
|
||||
supportsAllDrives=True,
|
||||
includeItemsFromAllDrives=True,
|
||||
q=query_string,
|
||||
spaces='drive', # ie not appDataFolder or photos
|
||||
fields='files(id, name)'
|
||||
).execute()
|
||||
items = results.get('files', [])
|
||||
results = (
|
||||
self.service.files()
|
||||
.list(
|
||||
# both below for Google Shared Drives
|
||||
supportsAllDrives=True,
|
||||
includeItemsFromAllDrives=True,
|
||||
q=query_string,
|
||||
spaces="drive", # ie not appDataFolder or photos
|
||||
fields="files(id, name)",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
items = results.get("files", [])
|
||||
|
||||
if len(items) > 0:
|
||||
logger.debug(f"{debug_header} found {len(items)} matches, returning last of {','.join([i['id'] for i in items])}")
|
||||
_id = items[-1]['id']
|
||||
if use_cache: self.api_cache[cache_key] = _id
|
||||
logger.debug(
|
||||
f"{debug_header} found {len(items)} matches, returning last of {','.join([i['id'] for i in items])}"
|
||||
)
|
||||
_id = items[-1]["id"]
|
||||
if use_cache:
|
||||
self.api_cache[cache_key] = _id
|
||||
return _id
|
||||
else:
|
||||
logger.debug(f'{debug_header} not found, attempt {attempt+1}/{retries}.')
|
||||
logger.debug(f"{debug_header} not found, attempt {attempt + 1}/{retries}.")
|
||||
if attempt < retries - 1:
|
||||
logger.debug(f'sleeping for {sleep_seconds} second(s)')
|
||||
logger.debug(f"Sleeping for {sleep_seconds} second(s)")
|
||||
time.sleep(sleep_seconds)
|
||||
|
||||
if raise_on_missing:
|
||||
raise ValueError(f'{debug_header} not found after {retries} attempt(s)')
|
||||
raise ValueError(f"{debug_header} not found after {retries} attempt(s)")
|
||||
return None
|
||||
|
||||
def _mkdir(self, name: str, parent_id: str):
|
||||
@@ -167,12 +180,7 @@ class GDriveStorage(Storage):
|
||||
Creates a new GDrive folder @name inside folder @parent_id
|
||||
Returns id of the created folder
|
||||
"""
|
||||
logger.debug(f'Creating new folder with {name=} inside {parent_id=}')
|
||||
file_metadata = {
|
||||
'name': [name],
|
||||
'mimeType': 'application/vnd.google-apps.folder',
|
||||
'parents': [parent_id]
|
||||
}
|
||||
gd_folder = self.service.files().create(supportsAllDrives=True, body=file_metadata, fields='id').execute()
|
||||
return gd_folder.get('id')
|
||||
|
||||
logger.debug(f"Creating new folder with {name=} inside {parent_id=}")
|
||||
file_metadata = {"name": [name], "mimeType": "application/vnd.google-apps.folder", "parents": [parent_id]}
|
||||
gd_folder = self.service.files().create(supportsAllDrives=True, body=file_metadata, fields="id").execute()
|
||||
return gd_folder.get("id")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user