Compare commits

...

391 Commits

Author SHA1 Message Date
dependabot[bot]
1aebed4916 Bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.1.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5.10.0` | `6.0.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3.7.0 to 4.1.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](c94ce9fb46...4907a6ddec)

Updates `docker/metadata-action` from 5.10.0 to 6.0.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](c299e40c65...030e881283)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 23:45:57 +00:00
Miguel Sozinho Ramalho
afbe4fac50 Merge pull request #430 from bellingcat/dev
bug fixes and maintenance
2026-04-27 15:52:39 +01:00
msramalho
e633be1721 version bump 2026-04-27 12:35:54 +01:00
msramalho
bc06de8e5c fixes incomplete yt-dlp parts download 2026-04-27 12:34:47 +01:00
Miguel Sozinho Ramalho
20fddce3a3 Merge pull request #427 from PeterUpfold/deno-container
Fix missing JS runtime config for bguils_po_token_method
2026-04-24 11:08:28 +01:00
msramalho
6efa439cdb dependencies bump 2026-04-23 17:20:54 +01:00
Miguel Sozinho Ramalho
ef77d1fc86 Merge branch 'main' into dev 2026-04-23 14:21:01 +01:00
msramalho
a57a5ee005 adds an extra check when calling pypi as it's led to uncaught ssl errors 2026-04-23 14:20:07 +01:00
msramalho
2582f567ac removes curl/unzip from dockerfile 2026-04-23 14:04:46 +01:00
msramalho
4e5c1a6218 suggested alternative change to deno install 2026-04-23 14:02:51 +01:00
Peter Upfold
12d9c469b2 Add Deno to Dockerfile 2026-04-13 18:19:23 +01:00
Miguel Sozinho Ramalho
792838f1a1 Merge pull request #419 from bellingcat/dev
Dependencies bump, new ghostarchive enricher
2026-04-07 14:44:35 +01:00
Miguel Sozinho Ramalho
17c4ae15eb Merge branch 'main' into dev 2026-04-07 10:51:10 +01:00
msramalho
a08af07348 version bump 2026-04-06 18:34:20 +01:00
Miguel Sozinho Ramalho
e54077f4e8 Merge pull request #418 from bellingcat/feat/ghostarchive
Feat/ghostarchive
2026-04-06 18:33:15 +01:00
msramalho
319c0528da dependencies bump 2026-04-06 18:27:47 +01:00
msramalho
ae0e53e434 adds tests for new ghostarchive enricher feature 2026-04-06 17:15:32 +01:00
msramalho
82fc786d56 implements new enricher to submit URLs to ghostarchive 2026-04-06 17:13:48 +01:00
Miguel Sozinho Ramalho
aa65299844 Merge pull request #408 from bellingcat/dev
telethon compatibility with celery workers, dependency bumps
2026-03-16 11:28:21 +00:00
msramalho
1b69ec1f00 dependencies bump 2026-03-16 11:11:57 +00:00
Miguel Sozinho Ramalho
304e5d40b1 Merge branch 'main' into dev 2026-03-16 11:10:26 +00:00
msramalho
3194fee95d fix telethon bug when running in celery workers that close the event loop 2026-03-12 10:20:11 +00:00
msramalho
0040810e2e dependencies bump 2026-03-10 14:33:25 +00:00
Miguel Sozinho Ramalho
63cfe34e23 Merge pull request #407 from bellingcat/dev
minor bug fix: handles failed get downloads
2026-03-02 17:10:46 +00:00
msramalho
23a88e3cf4 ci issues 2026-03-02 17:07:09 +00:00
msramalho
3cac160cc1 version bump 2026-03-02 17:01:33 +00:00
msramalho
e9a92272c5 bug fix: missing filename on url download 2026-03-02 17:01:16 +00:00
Miguel Sozinho Ramalho
5d6c5ac2b1 Merge pull request #406 from bellingcat/dev
1.2.3
2026-03-02 15:42:08 +00:00
msramalho
f1de07c9aa version bump 2026-03-02 15:41:03 +00:00
msramalho
1e1e060a77 closes #342 2026-03-02 15:37:55 +00:00
msramalho
b43d229326 closes #358 2026-03-02 14:27:48 +00:00
msramalho
077b03fc61 minor tests change to work in gh actions 2026-03-02 14:08:14 +00:00
Miguel Sozinho Ramalho
cf77cfa64d Merge pull request #405 from bellingcat/feat/nitter-alternative
closes #400 Feat twitter drop-in alternative
2026-03-02 12:33:34 +00:00
msramalho
bc66dd4f2a fxtwitter working instead of nitter 2026-03-02 12:31:28 +00:00
msramalho
139d647197 Merge branch 'dev' into feat/nitter-alternative 2026-03-02 12:16:22 +00:00
msramalho
f465b570cd adding missing tests (no download) 2026-03-02 12:14:47 +00:00
Miguel Sozinho Ramalho
52a7cabaf1 Merge pull request #402 from bellingcat/dev
bug fix: wacz screenshots leak in shared session
2026-02-25 10:39:54 +00:00
msramalho
a739361e12 bug fix: wacz screenshots leak in shared session 2026-02-23 16:26:36 +00:00
Miguel Sozinho Ramalho
9a97fede43 Merge pull request #401 from bellingcat/dev
Dependencies maintenance.
2026-02-23 13:27:51 +00:00
msramalho
2d13077fad bumping ruff version 2026-02-23 12:36:53 +00:00
msramalho
8a4a314cf9 ruff python version to dev version 2026-02-23 12:32:24 +00:00
msramalho
75e8b788ae revert ruff workflow changes 2026-02-23 12:31:20 +00:00
msramalho
defe2315bf docs updates 2026-02-23 12:28:25 +00:00
msramalho
b9ab26ed5a see #400 WIP nitter not working as of now 2026-02-23 12:20:10 +00:00
msramalho
ba0dffdd5e Merge branch 'dev' of github.com:bellingcat/auto-archiver into dev 2026-02-23 12:18:58 +00:00
msramalho
a09927c507 minor docs fix 2026-02-23 12:18:47 +00:00
Miguel Sozinho Ramalho
6c938c489a Merge pull request #392 from bellingcat/dependabot/github_actions/actions-bc0df0c757
Bump the actions group with 5 updates
2026-02-23 11:28:24 +00:00
msramalho
0e39768da9 version bumping settings script 2026-02-23 11:27:12 +00:00
msramalho
1e5d6ec4a6 version bump: minor 2026-02-23 11:23:40 +00:00
msramalho
3385d004cf yt-dlp to latest version 2026-02-23 11:23:26 +00:00
msramalho
7f27f7fce0 closes #383 fixing browsertrix-crawler at 1.11.4 2026-02-23 11:23:06 +00:00
msramalho
a6e3240af1 closes #399 and global dependency updates 2026-02-23 11:13:31 +00:00
dependabot[bot]
bf4c196cc2 Bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [docker/login-action](https://github.com/docker/login-action) | `3.4.0` | `3.7.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5.7.0` | `5.10.0` |
| [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` |
| [actions/cache](https://github.com/actions/cache) | `4` | `5` |


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `docker/login-action` from 3.4.0 to 3.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](74a5d14239...c94ce9fb46)

Updates `docker/metadata-action` from 5.7.0 to 5.10.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](902fa8ec7d...c299e40c65)

Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: 5.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-01 20:17:43 +00:00
Miguel Sozinho Ramalho
c640cc898a Merge pull request #385 from bellingcat/dev
1.2.0 dependencies, small bugs, 1st time contributors
2026-01-08 15:55:40 +00:00
msramalho
3e2c0b564b wiki fix 2026-01-08 15:49:42 +00:00
msramalho
5fd23baa55 this is ruff 2026-01-08 15:48:08 +00:00
msramalho
8a450310c7 version bump for new release 2026-01-08 15:41:27 +00:00
msramalho
bef8a14089 pyperclip version bump closes #339 2026-01-08 15:40:17 +00:00
msramalho
cd0b093e7a browsertrix-crawler to 1.9.2 see #383 2026-01-08 15:33:40 +00:00
msramalho
096c9d09ef fix for unexpected types for json.dump 2026-01-08 15:18:19 +00:00
Miguel Sozinho Ramalho
df3521e9ca Merge pull request #377 from m4cd4r4/fix/improve-deleted-post-detection
Fix #335: Add comprehensive deletion detection for removed/unavailable content
2026-01-08 15:06:21 +00:00
msramalho
a89d0193e4 removes patch file 2026-01-08 15:02:00 +00:00
msramalho
536cbd905f puts tests file in correct directory 2026-01-08 14:55:40 +00:00
msramalho
a936921c4e updates new utils file and test 2026-01-08 14:54:06 +00:00
Miguel Sozinho Ramalho
68f672a4fa Merge branch 'dev' into fix/improve-deleted-post-detection 2026-01-08 14:36:17 +00:00
Miguel Sozinho Ramalho
4ee0ad1cf8 Merge pull request #359 from mjgaughan/specify-medatada-feature
implementing default metadata omission/user metadata selection
2026-01-08 14:34:50 +00:00
msramalho
bac809451c expands tests to included non predefined metadata keys 2026-01-08 14:33:16 +00:00
msramalho
53dc9904ce refactorws PR to obey standard code approach 2026-01-08 14:30:26 +00:00
Miguel Sozinho Ramalho
c1f312d42a Merge branch 'dev' into specify-medatada-feature 2026-01-08 14:04:42 +00:00
msramalho
23c9dfe717 updating dependencies 2026-01-08 13:53:44 +00:00
m4cd4r4
d02e7e0f02 Add comprehensive deletion detection for removed/unavailable content
Implements issue #335: improve detection of deleted/missing posts

## Changes

### New Deletion Detection System
- Created `deletion_detection.py` utility module with platform-specific
  indicators for Twitter, Facebook, Instagram, TikTok, YouTube, Reddit,
  VK, and Telegram
- Detects deletion via HTML content, page titles, error messages, and
  video metadata
- Stores detailed deletion context (indicator, source, platform) in
  metadata for investigators

### Integration Points
- **Antibot Extractor**: Checks HTML and page titles after page load;
  resolves TODO about detecting deleted videos
- **Generic Extractor**: Checks yt-dlp video data and error messages
  for deletion indicators
- **Twitter Dropin**: Enhanced detection when user/created_at fields
  are missing

### Test Coverage
- Comprehensive test suite covering all platforms
- Tests for HTML, title, error message, and metadata detection
- Validates that normal content is not falsely flagged

## Impact for Conflict Documentation

This fix is critical for evidence preservation in war-torn regions:
- Investigators can now document that evidence existed but was deleted
- Prevents wasted archival attempts on deleted content
- Tracks patterns of content removal
- Preserves metadata about what was deleted and when

Twitter example: Detects "Hmm...this page doesn't exist. Try searching
for something else" and flags content as deleted_or_unavailable.
2025-12-17 18:40:58 +08:00
Miguel Sozinho Ramalho
56526a9ac7 Merge pull request #365 from bellingcat/dev
Facebook reels fix
2025-10-23 10:40:43 +01:00
msramalho
3a22cc28c0 skip tiktok antibot test in CI 2025-10-23 10:17:14 +01:00
msramalho
dbb3dfa04f fixes wikipedia test 2025-10-23 10:04:44 +01:00
msramalho
01bdb35f5d version bump 2025-10-23 09:51:31 +01:00
msramalho
43cbc6ac56 generic extractor improvements 2025-10-23 09:51:14 +01:00
msramalho
9c7cab1ae2 dependencies update 2025-10-22 21:07:12 +01:00
msramalho
a9a0bae083 dependencies update 2025-10-22 18:11:36 +01:00
Miguel Sozinho Ramalho
97d133ce79 Merge pull request #357 from bellingcat/dev
small improvements on tiktok and verison bumps
2025-10-22 16:02:26 +01:00
msramalho
432ee3dcfd version bump 2025-10-22 15:50:50 +01:00
mgaughan
94e0803fb3 implementing default metadata omission/user metadata selection 2025-09-22 20:16:40 -05:00
msramalho
794b4f6052 Merge branch 'dev' of https://github.com/bellingcat/auto-archiver into dev 2025-09-11 15:06:27 +01:00
msramalho
965d7d41dd dependency updates 2025-09-11 15:06:25 +01:00
Miguel Sozinho Ramalho
e73faa70cc Merge pull request #352 from mjgaughan/developer-documentation-updates
updating the style-checking code in the documentation
2025-08-11 10:42:53 +01:00
mgaughan
80beab9f23 ruff-fix -> ruff-clean; there is no ruff-fix in the Makefile. Maybe the command /should/ be ruff-fix to align with the underlying ruff command; for later discussion. This at least reconciles the documentation to the Makefile 2025-08-05 21:36:32 -04:00
Miguel Sozinho Ramalho
200cea4e12 Merge pull request #345 from mjgaughan/main
Correction of small documentation typos
2025-07-29 09:36:10 +01:00
mgaughan
1256fde159 updating location of .env.test.example in documentation 2025-07-23 13:04:48 -04:00
mgaughan
65e222e177 fixing typo in documentation pytest -> poetry 2025-07-22 17:20:59 -04:00
mgaughan
f2eb9ef784 correcting to double-dash in the poetry install documentation 2025-07-21 17:55:48 -04:00
msramalho
2081c16555 embed retry into timestamping 2025-07-10 14:49:53 +01:00
msramalho
d3efd7121c avoid empty metadata comments 2025-07-06 14:05:17 +01:00
msramalho
9d3cd5774b an improved approach for #295 2025-07-06 14:04:01 +01:00
Miguel Sozinho Ramalho
80d61e8b85 Merge pull request #341 from bellingcat/dev
Address several small bugs, includes tiktok photos extraction, and data-saving for proxy usage in generic_extractor.
2025-07-05 20:28:00 +01:00
msramalho
d36cdbfa87 fixing pypaperclip see issue #339 2025-07-05 19:07:23 +01:00
msramalho
c1506ee1cf some wayback errors are expected and should be warnings 2025-07-05 18:31:39 +01:00
msramalho
3a34a49822 adds antibot tiktok logic for photos closes #295 2025-07-05 18:31:12 +01:00
msramalho
37c6d97275 new auth wall check logic and escaped CSS selector in selenium 2025-07-05 18:30:31 +01:00
msramalho
7234eda85f expands Sheets API retries for really large spreadsheets 2025-07-05 18:29:33 +01:00
msramalho
a8c1ef3912 generic_extractor config to use proxy only when needed to avoid overzealousness 2025-07-05 16:54:58 +01:00
msramalho
52ed8196a5 updates dependencies 2025-07-05 16:03:47 +01:00
msramalho
2051e8e491 adds further exponential backoff for Sheets API worksheet enumeration 2025-07-05 16:02:07 +01:00
msramalho
21255db86a stops using service that is not up for timestamping 2025-07-05 16:00:46 +01:00
msramalho
eae0da08b3 fix issue with two runs of anitbot extractor 2025-07-05 16:00:03 +01:00
msramalho
0d1447117c updates docs to reflect new general approach extractor 2025-07-05 15:56:13 +01:00
Miguel Sozinho Ramalho
0f56a5aae5 Merge pull request #331 from bellingcat/dev
1.1.1 multiple small fixes, and new logging strategy
2025-06-30 02:36:25 +01:00
msramalho
649412053e exclude non-ready code 2025-06-30 02:27:21 +01:00
msramalho
c2c9718f73 make python api tests work on gh when no env is set 2025-06-30 02:20:51 +01:00
msramalho
30ea8a0ba4 bumps dependencies 2025-06-30 02:20:09 +01:00
msramalho
73c8dc583f closes #333 2025-06-30 01:52:22 +01:00
msramalho
b2648fa3cd follow docs advice on exponential backoff of SheetsAPI 2025-06-30 01:47:12 +01:00
msramalho
4ad71b3589 adds retry to worksheet read for slow worksheets 2025-06-30 01:42:34 +01:00
msramalho
7c9475cde2 allow for human readable console logs, but defaults to JSON on file logs. 2025-06-30 00:53:10 +01:00
msramalho
afd9090a4c concludes logging standardization refactor 2025-06-26 17:20:04 +01:00
msramalho
ad29cb4447 adds post_data to metadata for instagram 2025-06-26 15:48:10 +01:00
msramalho
ce4d7ac649 WIP refactor logging 2025-06-21 15:54:51 +01:00
msramalho
ade7feb5a0 version bump 2025-06-18 17:38:17 +01:00
msramalho
12b457706b closes #166 adds story URL feature to telethon extractor 2025-06-18 17:37:44 +01:00
msramalho
592dc30415 closes #330 2025-06-18 16:40:55 +01:00
msramalho
4a36e6f6b0 fix tests 2025-06-18 13:50:21 +01:00
msramalho
d46eeee9b6 docs improved 2025-06-18 13:35:51 +01:00
msramalho
302e6f4258 logs improved 2025-06-18 13:35:43 +01:00
Miguel Sozinho Ramalho
e803c5d0e3 Merge branch 'main' into dev 2025-06-18 13:35:21 +01:00
msramalho
e1d0314a9e Merge branch 'dev' of https://github.com/bellingcat/auto-archiver into dev 2025-06-18 13:26:48 +01:00
Miguel Sozinho Ramalho
5d5119e053 Merge pull request #329 from bellingcat/dev
installs ffmpeg in readthedocs
2025-06-18 00:31:09 +01:00
msramalho
d6c90d87f1 installs ffmpeg in readthedocs 2025-06-18 00:30:45 +01:00
msramalho
212bf67ab1 installs ffmpeg in readthedocs 2025-06-18 00:29:36 +01:00
Miguel Sozinho Ramalho
6abe2edb13 Merge pull request #328 from bellingcat/dev
fix to configuration editor npm versions
2025-06-18 00:22:39 +01:00
msramalho
03c0cf09ae fix issue with grid in scripts/config_editor @mui lib upgrade 2025-06-18 00:20:31 +01:00
Miguel Sozinho Ramalho
0db77c7e68 Merge pull request #326 from bellingcat/dependabot/npm_and_yarn/scripts/settings/actions-27795ad889
Bump @types/react from 19.1.7 to 19.1.8 in /scripts/settings in the actions group across 1 directory
2025-06-18 00:12:51 +01:00
dependabot[bot]
cd6607943d Bump @types/react
Bumps the actions group with 1 update in the /scripts/settings directory: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react).


Updates `@types/react` from 19.1.7 to 19.1.8
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-17 22:58:23 +00:00
Miguel Sozinho Ramalho
3869ea73d7 Merge pull request #312 from bellingcat/dev v1.1.0
v1.1.0 WIP
2025-06-17 23:57:22 +01:00
msramalho
918cb220be minor indentation issue 2025-06-17 23:51:10 +01:00
msramalho
76fd329fe5 twitter tests fix 2025-06-17 23:51:03 +01:00
msramalho
a3ae9ebbb3 log level updates 2025-06-17 20:36:33 +01:00
msramalho
23b781c866 new check for edge case 2025-06-17 20:36:22 +01:00
msramalho
2aec240128 thumbnail enricher always run probe by default 2025-06-17 20:28:20 +01:00
msramalho
c5a2fd45f9 log levels updated 2025-06-17 20:04:40 +01:00
msramalho
216226e7cc browsertrix version bump 2025-06-17 19:22:20 +01:00
msramalho
ad168785e7 retry for Google API 503s 2025-06-17 19:22:09 +01:00
msramalho
74a1561c3d logging and clean up 2025-06-17 19:21:40 +01:00
msramalho
55d9ffaacd typo 2025-06-17 18:51:21 +01:00
msramalho
f19fb575a7 logging updates 2025-06-17 18:50:54 +01:00
msramalho
f53b2075ba fixes gdrive error 2025-06-17 18:45:55 +01:00
Miguel Sozinho Ramalho
d20486c02a Merge pull request #320 from djhmateer/v1-dm-changes
V1 dm changes including logging
2025-06-17 16:13:37 +01:00
msramalho
6085a66c58 revert metadata json renaming 2025-06-17 16:10:24 +01:00
msramalho
33cca734d9 original_url changes still constitute empty result 2025-06-17 16:06:25 +01:00
msramalho
2f1a07abbf renaming and code improvements to json_e richer 2025-06-17 16:06:04 +01:00
msramalho
664ee8d037 fixes bugs and limited configuration of multi-level logs 2025-06-17 14:10:46 +01:00
msramalho
1b260788de do not add commit comments to code 2025-06-17 13:18:12 +01:00
msramalho
f0b876e67c removes dev specific instructions 2025-06-17 13:16:36 +01:00
msramalho
8067da0f60 custom user to its own file 2025-06-17 13:15:13 +01:00
Miguel Sozinho Ramalho
6f949738a3 Merge branch 'dev' into v1-dm-changes 2025-06-17 13:05:34 +01:00
msramalho
1b6d85884b complements authentication changes 2025-06-17 12:54:43 +01:00
msramalho
7ab804d163 dependencies update 2025-06-17 12:50:35 +01:00
Dave Mateer
b3adc5603a metadata.json hardcode in storage. add new metadata_json_enricher. log level change in orchestrator 2025-06-17 09:51:19 +01:00
Dave Mateer
ba3f1a52e8 Logging each_level_in_separate_file feature 2025-06-16 16:15:54 +01:00
Dave Mateer
a60d800b31 Changed log level for media 2025-06-16 15:07:39 +01:00
Dave Mateer
f2e80758a7 typo on authentication docs. Updated install docs. 2025-06-16 14:59:55 +01:00
Dave Mateer
f07fdbc500 Custom local version comment in toml file 2025-06-16 14:54:15 +01:00
Dave Mateer
b236f2510d Updates to installation docs 2025-06-16 14:40:40 +01:00
Dave Mateer
529d8b60bf Gitgnore to include launch.json and installtion docs to include build script. 2025-06-16 14:37:21 +01:00
msramalho
cd6a2b6031 generic_extractor download tests adaptations 2025-06-11 20:05:35 +01:00
msramalho
dfb361e3a0 reset generic_extractor description in result 2025-06-11 19:55:54 +01:00
Miguel Sozinho Ramalho
3d31c7605b Merge pull request #319 from bellingcat/feat/linkedin-antibot
Antibot Dropin for Linkedin
2025-06-11 19:42:38 +01:00
msramalho
d7a48e465b fix copypasta 2025-06-11 18:04:49 +01:00
msramalho
aaa9ead39d adds documentation for dropins 2025-06-11 17:58:53 +01:00
msramalho
f5be7a50c1 Testing Linkedin Dropin for Antibot 2025-06-11 16:52:03 +01:00
msramalho
2adcf231f7 new LinkedIn Dropin for Antibot 2025-06-11 16:51:52 +01:00
msramalho
cd19181d8f minor improvements 2025-06-11 16:51:42 +01:00
msramalho
b60469767a more flexibility to antibot dropins media finding process 2025-06-11 16:51:22 +01:00
msramalho
d60d02c16e improves download_from_url 2025-06-11 16:50:31 +01:00
msramalho
e567bba6f9 improves docs for how-to and migrations 2025-06-11 13:37:03 +01:00
msramalho
3cf51dd874 adds tracker remove feature and tests 2025-06-11 11:56:42 +01:00
msramalho
69ddb72146 separate reddit tests 2025-06-11 11:27:11 +01:00
msramalho
1039e9631f new reddit tests with .env.test 2025-06-11 11:22:23 +01:00
Miguel Sozinho Ramalho
79f42c3c41 Merge pull request #318 from bellingcat/feat/antibot-reddit
Adds RedditDropin and other flow improvements
2025-06-10 18:39:34 +01:00
msramalho
8314833ae8 removes exclude_media_extensions option 2025-06-10 18:34:33 +01:00
msramalho
6279610a43 updates docs 2025-06-10 18:28:45 +01:00
msramalho
fc89d96517 escape sequence 2025-06-10 18:04:33 +01:00
msramalho
54fda9cad4 antibot in docker uses a different user_data_dir 2025-06-10 18:04:27 +01:00
msramalho
71636233cb adds migration information and VkDropin info. 2025-06-10 17:07:10 +01:00
msramalho
fdbe96f2e4 vk and reddit should work without credentials but log the error 2025-06-10 16:44:14 +01:00
msramalho
22bd8727df python dependencies bump 2025-06-10 16:43:55 +01:00
msramalho
499c272260 dependabot switch to monthly 2025-06-10 16:37:52 +01:00
Miguel Sozinho Ramalho
f232bc45b8 Merge pull request #315 from bellingcat/dependabot/docker/webrecorder/browsertrix-crawler-1.6.2
Bump webrecorder/browsertrix-crawler from 1.6.1 to 1.6.2
2025-06-10 16:34:30 +01:00
msramalho
4270e06728 npm update on scripts/settings 2025-06-10 16:33:47 +01:00
msramalho
ca00aa302d version bump breaking 2025-06-10 16:31:32 +01:00
msramalho
773fa82f06 introduces reddit dropin 2025-06-10 16:31:19 +01:00
msramalho
ef0e909a72 extractor to auto detect best quality 2025-06-10 16:29:35 +01:00
msramalho
6bbc7fb47a improves antibot flow and makes auth_wall detection optional 2025-06-10 16:29:07 +01:00
msramalho
809b8c7749 default dropin introduced 2025-06-10 16:14:42 +01:00
msramalho
6d82655cc4 manifest improvement for antibot 2025-06-10 16:14:34 +01:00
msramalho
6bd493a791 dropin with new ytdlp feature and helper method 2025-06-10 16:11:55 +01:00
msramalho
287e823f43 improves twitter URL cleaning and introduces another bestquality check 2025-06-10 16:09:38 +01:00
msramalho
c815488daa adds new URLs to ignore 2025-06-10 15:44:52 +01:00
dependabot[bot]
f53e34d6bd Bump webrecorder/browsertrix-crawler from 1.6.1 to 1.6.2
Bumps webrecorder/browsertrix-crawler from 1.6.1 to 1.6.2.

---
updated-dependencies:
- dependency-name: webrecorder/browsertrix-crawler
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-09 20:55:07 +00:00
Miguel Sozinho Ramalho
4cfbc3008b Merge pull request #313 from bellingcat/feat/antibot-auth
Introduces more flexibility to the Antibot Extractor
2025-06-08 14:42:35 +01:00
msramalho
6f02493ff1 adds clips extraction to VK, though generic_extractor should still be run for those 2025-06-08 14:36:55 +01:00
msramalho
1f2d637928 minor improvements 2025-06-08 14:16:21 +01:00
msramalho
18cc05a2fe allows auth_for_site to receive do.main directly 2025-06-08 14:16:12 +01:00
msramalho
c96fd71f35 minor cleanup 2025-06-07 20:06:53 +01:00
msramalho
b3183510ea installs ffmpeg in GH actions 2025-06-07 20:03:26 +01:00
msramalho
d13a5ef003 adds tests in minor improvements 2025-06-07 19:58:18 +01:00
msramalho
48c1ab3c1f doc improvements 2025-06-07 19:14:16 +01:00
msramalho
b2ee42ee95 adds the first antibot dropin: VKontakte 2025-06-07 19:10:01 +01:00
msramalho
07ff5baf07 adds Dropin flexible integration for antibot 2025-06-07 19:09:37 +01:00
msramalho
d202d79e0f lint 2025-06-07 19:06:14 +01:00
msramalho
e2e6490b49 minimal changes 2025-06-07 18:15:21 +01:00
msramalho
952487da30 adds missing bin dependency 2025-06-07 18:14:42 +01:00
msramalho
c7a84bc97a generalizes ydl info to filename method for reusing 2025-06-07 18:14:08 +01:00
msramalho
c0be41950d Merge branch 'dev' of https://github.com/bellingcat/auto-archiver into dev 2025-06-04 17:06:42 +01:00
Miguel Sozinho Ramalho
ae547ef83f Merge pull request #308 from bellingcat/dependabot/npm_and_yarn/scripts/settings/actions-a541a3dacb
Bump the actions group in /scripts/settings with 4 updates
2025-06-04 15:06:59 +01:00
msramalho
8a897cf601 minimal changes: standard naming 2025-06-04 15:06:08 +01:00
Miguel Sozinho Ramalho
14c8af5cc8 Merge pull request #310 from djhmateer/waczscreenshot bug fix
counter_screenshots to counter_warc_files in wacz_extractor so don't …
2025-06-04 15:01:12 +01:00
Miguel Sozinho Ramalho
8e2e18ef75 Merge pull request #311 from bellingcat/feat/seleniumbase
Replaces ScreenshotEnricher with AntibotExtractorEnricher, removes VkExtractor
2025-06-04 14:53:31 +01:00
msramalho
5491f3e9e7 fixing s3 storage tests 2025-06-04 14:41:00 +01:00
msramalho
264ba82ea0 finish removing screenshot_enricher references 2025-06-04 14:31:07 +01:00
msramalho
05231445d9 removes unnecessary ignored files 2025-06-04 14:19:25 +01:00
msramalho
2c6be4447f linting 2025-06-04 14:17:38 +01:00
msramalho
5f68c151a0 removes webdriver utils used by screenshot enricher 2025-06-04 14:17:19 +01:00
msramalho
6d2aec032f Merge remote-tracking branch 'origin/main' into dev 2025-06-04 14:15:14 +01:00
msramalho
bc8cf2fb29 minor TODO 2025-06-04 14:10:19 +01:00
msramalho
f066111d49 removes geckodriver dependencies following screenshot enricher removal 2025-06-04 14:09:13 +01:00
msramalho
e6f3826a3a dropping screenshot enricher 2025-06-04 12:08:59 +01:00
msramalho
e5a78a5d06 antibot can be used out of the box 2025-06-04 12:01:42 +01:00
msramalho
258fb4faaf visual HTML preview improvements 2025-06-04 12:00:40 +01:00
msramalho
5ec00f7811 adds dependencies for seleniumbase 2025-06-04 12:00:22 +01:00
msramalho
22408e2a98 adds test for antibot 2025-06-04 11:59:59 +01:00
msramalho
378b1a6d22 expand S3 objects content type for better preview results in non-latin languages 2025-06-04 11:53:41 +01:00
msramalho
d130c1b3fa WIP attempt at ytdlp impersonation 2025-06-04 11:53:18 +01:00
msramalho
cbd189c97d general cleanup 2025-06-04 11:53:01 +01:00
msramalho
d2e8f1a512 introduces antibot step with seleniumbase 2025-06-04 11:20:46 +01:00
msramalho
488802b632 poetry update 2025-06-04 11:08:44 +01:00
Dave Mateer
c772082f0e counter_screenshots to counter_warc_files in wacz_extractor so don't get error about add mulitple items with same id. 2025-06-03 12:34:41 +01:00
msramalho
ee68f3efee Merge remote-tracking branch 'origin/main' into feat/seleniumbase 2025-06-03 11:05:16 +01:00
dependabot[bot]
efe2a1a8b6 Bump the actions group in /scripts/settings with 4 updates
Bumps the actions group in /scripts/settings with 4 updates: [@mui/icons-material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-icons-material), [@mui/material](https://github.com/mui/material-ui/tree/HEAD/packages/mui-material), [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom).


Updates `@mui/icons-material` from 6.4.12 to 7.1.1
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v7.1.1/packages/mui-icons-material)

Updates `@mui/material` from 6.4.12 to 7.1.1
- [Release notes](https://github.com/mui/material-ui/releases)
- [Changelog](https://github.com/mui/material-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/material-ui/commits/v7.1.1/packages/mui-material)

Updates `react` from 19.0.0 to 19.1.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.0/packages/react)

Updates `react-dom` from 19.0.0 to 19.1.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.0/packages/react-dom)

---
updated-dependencies:
- dependency-name: "@mui/icons-material"
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: "@mui/material"
  dependency-version: 7.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: react
  dependency-version: 19.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: react-dom
  dependency-version: 19.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 20:21:07 +00:00
Miguel Sozinho Ramalho
6735fa890b v1.0.1 dependency updates, generic extractor improvements (#307)
* wacz: allow exceptional cases where more than one resource image is available

* improves generic extractor edge-cases and yt-dlp updates

* REMOVES vk_extractor until further notice

* bumps browsertrix in docker image

* npm version bump on scripts/settings

* poetry updates

* Changed log level on gsheet_feeder_db started from warning to info (#301)

* closes 305 and further fixes finding local downloads from uncommon ytdlp extractors

* use ffmpeg -bitexact to reduce duplicate content storing

* formatting

* adds yt-dlp curl-cffi

* version bump

* linting

---------

Co-authored-by: Dave Mateer <davemateer@gmail.com>
2025-06-02 20:57:12 +01:00
msramalho
69028588b3 linting 2025-06-02 20:04:34 +01:00
msramalho
b351a33593 version bump 2025-06-02 20:03:48 +01:00
msramalho
87e1cdc102 adds yt-dlp curl-cffi 2025-06-02 20:02:35 +01:00
msramalho
4170c2011c formatting 2025-06-02 19:33:55 +01:00
msramalho
dd4e372703 use ffmpeg -bitexact to reduce duplicate content storing 2025-06-02 19:33:53 +01:00
msramalho
b9f7927a3b closes 305 and further fixes finding local downloads from uncommon ytdlp extractors 2025-06-02 19:14:09 +01:00
msramalho
d99b7c9efe Merge remote-tracking branch 'origin/main' into dev 2025-06-02 13:25:34 +01:00
Dave Mateer
48be13fb2a catch for if self.comments are true but no actual comments in video (#303)
* catch for if self.comments are true but no actual comments in video

* simplifies check code

---------

Co-authored-by: Miguel Sozinho Ramalho <19508417+msramalho@users.noreply.github.com>
2025-06-02 13:02:19 +01:00
Dave Mateer
4aae5047f5 Changed log level on gsheet_feeder_db started from warning to info (#301) 2025-06-02 12:53:21 +01:00
msramalho
258e56aa26 poetry updates 2025-06-02 12:52:26 +01:00
msramalho
9ad6213efa npm version bump on scripts/settings 2025-06-02 12:47:31 +01:00
msramalho
2f36e50e0b bumps browsertrix in docker image 2025-06-02 12:06:14 +01:00
msramalho
2d7206f99d REMOVES vk_extractor until further notice 2025-06-02 12:06:02 +01:00
msramalho
ac24fd8f49 improves generic extractor edge-cases and yt-dlp updates 2025-06-02 12:03:51 +01:00
msramalho
ee3e871dd8 wacz: allow exceptional cases where more than one resource image is available 2025-05-28 11:53:29 +01:00
msramalho
e6fdef66df improves instructions on docker setup with an example URL 2025-04-28 11:16:01 +01:00
msramalho
5cf640af8a experiments with seleniumbase 2025-04-28 11:08:00 +01:00
Miguel Sozinho Ramalho
33cacd145f Update tests-download.yaml
to shift ownership of notifications
2025-04-07 21:15:18 +01:00
Miguel Sozinho Ramalho
0f69b5fe0c update repo badges 2025-03-31 16:19:29 +01:00
Erin Clark
ad2e8397b2 Merge pull request #287 from bellingcat/fix/insta_tbot_empty
Only return success for instagram_tbot_extractor.py with content.
2025-03-31 14:31:46 +01:00
erinhmclark
144adaad5b Only return success for instagram_tbot_extractor.py with content. 2025-03-31 14:14:36 +01:00
Erin Clark
c7c7eb00a1 Merge pull request #286 from bellingcat/version_comparison
Small code fixes and GH Actions cache
2025-03-31 12:40:42 +01:00
erinhmclark
7e4ba62918 Small code change 2025-03-31 12:05:39 +01:00
erinhmclark
9c2b506189 update runner os to matrix os. 2025-03-31 12:00:24 +01:00
erinhmclark
8940580638 Add poetry cache clear, and small code change 2025-03-31 11:41:26 +01:00
erinhmclark
c2821d7c83 Fix poetry install deletion 2025-03-31 11:25:51 +01:00
erinhmclark
a590647279 Small code tidy to trigger tests. 2025-03-31 11:23:49 +01:00
erinhmclark
1edfdae03e Update download tests to match cache process. 2025-03-31 11:17:40 +01:00
erinhmclark
6c7f6af4b4 Add cache action with key to OS, py version and lockfile hash, and install packages from source. 2025-03-31 11:11:56 +01:00
Erin Clark
8685b6bf13 Merge pull request #285 from bellingcat/fix-ubuntu-22
[WIP] Change order of poetry install - in case this fixes core tests
2025-03-28 15:38:03 +00:00
Patrick Robertson
0ce7f5a1b5 Disable caching 2025-03-28 18:40:02 +04:00
Patrick Robertson
85d3f2fa02 Revert changes 2025-03-28 18:36:11 +04:00
Patrick Robertson
fd540bd03a Code change to trigger tests 2025-03-28 18:29:59 +04:00
Patrick Robertson
86f328515c Use cache key that includes os version 2025-03-28 18:29:52 +04:00
erinhmclark
68992025b0 Update version comparison. 2025-03-28 14:29:44 +00:00
Patrick Robertson
6544934825 Merge pull request #283 from bellingcat/1.0-release
v1.0.0 release 🎉
2025-03-28 18:06:59 +04:00
Patrick Robertson
197599b406 Merge pull request #284 from bellingcat/revert-downloads-test
Revert downloads CI tests changes
2025-03-28 18:06:49 +04:00
Erin Clark
96efdcbba1 Merge pull request #281 from bellingcat/add_inst_api_script
Add InstagrAPI server script to be used with the Instagram API Extractor.
2025-03-28 13:58:37 +00:00
Patrick Robertson
2ec494b4b9 Revert downloads CI tests changes
It wasn't properly being triggered after the core tests. this reverts so that the download tests just run whatever
2025-03-28 17:55:58 +04:00
Erin Clark
1d18399d70 Merge pull request #222 from bellingcat/feat/yt-dlp-pots
yt-dlp proposed extractor_args and PO Token client.
2025-03-28 13:54:27 +00:00
Patrick Robertson
3550a009e6 v1.0.0 release 🎉 2025-03-28 13:53:29 +00:00
erinhmclark
dd7d85b4b4 Lock 2025-03-28 13:47:18 +00:00
erinhmclark
c510c04643 Update config reference in test_generic_extractor.py 2025-03-28 13:43:46 +00:00
erinhmclark
a0d955fe84 lock 2025-03-28 13:39:58 +00:00
erinhmclark
5e7c57650b Update "default" to "auto" for clarity, update docs 2025-03-28 13:16:16 +00:00
erinhmclark
1db7d6702d Update the documentation 2025-03-28 12:27:18 +00:00
erinhmclark
b1a8792f9f Remove duplicate line 2025-03-28 11:44:37 +00:00
erinhmclark
f715100dd5 Add run_instagrapi_server.sh and update docs 2025-03-28 11:31:23 +00:00
erinhmclark
dbcf19d1b8 Update update path reference 2025-03-28 10:55:21 +00:00
erinhmclark
0840b7283c Format 2025-03-28 10:43:00 +00:00
erinhmclark
b5dc1854a2 Merge branch 'main' into feat/yt-dlp-pots 2025-03-28 10:42:24 +00:00
erinhmclark
efab0f9a91 Add test 2025-03-28 10:37:22 +00:00
erinhmclark
bc35116975 Update poetry.lock 2025-03-28 10:37:13 +00:00
Patrick Robertson
25f1f5dc93 Merge pull request #279 from bellingcat/telethon_tweaks
Fix calling extractor.cleanup (fixes telethon issue) + tidy up telethon extractor session file naming
2025-03-28 14:13:26 +04:00
erinhmclark
f99dcc63a1 Minor updates 2025-03-28 09:46:44 +00:00
Patrick Robertson
48fbfc3b86 Merge pull request #280 from bellingcat/download-tests
Download tests
2025-03-28 13:33:30 +04:00
Erin Clark
e7aae76ffe Merge pull request #271 from bellingcat/dependabot/github_actions/actions-7fa5136294
Bump the actions group with 3 updates
2025-03-28 09:33:25 +00:00
erinhmclark
1466700b45 Small updates to docs, poetry.lock 2025-03-28 08:23:10 +00:00
erinhmclark
00b29db390 Update documentaion for instagrapi api 2025-03-28 00:41:35 +00:00
erinhmclark
2a0dfaead2 Add instagrapi server scripts 2025-03-28 00:41:05 +00:00
Patrick Robertson
a448e2532c Code tweak for clarity 2025-03-27 15:20:52 +04:00
Patrick Robertson
46a51cce11 Fix up tests-download to properly run once core tests completed 2025-03-27 15:18:58 +04:00
Patrick Robertson
b7949a489f Simplify telethon unit tests for CI (don't use TestExtractorBase - it causes loading issues) 2025-03-26 23:51:21 +04:00
Patrick Robertson
e0e9f93065 Skip update checks for ytdlp when running tests 2025-03-26 23:41:20 +04:00
Patrick Robertson
e06b0c0585 Skip checking if docker is running for tests + more graceful test for filename 2025-03-26 23:03:48 +04:00
Patrick Robertson
95ea9fb231 Telethon unit tests + tidyup 2025-03-26 22:53:27 +04:00
Patrick Robertson
17d2d14680 Fix running 'cleanup' method on extractors that fail to start 2025-03-26 22:52:52 +04:00
erinhmclark
f54b5c5f18 Update poetry.lock 2025-03-26 18:05:04 +00:00
erinhmclark
456b2746c8 Update the docs 2025-03-26 18:01:20 +00:00
erinhmclark
2cad5edea8 Fix default config 2025-03-26 17:33:00 +00:00
Patrick Robertson
580de88366 Set the new session filename *before* copying
Fixes a potential bug whereby if the copy fails for some reason, the 'cleanup' command would remove the original session file
2025-03-26 21:32:23 +04:00
erinhmclark
093ce34a6a Ruff format. 2025-03-26 17:02:20 +00:00
erinhmclark
7872d9356c Merge branch 'main' into feat/yt-dlp-pots 2025-03-26 17:00:38 +00:00
erinhmclark
23e7dd0995 Remove old implementaion 2025-03-26 17:00:31 +00:00
erinhmclark
565275ac37 Basic documentation for POT process 2025-03-26 16:59:01 +00:00
erinhmclark
4a02407659 Typo fix. 2025-03-26 16:46:21 +00:00
erinhmclark
ae523eb06f Udpate PO token generation script method 2025-03-26 16:45:29 +00:00
erinhmclark
d87c0dc3a9 Implement update for pot plugin. 2025-03-26 16:02:29 +00:00
dependabot[bot]
1612fef59b Bump the actions group with 3 updates
Bumps the actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [docker/login-action](https://github.com/docker/login-action) and [docker/metadata-action](https://github.com/docker/metadata-action).


Updates `actions/checkout` from 3 to 4
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

Updates `docker/login-action` from 3.3.0 to 3.4.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](9780b0c442...74a5d14239)

Updates `docker/metadata-action` from 5.6.1 to 5.7.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](369eb591f4...902fa8ec7d)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-26 12:09:25 +00:00
Patrick Robertson
fbf51f61b9 Merge pull request #276 from bellingcat/actions_updates
Only run 'download' actions once core completes + re-add `ubuntu-latest` to matrix
2025-03-26 12:08:13 +00:00
Patrick Robertson
a9ff55a36e Merge pull request #278 from bellingcat/dependabot_fix
This force-pins cryptography to >44.0.1 to fix dependabot warning
2025-03-26 11:57:35 +00:00
Patrick Robertson
20bc80b9ef Slightly more consistent/tidier naming for the session files
Don't add/remove .session from name, keep the file name without .session at all times
2025-03-26 15:57:11 +04:00
Patrick Robertson
5bb0cbf3ff Lock poetry file 2025-03-26 15:43:03 +04:00
Patrick Robertson
3eb9ffddfe This force-pins cryptography to >44.0.1 to fix dependabot warning
pyOpenSSL also no longer needed
2025-03-26 15:39:53 +04:00
Patrick Robertson
76e90dd23a Small code tidy ups 2025-03-26 15:34:33 +04:00
Patrick Robertson
0450d3fcb9 Merge branch 'main' into actions_updates 2025-03-26 15:29:38 +04:00
Patrick Robertson
e9ee4d67ba Re-add 'ubuntu-latest' - now that we're rid of tsp_client 2025-03-26 15:29:36 +04:00
Patrick Robertson
43a80dbcda Merge pull request #224 from bellingcat/timestamping_rewrite
Timestamping rewrite
2025-03-26 11:25:55 +00:00
Patrick Robertson
cb3ae055d6 Also remove certvalidator from poetry/project 2025-03-26 15:11:25 +04:00
Patrick Robertson
4cfa6455c7 Only make the downloads action run if the core action was successful 2025-03-26 15:07:57 +04:00
Patrick Robertson
0073a08525 Update manifest dependencies to remove tsp_client et al. 2025-03-26 14:57:55 +04:00
Patrick Robertson
46e31808f6 Version bump 2025-03-26 14:54:33 +04:00
Patrick Robertson
4af23e13d1 Bump rfc3161-client to 1.0.1 2025-03-26 14:50:12 +04:00
Patrick Robertson
d6be1ff84f Merge branch 'main' into timestamping_rewrite 2025-03-26 14:37:51 +04:00
erinhmclark
633290a9cc Update for pot providers list 2025-03-25 18:27:06 +00:00
erinhmclark
040a864d5c Merge branch 'refs/heads/main' into feat/yt-dlp-pots
# Conflicts:
#	poetry.lock
2025-03-25 18:26:43 +00:00
erinhmclark
b4c33318c4 Merge branch 'main' into feat/yt-dlp-pots
# Conflicts:
#	src/auto_archiver/modules/generic_extractor/__manifest__.py
#	tests/test_modules.py
2025-03-25 15:16:31 +00:00
Patrick Robertson
74974ef0ed Merge pull request #268 from bellingcat/minor_improvements
Minor improvements
2025-03-25 12:52:08 +00:00
Patrick Robertson
5c6005d843 Merge pull request #269 from bellingcat/update-dependabot
Add explicit dependabots for pip/poetry, GH actions and npm
2025-03-25 06:30:24 +00:00
Patrick Robertson
d6a7f31248 Add note that authentication only works for some modules 2025-03-24 18:28:35 +04:00
Patrick Robertson
8aba663534 Update node module versions 2025-03-24 18:28:30 +04:00
Patrick Robertson
ace97ac7fd Don't run ruff on non-python file changes 2025-03-24 18:00:14 +04:00
Patrick Robertson
ad373ae733 Add explicit dependabots for pip/poetry, GH actiona and npm 2025-03-24 17:57:53 +04:00
Patrick Robertson
260e76dd3d Update dependencies 2025-03-24 17:48:25 +04:00
Patrick Robertson
a9fe959ea1 Fix unit tests for latest yt-dlp
(Yt-dlp title is now truncated)
2025-03-24 17:48:15 +04:00
Patrick Robertson
beb7f3893d Add comments/notes to WACZ enricher about browser profiles 2025-03-24 17:39:47 +04:00
Patrick Robertson
5055402c2a Bump browsertrix version 2025-03-24 17:39:44 +04:00
Patrick Robertson
3c4625d708 Further ruff tweaks 2025-03-24 16:39:59 +04:00
Patrick Robertson
31fa7380f5 Fix up unit tests + issue when working with self-signed certs 2025-03-24 16:00:40 +04:00
Patrick Robertson
396ec03bae Tidy up unit tests further + make more non-download 2025-03-24 15:26:22 +04:00
Patrick Robertson
e811196711 Ruff fixes 2025-03-24 15:10:46 +04:00
Patrick Robertson
dfde6f1995 Merge main into timestamping_enricher 2025-03-24 15:09:29 +04:00
Miguel Sozinho Ramalho
7b454baa02 Create dependabot.yml 2025-03-24 10:49:36 +00:00
Patrick Robertson
0f9c6a9a5c Update yt-dlp to latest 2025-03-24 14:49:18 +04:00
Patrick Robertson
c980500978 Actually restart AA after updating yt-dlp.
A simple 'importlib.reload()' doesn't take into account all imports
2025-03-24 14:33:59 +04:00
erinhmclark
93921e71d4 Clarify comments in pot scripts. 2025-03-19 11:33:35 +00:00
erinhmclark
675de50ee7 Update module test to test for default config keys within loaded 2025-03-19 10:47:28 +00:00
erinhmclark
fc6946f78a Run format. 2025-03-18 21:43:18 +00:00
erinhmclark
2fdf6b7564 Update generic_extractor.py for general/ youtube extraction. 2025-03-18 21:33:21 +00:00
erinhmclark
ba9d67e4bb Merge branch 'main' into feat/yt-dlp-pots 2025-03-18 20:10:38 +00:00
erinhmclark
c4e63ebd8c Add conditional check to setup bgutils token generation script.
TODO: Update tests
2025-03-18 14:54:57 +00:00
erinhmclark
b83bfda187 Update directory location, add .gitignore 2025-03-18 14:10:20 +00:00
erinhmclark
cb632723bd Add scripts to pull only /server/ section of pots generator, adn only install at runtime. 2025-03-18 13:47:01 +00:00
erinhmclark
0c892f3cf1 Temp fix for tests by setting path in manifest. 2025-03-18 11:44:08 +00:00
erinhmclark
43ef8f2aeb Add update to POT setup script. 2025-03-17 20:59:34 +00:00
erinhmclark
e6b1a8c893 Add POT setup script. 2025-03-17 20:34:00 +00:00
erinhmclark
8548b7def7 Refactor setup method to pull and transpile the token generator. 2025-03-17 18:53:59 +00:00
erinhmclark
bbe25537c7 Merge branch 'main' into feat/yt-dlp-pots 2025-03-17 16:54:29 +00:00
erinhmclark
5daeae994a Fix the extractor args for new list structure. 2025-03-17 14:17:31 +00:00
erinhmclark
f5bbfe5d1c Merge branch 'main' into feat/yt-dlp-pots 2025-03-17 10:43:35 +00:00
Patrick Robertson
89ee6f19b6 List out all valid TSAs + option for users to allow self signed if they want 2025-03-11 16:12:13 +00:00
Patrick Robertson
294033f156 Fix bug ordering tsr that only have one cert + more unit tests 2025-03-11 15:44:04 +00:00
Patrick Robertson
2ffe124d95 Add unit test for invalid digicert tsrs 2025-03-11 11:13:36 +00:00
Patrick Robertson
1db8be91db Improved unit tests for timestamping 2025-03-11 11:08:52 +00:00
Patrick Robertson
3f6acc0917 fully working timestamping enricher 2025-03-11 10:04:46 +00:00
erinhmclark
76bb1496c8 Merge branch 'main' into feat/yt-dlp-pots
# Conflicts:
#	src/auto_archiver/modules/generic_extractor/__manifest__.py
2025-03-07 16:54:01 +00:00
erinhmclark
7e4b44883b Add temp options for testing 2025-03-04 14:03:39 +00:00
erinhmclark
77b517cfc1 Merge remote-tracking branch 'origin/feat/yt-dlp-pots' into feat/yt-dlp-pots 2025-03-03 22:02:14 +00:00
erinhmclark
2c1753e14b Added Bgutils PO token provider. 2025-03-03 21:11:41 +00:00
erinhmclark
dd07b0b830 Allow flexible extractor_args in generic_extractor.py. 2025-03-03 21:11:34 +00:00
erinhmclark
0eae2bee6a Add yt-dlp-get-pot and yt-dlp-getpot-wpc requirements. 2025-03-03 21:08:00 +00:00
Patrick Robertson
a0869bb3b2 Fixed up timestamp verifying - waiting on issue with rfc-client to be fixed
Ref: https://github.com/trailofbits/rfc3161-client/issues/104#issuecomment-2693890607
2025-03-03 10:28:30 +00:00
Patrick Robertson
afc117a229 Get downloading certs working 2025-02-26 09:33:56 +00:00
Patrick Robertson
4dcb77c29f Merge branch 'main' into timestamping_rewrite 2025-02-25 17:10:55 +00:00
Patrick Robertson
898faf6fe4 Further WIP - currently working on verify_signed 2025-02-25 12:08:08 +00:00
Patrick Robertson
6987a4827e Set poetry packages - remove tsp_client and update cryptography 2025-02-25 11:57:20 +00:00
erinhmclark
2d4f1b5b79 Added Bgutils PO token provider. 2025-02-25 10:49:57 +00:00
Patrick Robertson
01bf88a695 Merge branch 'main' into timestamping_rewrite 2025-02-24 12:03:14 +00:00
erinhmclark
c5127f5fd1 Allow flexible extractor_args in generic_extractor.py. 2025-02-24 11:40:44 +00:00
erinhmclark
158d448cbc Add yt-dlp-get-pot and yt-dlp-getpot-wpc requirements. 2025-02-24 11:40:39 +00:00
Patrick Robertson
d0c379a3ba WIP - timestamping enricher 2025-02-11 18:18:19 +00:00
Patrick Robertson
3163cb793a Fix timestamping enricher for new module structure (temp paths) 2025-02-11 15:26:40 +00:00
Patrick Robertson
7bb4d68a22 Merge branch 'load_modules' into timestamping_rewrite 2025-02-11 15:21:31 +00:00
Patrick Robertson
4c1c8953ca Add unit tests for timestamping_enricher 2025-01-29 12:20:52 +01:00
166 changed files with 9756 additions and 3522 deletions

40
.github/dependabot.yml vendored Normal file
View 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"

View File

@@ -22,30 +22,30 @@ 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
uses: docker/setup-qemu-action@v4
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
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@030e881283bb7a6894de51c315a6bfe6a94e05cf
with:
images: bellingcat/auto-archiver
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -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

View File

@@ -3,18 +3,28 @@ 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@v4
- uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.11"
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@@ -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

View File

@@ -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 || '' }}

5
.gitignore vendored
View File

@@ -1,6 +1,7 @@
tmp*/
temp/
.env*
!.env*.example
.DS_Store
expmt/
service_account.json
@@ -37,3 +38,7 @@ docs/source/modules/autogen/
scripts/settings_page.html
scripts/settings/src/schema.json
.vite
downloaded_files
latest_logs
# for launch.json
.vscode

View File

@@ -7,6 +7,8 @@ version: 2
build:
os: ubuntu-22.04
apt_packages:
- ffmpeg
tools:
python: "3.10"
nodejs: "22"

View File

@@ -1,36 +1,17 @@
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 \
LC_ALL=C.UTF-8 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONFAULTHANDLER=1 \
PATH="/root/.local/bin:$PATH"
PYTHONFAULTHANDLER=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 +40,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)

View File

@@ -1,12 +1,13 @@
<h1 align="center">Auto Archiver</h1>
[![Documentation Status](https://readthedocs.org/projects/auto-archiver/badge/?version=latest)](https://auto-archiver.readthedocs.io/en/latest/?badge=latest)
[![PyPI version](https://badge.fury.io/py/auto-archiver.svg)](https://badge.fury.io/py/auto-archiver)
[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/bellingcat/auto-archiver?label=version&logo=docker)](https://hub.docker.com/r/bellingcat/auto-archiver)
[![Docker Image Version (latest by date)](https://img.shields.io/docker/v/bellingcat/auto-archiver?sort=semver&logo=docker&color=#69F0AE)](https://hub.docker.com/r/bellingcat/auto-archiver)
[![Core Test Status](https://github.com/bellingcat/auto-archiver/workflows/Core%20Tests/badge.svg)](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-core.yaml)
[![Download Test Status](https://github.com/bellingcat/auto-archiver/workflows/Download%20Tests/badge.svg)](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-download.yaml)
<!-- [![Download Test Status](https://github.com/bellingcat/auto-archiver/workflows/Download%20Tests/badge.svg)](https://github.com/bellingcat/auto-archiver/actions/workflows/tests-download.yaml) -->
<!-- ![Docker Pulls](https://img.shields.io/docker/pulls/bellingcat/auto-archiver) -->
<!-- [![PyPI download month](https://img.shields.io/pypi/dm/auto-archiver.svg)](https://pypi.python.org/pypi/auto-archiver/) -->
<!-- [![Documentation Status](https://readthedocs.org/projects/vk-url-scraper/badge/?version=latest)](https://vk-url-scraper.readthedocs.io/en/latest/?badge=latest) -->

View File

@@ -6,6 +6,9 @@ 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

View File

@@ -47,7 +47,6 @@ 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"]:
modules_by_type.setdefault(type, []).append(module)
@@ -64,6 +63,27 @@ def generate_module_docs():
"""
steps_str = "\n".join(f" {t}s:\n - {module.name}" for t in manifest["type"])
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:

View File

@@ -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}
@@ -33,4 +33,4 @@ docs
release
settings_page
style_guide
```
```

View File

@@ -50,7 +50,7 @@ Note not all warnings can be fixed automatically.
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-fix
make ruff-clean
```
**Changing Configurations ⚙️**
@@ -67,4 +67,4 @@ One example is to extend the selected rules for linting the `pyproject.toml` fil
extend-select = ["B"]
```
Then re-run the `make ruff-check` command to see the new rules in action.
Then re-run the `make ruff-check` command to see the new rules in action.

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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.

View 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
```

View 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.

View 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.

View File

@@ -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

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -11,7 +11,6 @@ are available on the [extractors](../modules/extractor.md) page. Some sites supp
* Twitter
* Instagram
* Telegram
* VKontact
* Tiktok
* Bluesky

View File

@@ -51,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

View File

@@ -27,8 +27,8 @@ 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 -it --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)
@@ -42,6 +42,7 @@ breaking this command down:
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

View File

@@ -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
```

3965
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[project]
name = "auto-archiver"
version = "0.13.8"
version = "1.2.7"
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,23 +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]
@@ -65,7 +66,7 @@ pytest = "^8.3.4"
autopep8 = "^2.3.1"
pytest-loguru = "^0.4.0"
pytest-mock = "^3.14.0"
ruff = "^0.9.10"
ruff = "^0.15.2"
pre-commit = "^4.1.0"
[tool.poetry.group.docs.dependencies]

2
scripts/instagrapi_server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
secrets*
*instagrapi_session.json

View 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"]

View 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"

View 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."

View 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)

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@
"@dnd-kit/sortable": "^10.0.0",
"@emotion/react": "latest",
"@emotion/styled": "latest",
"@mui/icons-material": "^6.4.7",
"@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"
},

View File

@@ -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';

View File

@@ -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';

View File

@@ -14,7 +14,7 @@ You will need to provide your phone number and a 2FA code the first time you run
import os
from telethon.sync import TelegramClient
from loguru import logger
from auto_archiver.utils.custom_logger import logger
# Create a
@@ -24,4 +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")

View File

@@ -7,7 +7,7 @@ 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
@@ -98,12 +98,11 @@ class BaseModule(ABC):
"""
# 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).removeprefix("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
@@ -111,9 +110,9 @@ 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:
if key in domain or domain in key:
logger.debug(
f"Could not find exact authentication information for site '{site}'. \
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."
)

View File

@@ -10,7 +10,7 @@ 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
@@ -118,8 +118,7 @@ class DefaultValidatingParser(argparse.ArgumentParser):
"""
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):
@@ -136,8 +135,7 @@ 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)

View File

@@ -8,14 +8,16 @@ Factory method to initialize an extractor instance based on its name.
from __future__ import annotations
from abc import abstractmethod
from contextlib import suppress
import mimetypes
import os
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):
@@ -70,17 +72,29 @@ 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
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]
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=}")
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"
}
@@ -98,10 +112,14 @@ class Extractor(BaseModule):
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:

View File

@@ -11,7 +11,7 @@ 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
@@ -86,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]
@@ -116,13 +116,12 @@ class Media:
# 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}")
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 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

View File

@@ -11,13 +11,14 @@ Key Functionalities:
from __future__ import annotations
import hashlib
import os
from typing import Any, List, Union, Dict
from dataclasses import dataclass, field
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
@@ -96,7 +97,7 @@ class Metadata:
def is_empty(self) -> bool:
meaningfull_ids = set(self.metadata.keys()) - set(
["_processed_at", "url", "total_bytes", "total_size", "archive_duration_seconds"]
["_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
@@ -181,8 +182,14 @@ class Metadata:
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:
if not os.path.exists(m.filename):
logger.warning(f"Skipping missing media file: {m.filename}")
continue
h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename)
if len(h) and h in media_hashes:
continue

View File

@@ -16,7 +16,7 @@ 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, SetupError
@@ -214,11 +214,8 @@ class LazyBaseModule:
# 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):
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."
@@ -277,6 +274,9 @@ class LazyBaseModule:
# 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
@@ -289,8 +289,6 @@ class LazyBaseModule:
instance.config_setup(config)
instance.setup()
# save the instance for future easy loading
self._instance = instance
return instance
def __repr__(self):

View File

@@ -5,6 +5,7 @@ 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,9 +15,11 @@ 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 (
@@ -33,7 +36,7 @@ from .config import (
from .module import ModuleFactory, LazyBaseModule
from . import validators, Feeder, Extractor, Database, Storage, Formatter, Enricher
from .consts import MODULE_TYPES, SetupError
from auto_archiver.utils.url import check_url_or_raise
from auto_archiver.utils.url import check_url_or_raise, clean
if TYPE_CHECKING:
from .base_module import BaseModule
@@ -248,7 +251,7 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
action="store",
dest="logging.level",
choices=["INFO", "DEBUG", "ERROR", "WARNING"],
help="the logging level to use",
help="the logging level to use for the standard output and file logging",
default="INFO",
type=str.upper,
)
@@ -263,6 +266,14 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
default=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:
@@ -332,11 +343,32 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
# 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"]
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):
"""
@@ -387,8 +419,10 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
except (KeyboardInterrupt, Exception) as e:
if not isinstance(e, KeyboardInterrupt) and not isinstance(e, SetupError):
logger.error(f"Error during setup of modules: {e}\n{traceback.format_exc()}")
if loaded_module and module_type == "extractor":
loaded_module.cleanup()
# 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:
@@ -433,19 +467,22 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
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"]
try:
response = requests.get("https://pypi.org/pypi/auto-archiver/json", timeout=10).json()
except Exception as e:
logger.debug(f"Unable to check for updates: {e}")
return
latest_version = version.parse(response["info"]["version"])
current_version = version.parse(__version__)
# check version compared to current version
if latest_version != __version__:
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):
"""
@@ -495,7 +532,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:
@@ -507,10 +544,12 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
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:
@@ -528,13 +567,13 @@ 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 isinstance(e, AssertionError):
d.failed(item, str(e))
@@ -562,16 +601,17 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
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:
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
@@ -586,25 +626,25 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
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
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)
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)
@@ -623,7 +663,7 @@ Here's how that would look: \n\nsteps:\n extractors:\n - [your_extractor_name_
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

View File

@@ -24,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

View File

@@ -4,12 +4,6 @@ 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:
raise argparse.ArgumentTypeError(f"{value} is not a positive number")

View File

@@ -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.
""",
}

View File

@@ -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}))

View File

@@ -0,0 +1 @@
*.py

View 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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
@@ -36,9 +36,9 @@ class AAApiDb(Database):
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")
logger.debug("Skipping saving archive to AA API because it was cached")
return
logger.debug(f"saving archive of {item.get_url()} to the AA API.")
logger.debug("Saving archive to the AA API.")
payload = {
"author_id": self.author_id,

View File

@@ -3,7 +3,7 @@ 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
@@ -66,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
@@ -88,7 +88,7 @@ class AtlosFeederDbStorage(Feeder, Database, Storage):
"""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",
@@ -100,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

View File

@@ -1,5 +1,3 @@
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
@@ -16,8 +14,5 @@ class CLIFeeder(Feeder):
def __iter__(self) -> Metadata:
urls = self.config["urls"]
for url in urls:
logger.debug(f"Processing {url}")
m = Metadata().set_url(url)
yield m
logger.success(f"Processed {len(urls)} URL(s)")

View File

@@ -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

View 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

View File

@@ -1,4 +1,4 @@
from loguru import logger
from auto_archiver.utils.custom_logger import logger
import csv
from auto_archiver.core import Feeder
@@ -35,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)

View File

@@ -8,7 +8,7 @@ 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
@@ -62,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
@@ -70,7 +70,7 @@ 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"
@@ -83,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:
@@ -91,15 +91,20 @@ class GDriveStorage(Storage):
parent_id = upload_to
# upload file to gd
logger.debug(f"uploading {filename=} to folder id {upload_to}")
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}")
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:
@@ -128,7 +133,7 @@ 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
@@ -163,7 +168,7 @@ class GDriveStorage(Storage):
else:
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:

View File

@@ -4,9 +4,7 @@
"author": "Bellingcat",
"type": ["extractor"],
"requires_setup": False,
"dependencies": {
"python": ["yt_dlp", "requests", "loguru", "slugify"],
},
"dependencies": {"python": ["yt_dlp", "requests", "loguru", "slugify"], "bin": ["ffmpeg"]},
"description": """
This is the generic extractor used by auto-archiver, which uses `yt-dlp` under the hood.
@@ -32,6 +30,8 @@ For a full list of video platforms supported by `yt-dlp`, see the
custom dropins can be created to handle additional websites and passed to the archiver
via the command line using the `--dropins` option (TODO!).
You can see all currently implemented dropins in [the source code](https://github.com/bellingcat/auto-archiver/tree/main/src/auto_archiver/modules/generic_extractor).
### Auto-Updates
The Generic Extractor will also automatically check for updates to `yt-dlp` (every 5 days by default).
@@ -58,11 +58,15 @@ If you are having issues with the extractor, you can review the version of `yt-d
},
"proxy": {
"default": "",
"help": "http/socks (https seems to not work atm) proxy to use for the webdriver, eg https://proxy-user:password@proxy-ip:port",
"help": "http/https/socks proxy to use for the webdriver, eg https://proxy-user:password@proxy-ip:port",
},
"proxy_on_failure_only": {
"default": True,
"help": "Applies only if a proxy is set. In that case if this setting is True, the extractor will only use the proxy if the initial request fails; if it is False, the extractor will always use the proxy.",
},
"end_means_success": {
"default": True,
"help": "if True, any archived content will mean a 'success', if False this archiver will not return a 'success' stage; this is useful for cases when the yt-dlp will archive a video but ignore other types of content like images or text only pages that the subsequent archivers can retrieve.",
"help": "if True, any archived content will mean a 'success', if False this extractor will not return a 'success' stage; this is useful for cases when the yt-dlp will archive a video but ignore other types of content like images or text only pages that the subsequent extractors can retrieve.",
"type": "bool",
},
"allow_playlist": {
@@ -74,6 +78,11 @@ If you are having issues with the extractor, you can review the version of `yt-d
"default": "inf",
"help": "Use to limit the number of videos to download when a channel or long page is being extracted. 'inf' means no limit.",
},
"bguils_po_token_method": {
"default": "auto",
"help": "Set up a Proof of origin token provider. This process has additional requirements. See [authentication](https://auto-archiver.readthedocs.io/en/latest/how_to/authentication_how_to.html) for more information.",
"choices": ["auto", "script", "disabled"],
},
"extractor_args": {
"default": {},
"help": "Additional arguments to pass to the yt-dlp extractor. See https://github.com/yt-dlp/yt-dlp/blob/master/README.md#extractor-arguments.",

View File

@@ -1,4 +1,4 @@
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core.extractor import Extractor
from auto_archiver.core.metadata import Metadata, Media
@@ -39,12 +39,18 @@ class Bluesky(GenericDropin):
media_url = "https://bsky.social/xrpc/com.atproto.sync.getBlob?cid={}&did={}"
for image_media in image_medias:
url = media_url.format(image_media["image"]["ref"]["$link"], post["author"]["did"])
image_media = archiver.download_from_url(url)
media.append(Media(image_media))
filename = archiver.download_from_url(url)
if filename:
media.append(Media(filename))
else:
logger.warning(f"Failed to download Bluesky image from {url}")
for video_media in video_medias:
url = media_url.format(video_media["ref"]["$link"], post["author"]["did"])
video_media = archiver.download_from_url(url)
media.append(Media(video_media))
filename = archiver.download_from_url(url)
if filename:
media.append(Media(filename))
else:
logger.warning(f"Failed to download Bluesky video from {url}")
return media
def _get_post_data(self, post: dict) -> dict:

View File

@@ -34,7 +34,7 @@ def _extract_metadata(self, webpage, video_id):
...,
"attachments",
...,
lambda k, v: (k == "media" and str(v["id"]) == video_id and v["__typename"] == "Video"),
lambda k, v: k == "media" and str(v["id"]) == video_id and v["__typename"] == "Video",
),
expected_type=dict,
)

View File

@@ -1,19 +1,27 @@
import shutil
import sys
import datetime
import os
import importlib
import subprocess
import traceback
import zipfile
from typing import Generator, Type
from urllib.request import urlretrieve
import yt_dlp
from yt_dlp.extractor.common import InfoExtractor
from yt_dlp.utils import MaxDownloadsReached
import pysubs2
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core.extractor import Extractor
from auto_archiver.core import Metadata, Media
from auto_archiver.utils import get_datetime_from_str
from auto_archiver.utils.misc import ydl_entry_to_filename
from auto_archiver.utils.deletion_detection import detect_deletion, flag_as_deleted
from .dropin import GenericDropin
@@ -25,45 +33,140 @@ class GenericExtractor(Extractor):
_dropins = {}
def setup(self):
# check for file .ytdlp-update in the secrets folder
self.check_for_extractor_updates()
self.setup_po_tokens()
# TODO: figure out why the following is not properly recognised by yt-dlp:
# if "generic" not in self.extractor_args:
# self.extractor_args["generic"] = "impersonate"
def check_for_extractor_updates(self):
"""Checks whether yt-dlp or its plugins need updating and triggers a restart if so."""
if self.ytdlp_update_interval < 0:
return
use_secrets = os.path.exists("secrets")
path = os.path.join("secrets" if use_secrets else "", ".ytdlp-update")
next_update_check = None
if os.path.exists(path):
with open(path, "r") as f:
next_update_check = datetime.datetime.fromisoformat(f.read())
update_file = os.path.join("secrets" if os.path.exists("secrets") else "", ".ytdlp-update")
next_check = None
if os.path.exists(update_file):
with open(update_file, "r") as f:
next_check = datetime.datetime.fromisoformat(f.read())
if not next_update_check or next_update_check < datetime.datetime.now():
self.update_ytdlp()
if next_check and next_check > datetime.datetime.now():
return
next_update_check = datetime.datetime.now() + datetime.timedelta(days=self.ytdlp_update_interval)
with open(path, "w") as f:
f.write(next_update_check.isoformat())
yt_dlp_updated = self.update_package("yt-dlp")
bgutil_updated = self.update_package("bgutil-ytdlp-pot-provider")
def update_ytdlp(self):
logger.info("Checking and updating yt-dlp...")
logger.info(
f"Tip: change the 'ytdlp_update_interval' setting to control how often yt-dlp is updated. Set to -1 to disable or 0 to enable on every run. Current setting: {self.ytdlp_update_interval}"
)
# Write the new timestamp
with open(update_file, "w") as f:
next_check = datetime.datetime.now() + datetime.timedelta(days=self.ytdlp_update_interval)
f.write(next_check.isoformat())
if yt_dlp_updated or bgutil_updated:
if os.environ.get("AUTO_ARCHIVER_ALLOW_RESTART", "1") != "1":
logger.warning("yt-dlp or plugin was updated — please restart auto-archiver manually")
else:
logger.warning("yt-dlp or plugin was updated — restarting auto-archiver\n ======= RESTARTING ======= ")
os.execv(sys.executable, [sys.executable] + sys.argv)
def update_package(self, package_name: str) -> bool:
logger.info(f"Checking and updating {package_name}...")
from importlib.metadata import version as get_version
old_version = get_version("yt-dlp")
old_version = get_version(package_name)
try:
# try and update with pip (this works inside poetry environment and in a normal virtualenv)
result = subprocess.run(["pip", "install", "--upgrade", "yt-dlp"], check=True, capture_output=True)
result = subprocess.run(["pip", "install", "--upgrade", package_name], check=True, capture_output=True)
if f"Successfully installed {package_name}" in result.stdout.decode():
new_version = importlib.metadata.version(package_name)
logger.info(f"{package_name} updated from {old_version} to {new_version}")
return True
logger.info(f"{package_name} already up to date")
except Exception as e:
logger.error(f"Failed to update {package_name}: {e}")
return False
if "Successfully installed yt-dlp" in result.stdout.decode():
new_version = importlib.metadata.version("yt-dlp")
logger.info(f"yt-dlp successfully (from {old_version} to {new_version})")
importlib.reload(yt_dlp)
def setup_po_tokens(self) -> None:
"""Setup Proof of Origin Token method conditionally.
Uses provider: https://github.com/Brainicism/bgutil-ytdlp-pot-provider.
"""
in_docker = os.environ.get("RUNNING_IN_DOCKER")
if self.bguils_po_token_method == "disabled":
# This allows disabling of the PO Token generation script in the Docker implementation.
logger.warning("Proof of Origin Token generation is disabled.")
return
if self.bguils_po_token_method == "auto" and not in_docker:
logger.info(
"Proof of Origin Token method not explicitly set. "
"If you're running an external HTTP server separately, you can safely ignore this message. "
"To reduce the likelihood of bot detection, enable one of the methods described in the documentation: "
"https://auto-archiver.readthedocs.io/en/settings_page/installation/authentication.html#proof-of-origin-tokens"
)
return
# Either running in Docker, or "script" method is set beyond this point
self.setup_token_generation_script()
def setup_token_generation_script(self) -> None:
"""This function sets up the Proof of Origin Token generation script method for
bgutil-ytdlp-pot-provider if enabled or in Docker."""
missing_tools = [tool for tool in ("node", "yarn", "npx") if shutil.which(tool) is None]
if missing_tools:
logger.error(
f"Cannot set up PO Token script; missing required tools: {', '.join(missing_tools)}. "
"Install these tools or run bgutils via Docker. "
"See: https://github.com/Brainicism/bgutil-ytdlp-pot-provider"
)
return
try:
from importlib.metadata import version as get_version
plugin_version = get_version("bgutil-ytdlp-pot-provider")
base_dir = os.path.expanduser("~/bgutil-ytdlp-pot-provider")
server_dir = os.path.join(base_dir, "server")
version_file = os.path.join(server_dir, ".VERSION")
transpiled_script = os.path.join(server_dir, "build", "generate_once.js")
# Skip setup if version is correct and transpiled script exists
if os.path.isfile(transpiled_script) and os.path.isfile(version_file):
with open(version_file) as vf:
if vf.read().strip() == plugin_version:
logger.info("PO Token script already set up and up to date.")
else:
logger.info("yt-dlp already up to date")
# Remove an outdated directory and pull a new version
if os.path.exists(base_dir):
shutil.rmtree(base_dir)
os.makedirs(base_dir, exist_ok=True)
zip_url = (
f"https://github.com/Brainicism/bgutil-ytdlp-pot-provider/archive/refs/tags/{plugin_version}.zip"
)
zip_path = os.path.join(base_dir, f"{plugin_version}.zip")
logger.info(f"Downloading bgutils release zip for version {plugin_version}...")
urlretrieve(zip_url, zip_path)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(base_dir)
os.remove(zip_path)
extracted_root = os.path.join(base_dir, f"bgutil-ytdlp-pot-provider-{plugin_version}")
shutil.move(os.path.join(extracted_root, "server"), server_dir)
shutil.rmtree(extracted_root)
logger.info("Installing dependencies and transpiling PoT Generator script...")
subprocess.run(["yarn", "install", "--frozen-lockfile"], cwd=server_dir, check=True)
subprocess.run(["npx", "tsc"], cwd=server_dir, check=True)
with open(version_file, "w") as vf:
vf.write(plugin_version)
script_path = os.path.join(server_dir, "build", "generate_once.js")
if not os.path.exists(script_path):
logger.error("generate_once.js not found after transpilation.")
return
self.extractor_args.setdefault("youtubepot-bgutilscript", {})["script_path"] = script_path
logger.info(f"PO Token script configured at: {script_path}")
except Exception as e:
logger.error(f"Error updating yt-dlp: {e}")
logger.error(f"Failed to set up PO Token script: {e}")
def suitable_extractors(self, url: str) -> Generator[str, None, None]:
"""
@@ -101,10 +204,13 @@ class GenericExtractor(Extractor):
if thumbnail_url:
try:
cover_image_path = self.download_from_url(thumbnail_url)
media = Media(cover_image_path)
metadata.add_media(media, id="cover")
if cover_image_path:
media = Media(cover_image_path)
metadata.add_media(media, id="cover")
else:
logger.warning(f"Failed to download cover image from {thumbnail_url}")
except Exception as e:
logger.error(f"Error downloading cover image {thumbnail_url}: {e}")
logger.error(f"Could not download cover image {thumbnail_url}: {e}")
dropin = self.dropin_for_name(info_extractor.ie_key())
if dropin:
@@ -204,9 +310,9 @@ class GenericExtractor(Extractor):
result.set_url(url)
if "description" in video_data and not result.get("content"):
result.set_content(video_data["description"])
result.set_content(video_data.pop("description"))
# extract comments if enabled
if self.comments:
if self.comments and video_data.get("comments", None) is not None:
result.set(
"comments",
[
@@ -252,7 +358,7 @@ class GenericExtractor(Extractor):
if not dropin:
# TODO: add a proper link to 'how to create your own dropin'
logger.debug(f"""Could not find valid dropin for {info_extractor.ie_key()}.
Why not try creating your own, and make sure it has a valid function called 'create_metadata'. Learn more: https://auto-archiver.readthedocs.io/en/latest/user_guidelines.html#""")
Why not try creating your own, and make sure it has a valid function called 'create_metadata'. Learn more: https://auto-archiver.readthedocs.io/en/latest/modules/autogen/extractor/generic_extractor.html#dropins""")
return False
post_data = dropin.extract_post(url, ie_instance)
@@ -265,22 +371,29 @@ class GenericExtractor(Extractor):
# this time download
ydl.params["getcomments"] = self.comments
# TODO: for playlist or long lists of videos, how to download one at a time so they can be stored before the next one is downloaded?
data = ydl.extract_info(url, ie_key=info_extractor.ie_key(), download=True)
try:
data = ydl.extract_info(url, ie_key=info_extractor.ie_key(), download=True)
except MaxDownloadsReached: # proceed as normal once MaxDownloadsReached is raised
pass
if "entries" in data:
entries = data.get("entries", [])
if not len(entries):
logger.warning("YoutubeDLArchiver could not find any video")
logger.info("GenericExtractor could not find any video")
return False
else:
entries = [data]
result = Metadata()
for entry in entries:
try:
filename = ydl.prepare_filename(entry)
if not os.path.exists(filename):
filename = filename.split(".")[0] + ".mkv"
filename = ydl_entry_to_filename(ydl, entry)
if not filename:
# file was not downloaded or could not be retrieved, example: sensitive videos on YT without using cookies.
continue
logger.debug(f"Using filename {filename} for entry {entry.get('id', 'unknown')}")
new_media = Media(filename)
for x in ["duration", "original_url", "fulltitle", "description", "upload_date"]:
@@ -298,7 +411,10 @@ class GenericExtractor(Extractor):
logger.error(f"Error loading subtitle file {val.get('filepath')}: {e}")
result.add_media(new_media)
except Exception as e:
logger.error(f"Error processing entry {entry}: {e}")
logger.error(f"Error processing entry {str(entry)[:256]}: {e} {traceback.format_exc()}")
if not len(result.media):
logger.info(f"No media found for entry {str(entry)[:256]}, skipping.")
return False
return self.add_metadata(data, info_extractor, url, result)
@@ -357,6 +473,13 @@ class GenericExtractor(Extractor):
dropin_submodule = self.dropin_for_name(info_extractor.ie_key())
def _helper_for_successful_extract_info(data, info_extractor, url, ydl):
if data.get("is_live", False) and not self.livestreams:
logger.warning("Livestream detected, skipping due to 'livestreams' configuration setting")
return False
# it's a valid video, that the youtubdedl can download out of the box
return self.get_metadata_for_video(data, info_extractor, url, ydl)
try:
if dropin_submodule and dropin_submodule.skip_ytdlp_download(url, info_extractor):
logger.debug(f"Skipping using ytdlp to download files for {info_extractor.ie_key()}")
@@ -364,11 +487,19 @@ class GenericExtractor(Extractor):
# don't download since it can be a live stream
data = ydl.extract_info(url, ie_key=info_extractor.ie_key(), download=False)
if data.get("is_live", False) and not self.livestreams:
logger.warning("Livestream detected, skipping due to 'livestreams' configuration setting")
return False
# it's a valid video, that the youtubdedl can download out of the box
result = self.get_metadata_for_video(data, info_extractor, url, ydl)
# Check for deletion indicators in video data
deletion_info = detect_deletion(video_data=data, url=url)
if deletion_info:
result = Metadata()
flag_as_deleted(result, deletion_info)
return result
result = _helper_for_successful_extract_info(data, info_extractor, url, ydl)
except MaxDownloadsReached:
# yt-dlp raises an error when the max downloads limit is reached, and it shouldn't for our purposes, so we consider that a success
result = _helper_for_successful_extract_info(data, info_extractor, url, ydl)
except Exception as e:
if info_extractor.IE_NAME == "generic":
@@ -383,6 +514,16 @@ class GenericExtractor(Extractor):
try:
result = self.get_metadata_for_post(info_extractor, url, ydl)
except (yt_dlp.utils.DownloadError, yt_dlp.utils.ExtractorError) as post_e:
# Check if the error indicates deletion
deletion_info = detect_deletion(error_message=str(post_e), url=url)
if deletion_info:
result = Metadata()
flag_as_deleted(result, deletion_info)
return result
if "NSFW tweet requires authentication." in str(post_e):
logger.warning(str(post_e))
return False
logger.error("Error downloading metadata for post: {error}", error=str(post_e))
return False
except Exception as generic_e:
@@ -394,7 +535,7 @@ class GenericExtractor(Extractor):
)
return False
if result:
if result and not result.is_success():
extractor_name = "yt-dlp"
if info_extractor:
extractor_name += f"_{info_extractor.ie_key()}"
@@ -406,7 +547,7 @@ class GenericExtractor(Extractor):
return result
def download(self, item: Metadata) -> Metadata:
def download(self, item: Metadata, skip_proxy: bool = False) -> Metadata:
url = item.get_url()
# TODO: this is a temporary hack until this issue is closed: https://github.com/yt-dlp/yt-dlp/issues/11025
@@ -414,6 +555,16 @@ class GenericExtractor(Extractor):
url = url.replace("https://ya.ru", "https://yandex.ru")
item.set("replaced_url", url)
# proxy_on_failure_only logic
if self.proxy and self.proxy_on_failure_only and not skip_proxy:
# when proxy_on_failure_only is True, we first try to download without a proxy and only continue with execution if that fails
try:
if without_proxy := self.download(item, skip_proxy=True):
logger.info("Downloaded successfully without proxy.")
return without_proxy
except Exception:
logger.debug("Download without proxy failed, trying with proxy...")
ydl_options = [
"-o",
os.path.join(self.tmp_dir, "%(id)s.%(ext)s"),
@@ -422,10 +573,14 @@ class GenericExtractor(Extractor):
"--write-subs" if self.subtitles else "--no-write-subs",
"--write-auto-subs" if self.subtitles else "--no-write-auto-subs",
"--live-from-start" if self.live_from_start else "--no-live-from-start",
"--postprocessor-args",
"ffmpeg:-bitexact", # ensure bitexact output to avoid mismatching hashes for same video
"--js-runtimes",
"node", # yt-dlp defaults to deno-only; node is available in the base image
]
# proxy handling
if self.proxy:
if self.proxy and not skip_proxy:
ydl_options.extend(["--proxy", self.proxy])
# max_downloads handling
@@ -438,31 +593,31 @@ class GenericExtractor(Extractor):
# order of importance: username/password -> api_key -> cookie -> cookies_from_browser -> cookies_file
if auth:
if "username" in auth and "password" in auth:
logger.debug(f"Using provided auth username and password for {url}")
logger.debug("Using provided auth username and password")
ydl_options.extend(("--username", auth["username"]))
ydl_options.extend(("--password", auth["password"]))
elif "cookie" in auth:
logger.debug(f"Using provided auth cookie for {url}")
logger.debug("Using provided auth cookie")
yt_dlp.utils.std_headers["cookie"] = auth["cookie"]
elif "cookies_from_browser" in auth:
logger.debug(f"Using extracted cookies from browser {auth['cookies_from_browser']} for {url}")
logger.debug(f"Using extracted cookies from browser {auth['cookies_from_browser']}")
ydl_options.extend(("--cookies-from-browser", auth["cookies_from_browser"]))
elif "cookies_file" in auth:
logger.debug(f"Using cookies from file {auth['cookies_file']} for {url}")
logger.debug(f"Using cookies from file {auth['cookies_file']}")
ydl_options.extend(("--cookies", auth["cookies_file"]))
# Applying user-defined extractor_args
if self.extractor_args:
for key, args in self.extractor_args.items():
logger.debug(f"Setting extractor_args: {key}")
if isinstance(args, dict):
arg_str = ";".join(f"{k}={v}" for k, v in args.items())
else:
arg_str = str(args)
logger.debug(f"Setting extractor_args: {key}:{arg_str}")
ydl_options.extend(["--extractor-args", f"{key}:{arg_str}"])
if self.ytdlp_args:
logger.debug("Adding additional ytdlp arguments: {self.ytdlp_args}")
logger.debug(f"Adding additional ytdlp arguments: {self.ytdlp_args}")
ydl_options += self.ytdlp_args.split(" ")
*_, validated_options = yt_dlp.parse_options(ydl_options)
@@ -470,9 +625,9 @@ class GenericExtractor(Extractor):
validated_options
) # allsubtitles and subtitleslangs not working as expected, so default lang is always "en"
result: Metadata = None
for info_extractor in self.suitable_extractors(url):
result = self.download_for_extractor(info_extractor, url, ydl)
if result:
return result
return False
local_result: Metadata = self.download_for_extractor(info_extractor, url, ydl)
if local_result:
result = result.merge(local_result) if result else local_result
return result if result else False

View File

@@ -1,5 +1,6 @@
import re
import requests
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from yt_dlp.extractor.tiktok import TikTokIE, TikTokLiveIE, TikTokVMIE, TikTokUserIE
@@ -10,74 +11,113 @@ from .dropin import GenericDropin
class Tiktok(GenericDropin):
"""
TikTok droping for the Generic Extractor that uses an unofficial API if/when ytdlp fails.
TikTok dropin for the Generic Extractor that uses an unofficial API if/when ytdlp fails.
It's useful for capturing content that requires a login, like sensitive content.
"""
# Regex pattern to match TikTok photo post URLs
PHOTO_URL_REGEX = r"https?://(?:www\.)?tiktok\.com/@[\w\.-]+/photo/\d+"
TIKWM_ENDPOINT = "https://www.tikwm.com/api/?url={url}"
def suitable(self, url, info_extractor) -> bool:
"""This dropin (which uses Tikvm) is suitable for *all* Tiktok type URLs - videos, lives, VMs, and users.
Return the 'suitable' method from the TikTokIE class."""
return any(extractor().suitable(url) for extractor in (TikTokIE, TikTokLiveIE, TikTokVMIE, TikTokUserIE))
return any(extractor().suitable(url) for extractor in (TikTokIE, TikTokLiveIE, TikTokVMIE, TikTokUserIE)) or (
re.match(self.PHOTO_URL_REGEX, url) is not None
)
def extract_post(self, url: str, ie_instance):
logger.debug(f"Using Tikwm API to attempt to download tiktok video from {url=}")
logger.debug("Using Tikwm API to attempt to download tiktok video")
endpoint = self.TIKWM_ENDPOINT.format(url=url)
r = requests.get(endpoint)
if r.status_code != 200:
raise ValueError(f"unexpected status code '{r.status_code}' from tikwm.com for {url=}:")
raise ValueError(f"Unexpected status code '{r.status_code}' from tikwm.com")
try:
json_response = r.json()
except ValueError:
raise ValueError(f"failed to parse JSON response from tikwm.com for {url=}")
raise ValueError("Failed to parse JSON response from tikwm.com")
if not json_response.get("msg") == "success" or not (api_data := json_response.get("data", {})):
raise ValueError(f"failed to get a valid response from tikwm.com for {url=}: {repr(json_response)}")
raise ValueError(f"Unable to download with tikwm.com: {repr(json_response)}")
# tries to get the non-watermarked version first
video_url = api_data.pop("play", api_data.pop("wmplay", None))
if not video_url:
raise ValueError(f"no valid video URL found in response from tikwm.com for {url=}")
api_data["video_url"] = video_url
play_url = api_data.pop("play", api_data.pop("wmplay", None))
if play_url and "mime_type=audio" in play_url:
play_url = None
if play_url:
api_data["video_url"] = play_url
return api_data
def keys_to_clean(self, video_data: dict, info_extractor):
return ["video_url", "title", "create_time", "author", "cover", "origin_cover", "ai_dynamic_cover", "duration"]
return [
"video_url",
"title",
"create_time",
"author",
"cover",
"origin_cover",
"ai_dynamic_cover",
"duration",
"size",
"wm_size",
"music",
"music_info",
"play_count",
"digg_count",
"comment_count",
"share_count",
"download_count",
"collect_count",
"anchors",
"anchors_extras",
"is_ad",
"commerce_info",
"commercial_video_info",
"item_comment_settings",
"mentioned_users",
] # all of these will be added via api_data in a single metadata field vs individual ones in the generic extractor
def create_metadata(self, post: dict, ie_instance, archiver, url):
# prepare result, start by downloading video
result = Metadata()
video_url = post.pop("video_url")
is_success = False
# get the cover if possible
cover_url = post.pop("origin_cover", post.pop("cover", post.pop("ai_dynamic_cover", None)))
if cover_url and (cover_downloaded := archiver.download_from_url(cover_url)):
result.add_media(Media(cover_downloaded))
# get the video or fail
video_downloaded = archiver.download_from_url(video_url, f"vid_{post.get('id', '')}")
if not video_downloaded:
logger.error(f"failed to download video from {video_url}")
return False
video_media = Media(video_downloaded)
if duration := post.get("duration", None):
video_media.set("duration", duration)
result.add_media(video_media)
for image_url in post.pop("images", []):
if image_downloaded := archiver.download_from_url(image_url):
result.add_media(Media(image_downloaded))
is_success = True # this is an images post and we got it/them
# get the video if present, could be an image post
if video_url := post.pop("video_url", None):
video_downloaded = archiver.download_from_url(video_url, f"vid_{post.get('id', '')}")
if not video_downloaded:
logger.error("Failed to download video")
return False
video_media = Media(video_downloaded)
if duration := post.pop("duration", None):
video_media.set("duration", duration)
result.add_media(video_media)
is_success = True # this is a video post and we got it
# add remaining metadata
result.set_title(post.get("title", ""))
result.set_title(post.pop("title", ""))
if created_at := post.get("create_time", None):
if created_at := post.pop("create_time", None):
result.set_timestamp(datetime.fromtimestamp(created_at, tz=timezone.utc))
if author := post.get("author", None):
if author := post.pop("author", None):
result.set("author", author)
result.set("api_data", post)
result.set("api_data", {k: v for k, v in post.items() if v})
if is_success:
result.success("yt-dlp_TikTok")
else:
raise ValueError("Unable to download any media from TikTok post, possibly deleted or private.")
return result

View File

@@ -1,6 +1,7 @@
from typing import Type
from auto_archiver.utils import traverse_obj
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core.metadata import Metadata, Media
from auto_archiver.core.extractor import Extractor
from yt_dlp.extractor.common import InfoExtractor
@@ -58,6 +59,9 @@ class Truth(GenericDropin):
# add the media
for media in post.get("media_attachments", []):
filename = archiver.download_from_url(media["url"])
if not filename:
logger.warning(f"Failed to download media from {media['url']}")
continue
result.add_media(Media(filename), id=media.get("id"))
return result

View File

@@ -1,14 +1,16 @@
import re
import mimetypes
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from slugify import slugify
from auto_archiver.core.metadata import Metadata, Media
from auto_archiver.utils import url as UrlUtil, get_datetime_from_str
from auto_archiver.core.extractor import Extractor
from .dropin import GenericDropin, InfoExtractor
from auto_archiver.utils.deletion_detection import detect_deletion, flag_as_deleted
from auto_archiver.modules.generic_extractor.dropin import GenericDropin, InfoExtractor
import requests
from retrying import retry
class Twitter(GenericDropin):
@@ -29,7 +31,85 @@ class Twitter(GenericDropin):
def extract_post(self, url: str, ie_instance: InfoExtractor):
twid = ie_instance._match_valid_url(url).group("id")
return ie_instance._extract_status(twid=twid)
try:
post_data = ie_instance._extract_status(twid=twid)
if not post_data or not post_data.get("user") or not post_data.get("created_at"):
raise ValueError("Error retrieving post with twitter dropin")
return post_data
except Exception as e:
logger.debug(f"yt-dlp twitter extraction failed: {e}")
# try fxtwitter API as fallback
return self._fetch_fxtwitter(twid)
def _fetch_fxtwitter(self, twid: str) -> dict:
"""Fetch tweet data from fxtwitter API and convert to expected format."""
fxtwitter_url = f"https://api.fxtwitter.com/status/{twid}"
logger.info(f"Falling back to fxtwitter API for tweet extraction: {fxtwitter_url}")
@retry(wait_random_min=500, wait_random_max=2000, stop_max_attempt_number=3)
def fetch_fxtwitter_data(url):
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/112.0"}
resp = requests.get(url, headers=headers, timeout=15)
if resp.status_code != 200:
raise ValueError(f"Failed to retrieve tweet from fxtwitter API: {resp.status_code}")
data = resp.json()
if "tweet" not in data:
raise ValueError(f"No tweet data in fxtwitter response: {data.get('message', 'Unknown error')}")
return data["tweet"]
tweet = fetch_fxtwitter_data(fxtwitter_url)
# Convert fxtwitter format to expected format
author = tweet.get("author", {}).get("name", "")
created_at = tweet.get("created_at", "") # Format: "Sun Feb 08 18:45:00 +0000 2026"
full_text = tweet.get("text", "") or tweet.get("raw_text", "")
# Convert media format
media = []
fx_media = tweet.get("media", {})
# Handle photos
for photo in fx_media.get("photos", []):
media.append({"type": "photo", "media_url_https": photo.get("url", "")})
# Handle videos
for video in fx_media.get("videos", []):
variants = video.get("variants", [])
# Convert to expected variant format
converted_variants = []
for var in variants:
converted_variants.append(
{
"url": var.get("url", ""),
"content_type": var.get("content_type", "video/mp4"),
"bitrate": var.get("bitrate", 0),
}
)
if converted_variants:
media.append({"type": "video", "video_info": {"variants": converted_variants}})
# Handle animated gifs (fxtwitter may include these in videos)
for item in fx_media.get("all", []):
if item.get("type") == "gif":
variants = item.get("variants", [])
converted_variants = []
for var in variants:
converted_variants.append(
{
"url": var.get("url", ""),
"content_type": var.get("content_type", "video/mp4"),
"bitrate": var.get("bitrate", 0),
}
)
if converted_variants:
media.append({"type": "animated_gif", "video_info": {"variants": converted_variants}})
return {
"user": {"name": author},
"created_at": created_at,
"full_text": full_text,
"entities": {"media": media},
}
def keys_to_clean(self, video_data, info_extractor):
return ["user", "created_at", "entities", "favorited", "translator_type"]
@@ -38,7 +118,15 @@ class Twitter(GenericDropin):
result = Metadata()
try:
if not tweet.get("user") or not tweet.get("created_at"):
raise ValueError("Error retreiving post. Are you sure it exists?")
# Check for deletion indicators
deletion_info = detect_deletion(
video_data=tweet, url=url, error_message="Missing user or created_at fields"
)
if deletion_info:
flag_as_deleted(result, deletion_info)
return result
raise ValueError("Error retrieving post. Are you sure it exists?")
timestamp = get_datetime_from_str(tweet["created_at"], "%a %b %d %H:%M:%S %z %Y")
except (ValueError, KeyError) as ex:
logger.warning(f"Unable to parse tweet: {str(ex)}\nRetreived tweet data: {tweet}")
@@ -69,5 +157,8 @@ class Twitter(GenericDropin):
mimetype = variant["content_type"]
ext = mimetypes.guess_extension(mimetype)
media.filename = archiver.download_from_url(media.get("src"), f"{slugify(url)}_{i}{ext}")
if not media.filename:
logger.warning(f"Failed to download media from {media.get('src')}")
continue
result.add_media(media)
return result

View File

@@ -0,0 +1 @@
from .ghostarchive_enricher import GhostarchiveEnricher

View File

@@ -0,0 +1,58 @@
{
"name": "Ghost Archive Enricher",
"type": ["enricher"],
"entry_point": "ghostarchive_enricher::GhostarchiveEnricher",
"requires_setup": False,
"dependencies": {
"python": ["loguru", "requests", "bs4", "seleniumbase"],
},
"configs": {
"timeout": {
"default": 120,
"type": "int",
"help": "seconds to wait for successful archive confirmation from Ghost Archive.",
},
"check_existing": {
"default": True,
"type": "bool",
"help": "whether to search for an existing archive before submitting a new one.",
},
"proxy_http": {
"default": None,
"help": "http proxy to use for requests, eg http://proxy-user:password@proxy-ip:port",
},
"proxy_https": {
"default": None,
"help": "https proxy to use for requests, eg https://proxy-user:password@proxy-ip:port",
},
},
"description": """
Submits the current URL to [Ghost Archive](https://ghostarchive.org/) for archiving and returns the archived page URL.
Used as an **enricher** to add a Ghost Archive URL to items already extracted by other modules.
### Features
- Archives any public URL using the Ghost Archive service.
- Optionally checks for existing archives before submitting a new one.
- Supports HTTP and HTTPS proxies for requests.
- Parses HTML responses to extract archive URLs (Ghost Archive has no JSON API).
### Important
- This module confirms that Ghost Archive accepted the URL submission and returned an archive link.
It does **not** verify the contents or completeness of the archived page.
### Notes
- Ghost Archive is a free service with no authentication required.
- Archived pages must be smaller than 50 MB (including CSS, fonts, images, etc.).
- Videos are archived up to 360p and must be under 100 MB and shorter than 30 minutes.
- Archival may take up to 5 minutes depending on the queue and page complexity.
- Archived content is stored indefinitely.
- Ghost Archive does not archive pages that require authentication or form submission.
### Limitations
- No official API — this module interacts with the Ghost Archive web interface.
- The submission endpoint is protected by Cloudflare, so a headless browser (SeleniumBase) is used for new submissions.
- Searching for existing archives uses plain HTTP requests and does not require a browser.
- Rate limiting may apply; consider using a delay between requests if archiving many URLs.
""",
}

View File

@@ -0,0 +1,153 @@
import time
import re
import requests
from bs4 import BeautifulSoup
from seleniumbase import SB
from auto_archiver.utils.custom_logger import logger
from auto_archiver.utils import url as UrlUtil
from auto_archiver.core import Enricher, Metadata
class GhostarchiveEnricher(Enricher):
"""
Submits the current URL to Ghost Archive (ghostarchive.org) for archiving
and stores the archived page URL as enrichment metadata.
Ghost Archive has no official API — this module interacts with the web form
and parses HTML responses. The submission endpoint is protected by Cloudflare,
so a headless browser (SeleniumBase) is used for archival submissions, while
plain HTTP requests are used for searching existing archives.
Note: this module only confirms that Ghost Archive accepted the submission
and returned an archive URL. It does not verify that the archived page
content is complete or correctly rendered.
"""
GHOSTARCHIVE_BASE = "https://ghostarchive.org"
ARCHIVE_ENDPOINT = f"{GHOSTARCHIVE_BASE}/archive2"
SEARCH_ENDPOINT = f"{GHOSTARCHIVE_BASE}/search"
ARCHIVE_URL_PATTERN = re.compile(r"/archive/([A-Za-z0-9]+)")
def _get_proxies(self) -> dict:
proxies = {}
if self.proxy_http:
proxies["http"] = self.proxy_http
if self.proxy_https:
proxies["https"] = self.proxy_https
return proxies
def _get_headers(self) -> dict:
return {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
def _normalize_archive_href(self, href: str) -> str | None:
"""Normalize an archive link href to a full HTTPS URL, filtering out replay links."""
if "/archive/" not in href or "/replay/" in href:
return None
if href.startswith("/"):
return f"{self.GHOSTARCHIVE_BASE}{href}"
if href.startswith("http://ghostarchive.org"):
return href.replace("http://", "https://")
if href.startswith("https://ghostarchive.org"):
return href
return None
def _search_existing(self, url: str) -> str | None:
"""
Search Ghost Archive for an existing archive of the given URL.
Returns the archive URL if found, otherwise None.
"""
try:
r = requests.get(
self.SEARCH_ENDPOINT,
params={"term": url},
headers=self._get_headers(),
proxies=self._get_proxies(),
timeout=30,
)
if r.status_code != 200:
logger.warning(f"Ghost Archive search returned status {r.status_code}")
return None
soup = BeautifulSoup(r.text, "html.parser")
for link in soup.find_all("a", href=True):
archive_url = self._normalize_archive_href(link["href"])
if archive_url:
logger.info(f"Found existing Ghost Archive: {archive_url}")
return archive_url
except requests.exceptions.RequestException as e:
logger.warning(f"Ghost Archive search failed: {e}")
return None
def _submit_url(self, url: str) -> str | None:
"""
Submit a URL to Ghost Archive for archiving using a headless browser.
The /archive2 endpoint is Cloudflare-protected, requiring JS execution.
Returns the archive URL if successful, otherwise None.
"""
try:
with SB(uc=True, headless=True) as sb:
logger.debug("Opening Ghost Archive homepage in headless browser")
sb.open(self.GHOSTARCHIVE_BASE)
# fill in the archive form and submit
sb.type('input[name="archive"]', url)
sb.click('input[type="submit"][value="Submit for archival"]')
# wait for navigation to /archive/{id} or timeout
start_time = time.time()
while time.time() - start_time < self.timeout:
current_url = sb.get_current_url()
if self.ARCHIVE_URL_PATTERN.search(current_url):
archive_url = current_url.split("?")[0]
logger.info(f"Ghost Archive saved: {archive_url}")
return archive_url
time.sleep(2)
# if we didn't redirect, try parsing the page source
page_source = sb.get_page_source()
return self._parse_archive_url(page_source)
except Exception as e:
logger.warning(f"Ghost Archive submission failed: {e}")
return None
def _parse_archive_url(self, html: str) -> str | None:
"""Parse HTML response to find an archive URL."""
soup = BeautifulSoup(html, "html.parser")
for link in soup.find_all("a", href=True):
archive_url = self._normalize_archive_href(link["href"])
if archive_url:
return archive_url
return None
def enrich(self, to_enrich: Metadata) -> bool:
url = to_enrich.get_url()
if UrlUtil.is_auth_wall(url):
logger.debug("[SKIP] Ghost Archive since url is behind AUTH WALL")
return False
if to_enrich.get("ghostarchive"):
logger.info(f"Ghost Archive enricher had already been executed: {to_enrich.get('ghostarchive')}")
return True
# optionally check for existing archive first
archive_url = None
if self.check_existing:
logger.debug(f"Searching Ghost Archive for existing archive of {url}")
archive_url = self._search_existing(url)
if not archive_url:
logger.debug(f"Submitting {url} to Ghost Archive")
archive_url = self._submit_url(url)
if archive_url:
to_enrich.set("ghostarchive", archive_url)
return True
logger.warning(f"Ghost Archive failed to archive {url}")
return False

View File

@@ -10,12 +10,14 @@ The filtered rows are processed into `Metadata` objects.
"""
import os
from typing import Tuple, Union
import traceback
from typing import Tuple, Union, Iterator
from urllib.parse import quote
import gspread
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from slugify import slugify
from retrying import retry
from auto_archiver.core import Feeder, Database, Media
from auto_archiver.core import Metadata
@@ -30,29 +32,40 @@ class GsheetsFeederDB(Feeder, Database):
if not self.sheet and not self.sheet_id:
raise ValueError("You need to define either a 'sheet' name or a 'sheet_id' in your manifest.")
def open_sheet(self):
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=6,
)
def open_sheet(self) -> gspread.Spreadsheet:
if self.sheet:
return self.gsheets_client.open(self.sheet)
else: # self.sheet_id
else:
return self.gsheets_client.open_by_key(self.sheet_id)
def __iter__(self) -> Metadata:
sh = self.open_sheet()
for ii, worksheet in enumerate(sh.worksheets()):
if not self.should_process_sheet(worksheet.title):
logger.debug(f"SKIPPED worksheet '{worksheet.title}' due to allow/block rules")
continue
logger.info(f"Opening worksheet {ii=}: {worksheet.title=} header={self.header}")
gw = GWorksheet(worksheet, header_row=self.header, columns=self.columns)
if len(missing_cols := self.missing_required_columns(gw)):
logger.warning(
f"SKIPPED worksheet '{worksheet.title}' due to missing required column(s) for {missing_cols}"
)
continue
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=6,
)
def enumerate_sheets(self, sheet) -> Iterator[gspread.Worksheet]:
for worksheet in sheet.worksheets():
yield worksheet
# process and yield metadata here:
yield from self._process_rows(gw)
logger.success(f"Finished worksheet {worksheet.title}")
def __iter__(self) -> Iterator[Metadata]:
spreadsheet = self.open_sheet()
for worksheet in self.enumerate_sheets(spreadsheet):
with logger.contextualize(worksheet=f"{spreadsheet.title}:{worksheet.title}"):
if not self.should_process_sheet(worksheet.title):
logger.debug("Skipped worksheet due to allow/block rules")
continue
logger.info(f"Opening worksheet header={self.header}")
gw = GWorksheet(worksheet, header_row=self.header, columns=self.columns)
if len(missing_cols := self.missing_required_columns(gw)):
logger.debug(f"Skipped worksheet due to missing required column(s) for {missing_cols}")
continue
# process and yield metadata here:
yield from self._process_rows(gw)
logger.info(f"Finished worksheet {worksheet.title}")
def _process_rows(self, gw: GWorksheet):
for row in range(1 + self.header, gw.count_rows() + 1):
@@ -68,7 +81,9 @@ class GsheetsFeederDB(Feeder, Database):
# All checks done - archival process starts here
m = Metadata().set_url(url)
self._set_context(m, gw, row)
yield m
with logger.contextualize(row=row):
yield m
def _set_context(self, m: Metadata, gw: GWorksheet, row: int) -> Metadata:
# TODO: Check folder value not being recognised
@@ -88,10 +103,7 @@ class GsheetsFeederDB(Feeder, Database):
if len(self.allow_worksheets) and sheet_name not in self.allow_worksheets:
# ALLOW rules exist AND sheet name not explicitly allowed
return False
if len(self.block_worksheets) and sheet_name in self.block_worksheets:
# BLOCK rules exist AND sheet name is blocked
return False
return True
return not (self.block_worksheets and sheet_name in self.block_worksheets)
def missing_required_columns(self, gw: GWorksheet) -> list:
missing = []
@@ -101,16 +113,16 @@ class GsheetsFeederDB(Feeder, Database):
return missing
def started(self, item: Metadata) -> None:
logger.warning(f"STARTED {item}")
logger.info("STARTED")
gw, row = self._retrieve_gsheet(item)
gw.set_cell(row, "status", "Archive in progress")
def failed(self, item: Metadata, reason: str) -> None:
logger.error(f"FAILED {item}")
logger.error("FAILED")
self._safe_status_update(item, f"Archive failed {reason}")
def aborted(self, item: Metadata) -> None:
logger.warning(f"ABORTED {item}")
logger.warning("ABORTED")
self._safe_status_update(item, "")
def fetch(self, item: Metadata) -> Union[Metadata, bool]:
@@ -119,13 +131,13 @@ class GsheetsFeederDB(Feeder, Database):
def done(self, item: Metadata, cached: bool = False) -> None:
"""archival result ready - should be saved to DB"""
logger.success(f"DONE {item.get_url()}")
gw, row = self._retrieve_gsheet(item)
# self._safe_status_update(item, 'done')
cell_updates = []
row_values = gw.get_row(row)
logger.info("DONE")
def batch_if_valid(col, val, final_value=None):
final_value = final_value or val
try:
@@ -161,9 +173,8 @@ class GsheetsFeederDB(Feeder, Database):
if (screenshot := item.get_media_by_id("screenshot")) and hasattr(screenshot, "urls"):
batch_if_valid("screenshot", "\n".join(screenshot.urls))
if thumbnail := item.get_first_image("thumbnail"):
if hasattr(thumbnail, "urls"):
batch_if_valid("thumbnail", f'=IMAGE("{thumbnail.urls[0]}")')
if (thumbnail := item.get_first_image("thumbnail")) and hasattr(thumbnail, "urls"):
batch_if_valid("thumbnail", f'=IMAGE("{thumbnail.urls[0]}")')
if browsertrix := item.get_media_by_id("browsertrix"):
batch_if_valid("wacz", "\n".join(browsertrix.urls))
@@ -177,22 +188,27 @@ class GsheetsFeederDB(Feeder, Database):
),
)
gw.batch_set_cell(cell_updates)
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=5,
)
def batch_set_cell_with_retry(gw, cell_updates: list):
gw.batch_set_cell(cell_updates)
batch_set_cell_with_retry(gw, cell_updates)
def _safe_status_update(self, item: Metadata, new_status: str) -> None:
try:
gw, row = self._retrieve_gsheet(item)
gw.set_cell(row, "status", new_status)
except Exception as e:
logger.debug(f"Unable to update sheet: {e}")
logger.debug(f"Unable to update sheet: {e}: {traceback.format_exc()}")
def _retrieve_gsheet(self, item: Metadata) -> Tuple[GWorksheet, int]:
if gsheet := item.get_context("gsheet"):
gw: GWorksheet = gsheet.get("worksheet")
row: int = gsheet.get("row")
elif self.sheet_id:
logger.error(
f"Unable to retrieve Gsheet for {item.get_url()}, GsheetDB must be used alongside GsheetFeeder."
)
logger.error("Unable to retrieve Gsheet, GsheetDB must be used alongside GsheetFeeder.")
return gw, row

View File

@@ -1,4 +1,5 @@
from gspread import utils
from retrying import retry
class GWorksheet:
@@ -26,6 +27,10 @@ class GWorksheet:
"replaywebpage": "replaywebpage",
}
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=6,
)
def __init__(self, worksheet, columns=COLUMN_NAMES, header_row=1):
self.wks = worksheet
self.columns = columns

View File

@@ -9,7 +9,7 @@ making it suitable for handling large files efficiently.
"""
import hashlib
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Enricher
from auto_archiver.core import Metadata
@@ -22,10 +22,12 @@ class HashEnricher(Enricher):
"""
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"calculating media hashes for {url=} (using {self.algorithm})")
logger.debug(f"Calculating media hashes with algo={self.algorithm}")
for i, m in enumerate(to_enrich.media):
if not m.filename:
logger.warning(f"Skipping hash for media without filename: {m}")
continue
if len(hd := self.calculate_hash(m.filename)):
to_enrich.media[i].set("hash", f"{self.algorithm}:{hd}")

View File

@@ -4,7 +4,7 @@ import os
import pathlib
from jinja2 import Environment, FileSystemLoader
from urllib.parse import quote
from loguru import logger
from auto_archiver.utils.custom_logger import logger
import json
import base64
@@ -35,7 +35,7 @@ class HtmlFormatter(Formatter):
def format(self, item: Metadata) -> Media:
url = item.get_url()
if item.is_empty():
logger.debug(f"[SKIP] FORMAT there is no media or metadata to format: {url=}")
logger.debug("Nothing to format, skipping")
return
content = self.template.render(

View File

@@ -12,6 +12,12 @@
font-family: 'Roboto', sans-serif;
}
h2 {
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
table {
table-layout: fixed;
width: 90%;
@@ -97,13 +103,17 @@
background-color: #f1f1f1;
}
.pem-certificate, .text-preview {
.pem-certificate,
.text-preview {
text-align: left;
font-size: small;
}
.text-preview{
.text-preview {
padding-left: 10px;
padding-right: 10px;
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
}
</style>

View File

@@ -22,7 +22,7 @@
"full_profile_max_posts": {
"default": 0,
"type": "int",
"help": "Use to limit the number of posts to download when full_profile is true. 0 means no limit. limit is applied softly since posts are fetched in batch, once to: posts, tagged posts, and highlights",
"help": "Use to limit the number of posts to download when full_profile is true or when a URL for multiple posts is passed (like /stories /highlights ...). 0 means no limit. when full_profile is true the order of downloaded content is stories -> posts -> tagged posts -> highlights, so a value of 10 could download 2 stories, 7 posts, 1 tagged posts, and 0 highlights.",
},
"minimize_json_output": {
"default": True,
@@ -31,9 +31,11 @@
},
},
"description": """
Archives various types of Instagram content using the Instagrapi API.
Archives Instagram content using a deployment of the [Instagrapi API](https://subzeroid.github.io/instagrapi/).
Requires setting up an Instagrapi API deployment and providing an access token and API endpoint.
Requires either getting a token from using a hosted [(paid) service](https://api.instagrapi.com/docs) and setting this in the configuration file.
Alternatively you can run your own server. We have a basic script which you can use for this which can be ran locally or using Docker.
For more information, read the [how to guide](https://auto-archiver.readthedocs.io/en/latest/how_to/run_instagrapi_server.html) on this.
### Features
- Connects to an Instagrapi API deployment to fetch Instagram profiles, posts, stories, highlights, reels, and tagged content.

View File

@@ -8,11 +8,13 @@ data, reducing JSON output size, and handling large profiles.
"""
import math
import re
from datetime import datetime
import traceback
import requests
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from retrying import retry
from tqdm import tqdm
@@ -35,17 +37,19 @@ class InstagramAPIExtractor(Extractor):
def setup(self) -> None:
if self.api_endpoint[-1] == "/":
self.api_endpoint = self.api_endpoint[:-1]
self.full_profile_max_posts = int(self.full_profile_max_posts or 0)
if self.full_profile_max_posts == 0:
self.full_profile_max_posts = math.inf
def download(self, item: Metadata) -> Metadata:
url = item.get_url()
url.replace("instagr.com", "instagram.com").replace("instagr.am", "instagram.com")
insta_matches = self.valid_url.findall(url)
logger.info(f"{insta_matches=}")
if not len(insta_matches) or len(insta_matches[0]) != 3:
return
if len(insta_matches) > 1:
logger.warning(f"Multiple instagram matches found in {url=}, using the first one")
logger.debug("Multiple instagram matches found, using the first one")
return
g1, g2, g3 = insta_matches[0][0], insta_matches[0][1], insta_matches[0][2]
if g1 == "":
@@ -61,13 +65,13 @@ class InstagramAPIExtractor(Extractor):
return self.download_post(item, id=g3, context="story")
return self.download_stories(item, g2)
else:
logger.warning(f"Unknown instagram regex group match {g1=} found in {url=}")
logger.warning(f"Unknown instagram regex group match {g1=}")
return
@retry(wait_random_min=1000, wait_random_max=3000, stop_max_attempt_number=5)
def call_api(self, path: str, params: dict) -> dict:
headers = {"accept": "application/json", "x-access-key": self.access_token}
logger.debug(f"calling {self.api_endpoint}/{path} with {params=}")
logger.debug(f"Calling {self.api_endpoint}/{path} with {params=}")
return requests.get(f"{self.api_endpoint}/{path}", headers=headers, params=params).json()
def cleanup_dict(self, d: dict | list) -> dict:
@@ -95,67 +99,89 @@ class InstagramAPIExtractor(Extractor):
result.set_title(user.get("full_name", username)).set("data", user)
if pic_url := user.get("profile_pic_url_hd", user.get("profile_pic_url")):
filename = self.download_from_url(pic_url)
result.add_media(Media(filename=filename), id="profile_picture")
if filename:
result.add_media(Media(filename=filename), id="profile_picture")
else:
logger.warning(f"Failed to download profile picture from {pic_url}")
count_posts = 0
if self.full_profile:
user_id = user.get("pk")
# download all stories
try:
stories = self._download_stories_reusable(result, username)
stories = self._download_stories_reusable(
result, username, max_to_download=self.full_profile_max_posts - count_posts
)
count_posts += len(stories)
result.set("#stories", len(stories))
except Exception as e:
result.append("errors", f"Error downloading stories for {username}")
logger.error(f"Error downloading stories for {username}: {e}")
logger.error(f"Error downloading stories for {username}: {e} {traceback.format_exc()}")
# download all posts
try:
self.download_all_posts(result, user_id)
if count_posts < self.full_profile_max_posts:
count_posts += self.download_all_posts(
result, user_id, max_to_download=self.full_profile_max_posts - count_posts
)
except Exception as e:
result.append("errors", f"Error downloading posts for {username}")
logger.error(f"Error downloading posts for {username}: {e}")
logger.error(f"Error downloading posts for {username}: {e} {traceback.format_exc()}")
# download all tagged
try:
self.download_all_tagged(result, user_id)
if count_posts < self.full_profile_max_posts:
count_posts += self.download_all_tagged(
result, user_id, max_to_download=self.full_profile_max_posts - count_posts
)
except Exception as e:
result.append("errors", f"Error downloading tagged posts for {username}")
logger.error(f"Error downloading tagged posts for {username}: {e}")
logger.error(f"Error downloading tagged posts for {username}: {e} {traceback.format_exc()}")
# download all highlights
try:
self.download_all_highlights(result, username, user_id)
if count_posts < self.full_profile_max_posts:
count_posts += self.download_all_highlights(
result, username, user_id, max_to_download=self.full_profile_max_posts - count_posts
)
except Exception as e:
result.append("errors", f"Error downloading highlights for {username}")
logger.error(f"Error downloading highlights for {username}: {e}")
logger.error(f"Error downloading highlights for {username}: {e} {traceback.format_exc()}")
result.set_url(url) # reset as scrape_item modifies it
return result.success("insta profile")
def download_all_highlights(self, result, username, user_id):
def download_all_highlights(self, result, username, user_id, max_to_download: int) -> int:
count_highlights = 0
highlights = self.call_api("v1/user/highlights", {"user_id": user_id})
highlights = highlights[: min(max_to_download, len(highlights))] # newest to oldest
for h in highlights:
try:
h_info = self._download_highlights_reusable(result, h.get("pk"))
h_info = self._download_highlights_reusable(result, h.get("pk"), max_to_download=max_to_download)
count_highlights += len(h_info.get("items", []))
except Exception as e:
result.append(
"errors",
f"Error downloading highlight id{h.get('pk')} for {username}",
)
logger.error(f"Error downloading highlight id{h.get('pk')} for {username}: {e}")
if self.full_profile_max_posts and count_highlights >= self.full_profile_max_posts:
logger.info(f"HIGHLIGHTS reached full_profile_max_posts={self.full_profile_max_posts}")
logger.error(
f"Error downloading highlight id{h.get('pk')} for {username}: {e} {traceback.format_exc()}"
)
if count_highlights >= max_to_download:
logger.debug(f"HIGHLIGHTS reached max_to_download={self.full_profile_max_posts}")
break
result.set("#highlights", count_highlights)
return count_highlights
def download_post(self, result: Metadata, code: str = None, id: str = None, context: str = None) -> Metadata:
def download_post(self, result: Metadata, code: str = None, id: str = None, context: str = "") -> Metadata:
if id:
post = self.call_api("v1/media/by/id", {"id": id})
else:
post = self.call_api("v1/media/by/code", {"code": code})
assert post, f"Post {id or code} not found"
result.set(f"{context}_data", post)
if caption_text := post.get("caption_text"):
result.set_title(caption_text)
@@ -166,54 +192,58 @@ class InstagramAPIExtractor(Extractor):
return result.success(f"insta {context or 'post'}")
def download_highlights(self, result: Metadata, id: str) -> Metadata:
h_info = self._download_highlights_reusable(result, id)
h_info = self._download_highlights_reusable(result, id, self.full_profile_max_posts)
items = len(h_info.get("items", []))
del h_info["items"]
result.set_title(h_info.get("title")).set("data", h_info).set("#reels", items)
return result.success("insta highlights")
def _download_highlights_reusable(self, result: Metadata, id: str) -> dict:
def _download_highlights_reusable(self, result: Metadata, id: str, max_to_download: int) -> dict:
full_h = self.call_api("v2/highlight/by/id", {"id": id})
h_info = full_h.get("response", {}).get("reels", {}).get(f"highlight:{id}")
assert h_info, f"Highlight {id} not found: {full_h=}"
if cover_media := h_info.get("cover_media", {}).get("cropped_image_version", {}).get("url"):
filename = self.download_from_url(cover_media)
result.add_media(Media(filename=filename), id=f"cover_media highlight {id}")
if filename:
result.add_media(Media(filename=filename), id=f"cover_media highlight {id}")
else:
logger.warning(f"Failed to download cover media from {cover_media}")
items = h_info.get("items", [])[::-1] # newest to oldest
items = items[: min(max_to_download, len(items))]
for h in tqdm(items, desc="downloading highlights", unit="highlight"):
try:
self.scrape_item(result, h, "highlight")
except Exception as e:
result.append("errors", f"Error downloading highlight {h.get('id')}")
logger.error(f"Error downloading highlight, skipping {h.get('id')}: {e}")
logger.error(f"Error downloading highlight, skipping {h.get('id')}: {e} {traceback.format_exc()}")
return h_info
def download_stories(self, result: Metadata, username: str) -> Metadata:
now = datetime.now().strftime("%Y-%m-%d_%H-%M")
stories = self._download_stories_reusable(result, username)
stories = self._download_stories_reusable(result, username, max_to_download=self.full_profile_max_posts)
if stories == []:
return result.success("insta no story")
result.set_title(f"stories {username} at {now}").set("#stories", len(stories))
return result.success(f"insta stories {now}")
def _download_stories_reusable(self, result: Metadata, username: str) -> list[dict]:
def _download_stories_reusable(self, result: Metadata, username: str, max_to_download: int) -> list[dict]:
stories = self.call_api("v1/user/stories/by/username", {"username": username})
if not stories or not len(stories):
return []
stories = stories[::-1] # newest to oldest
stories = stories[::-1][: min(max_to_download, len(stories))] # newest to oldest
for s in tqdm(stories, desc="downloading stories", unit="story"):
try:
self.scrape_item(result, s, "story")
except Exception as e:
result.append("errors", f"Error downloading story {s.get('id')}")
logger.error(f"Error downloading story, skipping {s.get('id')}: {e}")
logger.error(f"Error downloading story, skipping {s.get('id')}: {e} {traceback.format_exc()}")
return stories
def download_all_posts(self, result: Metadata, user_id: str):
def download_all_posts(self, result: Metadata, user_id: str, max_to_download: int) -> int:
end_cursor = None
pbar = tqdm(desc="downloading posts")
@@ -223,22 +253,23 @@ class InstagramAPIExtractor(Extractor):
if not posts or not isinstance(posts, list) or len(posts) != 2:
break
posts, end_cursor = posts[0], posts[1]
logger.info(f"parsing {len(posts)} posts, next {end_cursor=}")
posts = posts[: min(max_to_download, len(posts))]
logger.info(f"Parsing {len(posts)} posts, next {end_cursor=} {post_count=} {max_to_download=}")
for p in posts:
try:
self.scrape_item(result, p, "post")
except Exception as e:
result.append("errors", f"Error downloading post {p.get('id')}")
logger.error(f"Error downloading post, skipping {p.get('id')}: {e}")
logger.error(f"Error downloading post, skipping {p.get('id')}: {e} {traceback.format_exc()}")
pbar.update(1)
post_count += 1
if self.full_profile_max_posts and post_count >= self.full_profile_max_posts:
logger.info(f"POSTS reached full_profile_max_posts={self.full_profile_max_posts}")
if post_count >= max_to_download:
logger.info(f"POSTS reached max_to_download={self.full_profile_max_posts}")
break
result.set("#posts", post_count)
return post_count
def download_all_tagged(self, result: Metadata, user_id: str):
def download_all_tagged(self, result: Metadata, user_id: str, max_to_download: int) -> int:
next_page_id = ""
pbar = tqdm(desc="downloading tagged posts")
@@ -250,22 +281,23 @@ class InstagramAPIExtractor(Extractor):
break
next_page_id = resp.get("next_page_id")
logger.info(f"parsing {len(posts)} tagged posts, next {next_page_id=}")
logger.info(f"Parsing {len(posts)} tagged posts, next {next_page_id=}")
posts = posts[: min(max_to_download, len(posts))]
for p in posts:
try:
self.scrape_item(result, p, "tagged")
except Exception as e:
result.append("errors", f"Error downloading tagged post {p.get('id')}")
logger.error(f"Error downloading tagged post, skipping {p.get('id')}: {e}")
logger.error(f"Error downloading tagged post, skipping {p.get('id')}: {e} {traceback.format_exc()}")
pbar.update(1)
tagged_count += 1
if self.full_profile_max_posts and tagged_count >= self.full_profile_max_posts:
logger.info(f"TAGS reached full_profile_max_posts={self.full_profile_max_posts}")
if tagged_count >= max_to_download:
logger.info(f"TAGS reached max_to_download={self.full_profile_max_posts}")
break
result.set("#tagged", tagged_count)
return tagged_count
### reusable parsing utils below
# reusable parsing utils below
def scrape_item(self, result: Metadata, item: dict, context: str = None) -> dict:
"""
@@ -319,7 +351,10 @@ class InstagramAPIExtractor(Extractor):
image_media = None
if image_url := item.get("thumbnail_url"):
filename = self.download_from_url(image_url, verbose=False)
image_media = Media(filename=filename)
if filename:
image_media = Media(filename=filename)
else:
logger.warning(f"Failed to download thumbnail from {image_url}")
# retrieve video info
best_id = item.get("id", item.get("pk"))
@@ -331,16 +366,19 @@ class InstagramAPIExtractor(Extractor):
if video_url := item.get("video_url"):
filename = self.download_from_url(video_url, verbose=False)
video_media = Media(filename=filename)
if taken_at:
video_media.set("date", taken_at)
if code:
video_media.set("url", f"https://www.instagram.com/p/{code}")
if caption_text:
video_media.set("text", caption_text)
video_media.set("preview", [image_media])
video_media.set("data", [item])
return item, video_media, f"{context or 'video'} {best_id}"
if filename:
video_media = Media(filename=filename)
if taken_at:
video_media.set("date", taken_at)
if code:
video_media.set("url", f"https://www.instagram.com/p/{code}")
if caption_text:
video_media.set("text", caption_text)
video_media.set("preview", [image_media])
video_media.set("data", [item])
return item, video_media, f"{context or 'video'} {best_id}"
else:
logger.warning(f"Failed to download video from {video_url}")
elif image_media:
if taken_at:
image_media.set("date", taken_at)

View File

@@ -7,8 +7,9 @@ highlights, and tagged posts. Authentication is required via username/password o
import re
import os
import shutil
import traceback
import instaloader
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Extractor
from auto_archiver.core import Metadata
@@ -29,8 +30,9 @@ class InstagramExtractor(Extractor):
# TODO: links to stories
def setup(self) -> None:
logger.warning("Instagram Extractor is not actively maintained, and may not work as expected.")
logger.warning("Please consider using the Instagram Tbot Extractor or Instagram API Extractor instead.")
logger.warning(
"Instagram Extractor is not actively maintained, and may not work as expected.\nPlease consider using the Instagram Tbot Extractor or Instagram API Extractor instead."
)
self.insta = instaloader.Instaloader(
download_geotags=True,
@@ -43,8 +45,7 @@ class InstagramExtractor(Extractor):
self.insta.load_session_from_file(self.username, self.session_file)
except Exception:
try:
logger.debug("Session file failed", exc_info=True)
logger.info("No valid session file found - Attempting login with use and password.")
logger.info("No valid session file found - Attempting login with username and password.")
self.insta.login(self.username, self.password)
self.insta.save_session_to_file(self.session_file)
except Exception as e:
@@ -79,7 +80,7 @@ class InstagramExtractor(Extractor):
return result
def download_post(self, url: str, post_id: str) -> Metadata:
logger.debug(f"Instagram {post_id=} detected in {url=}")
logger.debug(f"Instagram {post_id=} detected")
post = instaloader.Post.from_shortcode(self.insta.context, post_id)
if self.insta.download_post(post, target=post.owner_username):
@@ -87,7 +88,7 @@ class InstagramExtractor(Extractor):
def download_profile(self, url: str, username: str) -> Metadata:
# gets posts, posts where username is tagged, igtv postss, stories, and highlights
logger.debug(f"Instagram {username=} detected in {url=}")
logger.debug(f"Instagram {username=} detected")
profile = instaloader.Profile.from_username(self.insta.context, username)
try:
@@ -95,27 +96,27 @@ class InstagramExtractor(Extractor):
try:
self.insta.download_post(post, target=f"profile_post_{post.owner_username}")
except Exception as e:
logger.error(f"Failed to download post: {post.shortcode}: {e}")
logger.error(f"Failed to download post: {post.shortcode}: {e} {traceback.format_exc()}")
except Exception as e:
logger.error(f"Failed profile.get_posts: {e}")
logger.error(f"Failed profile.get_posts: {e}: {traceback.format_exc()}")
try:
for post in profile.get_tagged_posts():
try:
self.insta.download_post(post, target=f"tagged_post_{post.owner_username}")
except Exception as e:
logger.error(f"Failed to download tagged post: {post.shortcode}: {e}")
logger.error(f"Failed to download tagged post: {post.shortcode}: {e} {traceback.format_exc()}")
except Exception as e:
logger.error(f"Failed profile.get_tagged_posts: {e}")
logger.error(f"Failed profile.get_tagged_posts: {e} {traceback.format_exc()}")
try:
for post in profile.get_igtv_posts():
try:
self.insta.download_post(post, target=f"igtv_post_{post.owner_username}")
except Exception as e:
logger.error(f"Failed to download igtv post: {post.shortcode}: {e}")
logger.error(f"Failed to download igtv post: {post.shortcode}: {e} {traceback.format_exc()}")
except Exception as e:
logger.error(f"Failed profile.get_igtv_posts: {e}")
logger.error(f"Failed profile.get_igtv_posts: {e} {traceback.format_exc()}")
try:
for story in self.insta.get_stories([profile.userid]):
@@ -123,9 +124,9 @@ class InstagramExtractor(Extractor):
try:
self.insta.download_storyitem(item, target=f"story_item_{story.owner_username}")
except Exception as e:
logger.error(f"Failed to download story item: {item}: {e}")
logger.error(f"Failed to download story item: {item}: {e} {traceback.format_exc()}")
except Exception as e:
logger.error(f"Failed get_stories: {e}")
logger.error(f"Failed get_stories: {e} {traceback.format_exc()}")
try:
for highlight in self.insta.get_highlights(profile.userid):
@@ -133,9 +134,9 @@ class InstagramExtractor(Extractor):
try:
self.insta.download_storyitem(item, target=f"highlight_item_{highlight.owner_username}")
except Exception as e:
logger.error(f"Failed to download highlight item: {item}: {e}")
logger.error(f"Failed to download highlight item: {item}: {e} {traceback.format_exc()}")
except Exception as e:
logger.error(f"Failed get_highlights: {e}")
logger.error(f"Failed get_highlights: {e} {traceback.format_exc()}")
return self.process_downloads(url, f"@{username}", profile._asdict(), None)
@@ -158,4 +159,4 @@ class InstagramExtractor(Extractor):
return result.success("instagram")
except Exception as e:
logger.error(f"Could not fetch instagram post {url} due to: {e}")
logger.error(f"Could not fetch instagram post due to: {e} {traceback.format_exc()}")

View File

@@ -12,7 +12,7 @@ import shutil
import time
from sqlite3 import OperationalError
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from telethon.sync import TelegramClient
from auto_archiver.core import Extractor
@@ -32,7 +32,7 @@ class InstagramTbotExtractor(Extractor):
1. makes a copy of session_file that is removed in cleanup
2. checks if the session file is valid
"""
logger.info(f"SETUP {self.name} checking login...")
logger.debug(f"SETUP {self.name} checking login...")
self._prepare_session_file()
self._initialize_telegram_client()
@@ -58,10 +58,10 @@ class InstagramTbotExtractor(Extractor):
"If you do, disable at least one of the archivers for the first-time setup of the telethon session: {e}"
)
with self.client.start():
logger.success(f"SETUP {self.name} login works.")
logger.debug(f"SETUP {self.name} login works.")
def cleanup(self) -> None:
logger.info(f"CLEANUP {self.name}.")
logger.debug(f"CLEANUP {self.name}.")
session_file_name = self.session_file + ".session"
if os.path.exists(session_file_name):
os.remove(session_file_name)
@@ -79,15 +79,18 @@ class InstagramTbotExtractor(Extractor):
# This may be outdated and replaced by the below message, but keeping until confirmed
if "You must enter a URL to a post" in message:
logger.debug(f"invalid link {url=} for {self.name}: {message}")
logger.debug(f"Invalid link for {self.name}: {message}")
return False
if "Media not found or unavailable" in message:
logger.debug(f"No media found for link {url=} for {self.name}: {message}")
logger.debug(f"No media found for {self.name}: {message}")
return False
if message:
result.set_content(message).set_title(message[:128])
elif result.is_empty():
logger.debug(f"No media found for {self.name}: {message}")
return False
return result.success("insta-via-bot")
def _send_url_to_bot(self, url: str):
@@ -104,13 +107,13 @@ class InstagramTbotExtractor(Extractor):
message = ""
time.sleep(3)
# media is added before text by the bot so it can be used as a stop-logic mechanism
while attempts < max(self.timeout - 3, 3) and (not message or not len(seen_media)):
while attempts < max(self.timeout - 3, 15) and (not message or not len(seen_media)):
attempts += 1
time.sleep(1)
for post in self.client.iter_messages(chat, min_id=since_id):
since_id = max(since_id, post.id)
# Skip known filler message:
if post.message == "The bot receives information through https://hikerapi.com/p/hJqpppqi":
if "The bot receives information through https://hikerapi.com/" in post.message:
continue
if post.media and post.id not in seen_media:
filename_dest = os.path.join(tmp_dir, f"{chat.id}_{post.id}")

View File

@@ -0,0 +1 @@
from .json_enricher import JsonEnricher

View File

@@ -0,0 +1,16 @@
{
"name": "JSON Enricher",
"type": ["enricher"],
"requires_setup": True,
"dependencies": {
"python": ["loguru"],
},
"configs": {},
"description": """
Writes all archiving process metadata to a JSON file so it can be parsed by other tools. As this is an Enricher, it will not contain the final stored URLs.
WARNING: The resulting JSON may reveal sensitive information about the computer and settings in which the archiving process was run.
""",
}

View File

@@ -0,0 +1,17 @@
import json
from auto_archiver.utils.custom_logger import logger
import os
from auto_archiver.core import Enricher
from auto_archiver.core import Media, Metadata
class JsonEnricher(Enricher):
def enrich(self, to_enrich: Metadata) -> None:
logger.debug("Enriching as JSON")
item_path = os.path.join(self.tmp_dir, "metadata.json")
with open(item_path, mode="w", encoding="utf-8") as outf:
json.dump(to_enrich.to_dict(), outf, indent=4, default=str, ensure_ascii=False)
to_enrich.add_media(Media(filename=item_path), id="metadata_json")

View File

@@ -1,7 +1,7 @@
import shutil
from typing import IO
import os
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Media
from auto_archiver.core import Storage
@@ -38,8 +38,7 @@ class LocalStorage(Storage):
os.makedirs(os.path.dirname(dest), exist_ok=True)
logger.debug(f"[{self.__class__.__name__}] storing file {media.filename} with key {media.key} to {dest}")
res = shutil.copy2(media.filename, dest)
logger.info(res)
shutil.copy2(media.filename, dest)
return True
# must be implemented even if unused

View File

@@ -1,6 +1,6 @@
import datetime
import os
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Enricher
from auto_archiver.core import Metadata
@@ -12,22 +12,22 @@ class MetaEnricher(Enricher):
"""
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
if to_enrich.is_empty():
logger.debug(f"[SKIP] META_ENRICHER there is no media or metadata to enrich: {url=}")
logger.debug("[SKIP] META_ENRICHER there is no media or metadata to enrich")
return
logger.debug(f"calculating archive metadata information for {url=}")
logger.debug("Calculating archive metadata information")
self.enrich_file_sizes(to_enrich)
self.enrich_archive_duration(to_enrich)
def enrich_file_sizes(self, to_enrich: Metadata):
logger.debug(
f"calculating archive file sizes for url={to_enrich.get_url()} ({len(to_enrich.media)} media files)"
)
logger.debug(f"Calculating archive file sizes for {len(to_enrich.media)} media files")
total_size = 0
for media in to_enrich.get_all_media():
if not media.filename:
logger.warning(f"Skipping file size for media without filename: {media}")
continue
file_stats = os.stat(media.filename)
media.set("bytes", file_stats.st_size)
media.set("size", self.human_readable_bytes(file_stats.st_size))
@@ -44,7 +44,7 @@ class MetaEnricher(Enricher):
size /= 1024
def enrich_archive_duration(self, to_enrich):
logger.debug(f"calculating archive duration for url={to_enrich.get_url()} ")
logger.debug("Calculating archive duration")
archive_duration = datetime.datetime.now(datetime.timezone.utc) - to_enrich.get("_processed_at")
to_enrich.set("archive_duration_seconds", archive_duration.seconds)

View File

@@ -3,6 +3,13 @@
"type": ["enricher"],
"requires_setup": True,
"dependencies": {"python": ["loguru"], "bin": ["exiftool"]},
"configs": {
"look_for_keys": {
"default": [],
"help": "list of lowercased metadata keys that will be included in the enriched metadata. Special keys: 'author', 'datetimes', 'location' to include related metadata fields. The default empty list `[]` means all metadata will be included.",
"type": "list",
},
},
"description": """
Extracts metadata information from files using ExifTool.

View File

@@ -1,6 +1,6 @@
import subprocess
import traceback
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Enricher
from auto_archiver.core import Metadata
@@ -12,11 +12,12 @@ class MetadataEnricher(Enricher):
"""
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"extracting EXIF metadata for {url=}")
logger.debug("Extracting EXIF metadata")
for i, m in enumerate(to_enrich.media):
if len(md := self.get_metadata(m.filename)):
if self.look_for_keys != []:
md = self.select_metadata(md, self.look_for_keys)
to_enrich.media[i].set("metadata", md)
def get_metadata(self, filename: str) -> dict:
@@ -24,15 +25,44 @@ class MetadataEnricher(Enricher):
# Run ExifTool command to extract metadata from the file
cmd = ["exiftool", filename]
result = subprocess.run(cmd, capture_output=True, text=True)
# Process the output to extract individual metadata fields
metadata = {}
for line in result.stdout.splitlines():
field, value = line.strip().split(":", 1)
metadata[field.strip()] = value.strip()
return metadata
except FileNotFoundError:
logger.error("[exif_enricher] ExifTool not found. Make sure ExifTool is installed and added to PATH.")
except FileNotFoundError as e:
logger.error(f"ExifTool not found. Make sure ExifTool is installed and added to PATH. {e}")
except Exception as e:
logger.error(f"Error occurred: {e}: {traceback.format_exc()}")
return {}
def select_metadata(self, all_md, requested_metadata_keys):
"""
coordinates the selection of metadata from the general exiftool output to the user-specified grocery list
"""
# defining the batches of metadata that get pulled for special terms
author_key_terms = ["author", "producer", "creator"]
datetime_key_terms = ["date", "time"]
location_key_terms = ["gps", "latitude", "longitude"]
specified_md = {}
for md_key in all_md.keys():
md_key_lower = md_key.lower()
# checking for special baskets within the grocery list of requested metadata
if ("author" in requested_metadata_keys) and any(
term in md_key_lower and len(all_md[md_key]) for term in author_key_terms
):
specified_md[md_key] = all_md[md_key]
if ("datetime" in requested_metadata_keys) and any(
term in md_key_lower and len(all_md[md_key]) for term in datetime_key_terms
):
specified_md[md_key] = all_md[md_key]
if ("location" in requested_metadata_keys) and any(
term in md_key_lower and len(all_md[md_key]) for term in location_key_terms
):
specified_md[md_key] = all_md[md_key]
# if the metadata value is requested directly
if md_key_lower in requested_metadata_keys or md_key in requested_metadata_keys and len(all_md[md_key]):
specified_md[md_key] = all_md[md_key]
return specified_md

View File

@@ -1,6 +1,7 @@
import os
import traceback
from loguru import logger
from auto_archiver.utils.custom_logger import logger
import opentimestamps
from opentimestamps.calendar import RemoteCalendar, DEFAULT_CALENDAR_WHITELIST
from opentimestamps.core.timestamp import Timestamp, DetachedTimestampFile
@@ -14,13 +15,12 @@ from auto_archiver.utils.misc import get_current_timestamp
class OpentimestampsEnricher(Enricher):
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"OpenTimestamps timestamping files for {url=}")
logger.debug("OpenTimestamps timestamping files")
# Get the media files to timestamp
media_files = [m for m in to_enrich.media if m.filename and not m.get("opentimestamps")]
if not media_files:
logger.warning(f"No files found to timestamp in {url=}")
logger.debug("No files found to timestamp")
return
timestamp_files = []
@@ -94,7 +94,7 @@ class OpentimestampsEnricher(Enricher):
detached_timestamp.serialize(ctx)
f.write(ctx.getbytes())
except Exception as e:
logger.warning(f"Failed to serialize timestamp file: {e}")
logger.warning(f"Failed to serialize timestamp file: {e} {traceback.format_exc()}")
continue
# Create media for the timestamp file
@@ -113,16 +113,16 @@ class OpentimestampsEnricher(Enricher):
media.set("opentimestamps", True)
except Exception as e:
logger.warning(f"Error while timestamping {media.filename}: {e}")
logger.warning(f"Error while timestamping {media.filename}: {e} {traceback.format_exc()}")
# Add timestamp files to the metadata
if timestamp_files:
to_enrich.set("opentimestamped", True)
to_enrich.set("opentimestamps_count", len(timestamp_files))
logger.success(f"{len(timestamp_files)} OpenTimestamps proofs created for {url=}")
logger.info(f"{len(timestamp_files)} OpenTimestamps proofs created")
else:
to_enrich.set("opentimestamped", False)
logger.warning(f"No successful timestamps created for {url=}")
logger.warning("No successful timestamps created")
def verify_timestamp(self, detached_timestamp):
"""

View File

@@ -15,7 +15,7 @@
- Skips non-image media or files unsuitable for hashing (e.g., corrupted or unsupported formats).
### Notes
- Best used after enrichers like `thumbnail_enricher` or `screenshot_enricher` to ensure images are available.
- Best used after enrichers like `thumbnail_enricher` or `antibot_extractor_enricher` (takes screenshots) to ensure images are available.
- Uses the `pdqhash` library to compute 256-bit perceptual hashes, which are stored as hexadecimal strings.
""",
}

View File

@@ -6,7 +6,7 @@ objects and calculates perceptual hashes using the PDQ hashing algorithm.
These hashes are designed specifically for images and can be used
for detecting duplicate or near-duplicate visual content.
This enricher is typically used after thumbnail or screenshot enrichers
This enricher is typically used after thumbnail or screenshot (antibot) enrichers
to ensure images are available for hashing.
"""
@@ -15,7 +15,7 @@ import traceback
import pdqhash
import numpy as np
from PIL import Image, UnidentifiedImageError
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Enricher
from auto_archiver.core import Metadata
@@ -28,8 +28,7 @@ class PdqHashEnricher(Enricher):
"""
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"calculating perceptual hashes for {url=}")
logger.debug("Calculating perceptual hashes")
media_with_hashes = []
for m in to_enrich.media:
@@ -44,7 +43,7 @@ class PdqHashEnricher(Enricher):
media.set("pdq_hash", hd)
media_with_hashes.append(media.filename)
logger.debug(f"calculated '{len(media_with_hashes)}' perceptual hashes for {url=}: {media_with_hashes}")
logger.debug(f"Calculated '{len(media_with_hashes)}' perceptual hashes: {media_with_hashes}")
def calculate_pdq_hash(self, filename):
# returns a hexadecimal string with the perceptual hash for the given filename

View File

@@ -2,7 +2,7 @@ from typing import IO
import boto3
import os
from loguru import logger
from auto_archiver.utils.custom_logger import logger
from auto_archiver.core import Media
from auto_archiver.core import Storage
@@ -40,6 +40,8 @@ class S3Storage(Storage):
try:
if media.mimetype:
extra_args["ContentType"] = media.mimetype
if "text" in media.mimetype:
extra_args["ContentType"] += "; charset=utf-8"
except Exception as e:
logger.warning(f"Unable to get mimetype for {media.key=}, error: {e}")
self.s3.upload_fileobj(file, Bucket=self.bucket, Key=media.key, ExtraArgs=extra_args)
@@ -54,7 +56,7 @@ class S3Storage(Storage):
if existing_key := self.file_in_folder(path):
media._key = existing_key
media.set("previously archived", True)
logger.debug(f"skipping upload of {media.filename} because it already exists in {media.key}")
logger.debug(f"Skipping upload of {media.filename} because it already exists in {media.key}")
return False
_, ext = os.path.splitext(media.key)

View File

@@ -1 +0,0 @@
from .screenshot_enricher import ScreenshotEnricher

View File

@@ -1,44 +0,0 @@
{
"name": "Screenshot Enricher",
"type": ["enricher"],
"requires_setup": True,
"dependencies": {
"python": ["loguru", "selenium"],
},
"configs": {
"width": {"default": 1280, "type": "int", "help": "width of the screenshots"},
"height": {"default": 1024, "type": "int", "help": "height of the screenshots"},
"timeout": {"default": 60, "type": "int", "help": "timeout for taking the screenshot"},
"sleep_before_screenshot": {
"default": 4,
"type": "int",
"help": "seconds to wait for the pages to load before taking screenshot",
},
"http_proxy": {
"default": "",
"help": "http proxy to use for the webdriver, eg http://proxy-user:password@proxy-ip:port",
},
"save_to_pdf": {
"default": False,
"type": "bool",
"help": "save the page as pdf along with the screenshot. PDF saving options can be adjusted with the 'print_options' parameter",
},
"print_options": {
"default": {},
"help": "options to pass to the pdf printer, in JSON format. See https://www.selenium.dev/documentation/webdriver/interactions/print_page/ for more information",
"type": "json_loader",
},
},
"description": """
Captures screenshots and optionally saves web pages as PDFs using a WebDriver.
### Features
- Takes screenshots of web pages, with configurable width, height, and timeout settings.
- Optionally saves pages as PDFs, with additional configuration for PDF printing options.
- Bypasses URLs detected as authentication walls.
- Integrates seamlessly with the metadata enrichment pipeline, adding screenshots and PDFs as media.
### Notes
- Requires a WebDriver (e.g., ChromeDriver) installed and accessible via the system's PATH.
""",
}

View File

@@ -1,61 +0,0 @@
from loguru import logger
import time
import os
import base64
from selenium.common.exceptions import TimeoutException
from auto_archiver.core import Enricher
from auto_archiver.utils import Webdriver, url as UrlUtil, random_str
from auto_archiver.core import Media, Metadata
class ScreenshotEnricher(Enricher):
def __init__(self, webdriver_factory=None):
super().__init__()
self.webdriver_factory = webdriver_factory or Webdriver
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"Enriching screenshot for {url=}")
auth = self.auth_for_site(url)
# screenshot enricher only supports cookie-type auth (selenium)
has_valid_auth = auth and (auth.get("cookies") or auth.get("cookies_jar") or auth.get("cookie"))
if UrlUtil.is_auth_wall(url) and not has_valid_auth:
logger.warning(f"[SKIP] SCREENSHOT since url is behind AUTH WALL and no login details provided: {url=}")
if any(auth.get(key) for key in ["username", "password", "api_key", "api_secret"]):
logger.warning(
f"Screenshot enricher only supports cookie-type authentication, you have provided {auth.keys()} which are not supported.\
Consider adding 'cookie', 'cookies_file' or 'cookies_from_browser' to your auth for this site."
)
return
with self.webdriver_factory(
self.width,
self.height,
self.timeout,
facebook_accept_cookies="facebook.com" in url,
http_proxy=self.http_proxy,
print_options=self.print_options,
auth=auth,
) as driver:
try:
driver.get(url)
time.sleep(int(self.sleep_before_screenshot))
screenshot_file = os.path.join(self.tmp_dir, f"screenshot_{random_str(8)}.png")
driver.save_screenshot(screenshot_file)
to_enrich.add_media(Media(filename=screenshot_file), id="screenshot")
if self.save_to_pdf:
pdf_file = os.path.join(self.tmp_dir, f"pdf_{random_str(8)}.pdf")
pdf = driver.print_page(driver.print_options)
with open(pdf_file, "wb") as f:
f.write(base64.b64decode(pdf))
to_enrich.add_media(Media(filename=pdf_file), id="pdf")
except TimeoutException:
logger.info("TimeoutException loading page for screenshot")
except Exception as e:
logger.error(f"Got error while loading webdriver for screenshot enricher: {e}")

Some files were not shown because too many files have changed in this diff Show More