Compare commits

..

104 Commits

Author SHA1 Message Date
dependabot[bot]
d1e0adb773 Bump the python group across 1 directory with 9 updates
Bumps the python group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pytest-loguru](https://github.com/mcarans/pytest-loguru) | `0.4.0` | `0.4.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.11` | `0.15.15` |
| [sphinxcontrib-mermaid](https://github.com/mgaitan/sphinxcontrib-mermaid) | `1.2.3` | `2.0.2` |
| [google-api-python-client](https://github.com/googleapis/google-api-python-client) | `2.194.0` | `2.197.0` |
| [google-auth-httplib2](https://github.com/googleapis/google-cloud-python) | `0.3.1` | `0.4.0` |
| [google-auth-oauthlib](https://github.com/googleapis/google-cloud-python) | `1.3.1` | `1.4.0` |
| [boto3](https://github.com/boto/boto3) | `1.42.94` | `1.43.20` |
| [rich-argparse](https://github.com/hamdanal/rich-argparse) | `1.7.2` | `1.8.0` |
| [cryptography](https://github.com/pyca/cryptography) | `46.0.7` | `48.0.0` |



Updates `pytest-loguru` from 0.4.0 to 0.4.1
- [Release notes](https://github.com/mcarans/pytest-loguru/releases)
- [Commits](https://github.com/mcarans/pytest-loguru/compare/0.4.0...0.4.1)

Updates `ruff` from 0.15.11 to 0.15.15
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.11...0.15.15)

Updates `sphinxcontrib-mermaid` from 1.2.3 to 2.0.2
- [Changelog](https://github.com/mgaitan/sphinxcontrib-mermaid/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mgaitan/sphinxcontrib-mermaid/compare/1.2.3...2.0.2)

Updates `google-api-python-client` from 2.194.0 to 2.197.0
- [Release notes](https://github.com/googleapis/google-api-python-client/releases)
- [Commits](https://github.com/googleapis/google-api-python-client/compare/v2.194.0...v2.197.0)

Updates `google-auth-httplib2` from 0.3.1 to 0.4.0
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-httplib2-v0.3.1...google-auth-httplib2-v0.4.0)

Updates `google-auth-oauthlib` from 1.3.1 to 1.4.0
- [Release notes](https://github.com/googleapis/google-cloud-python/releases)
- [Changelog](https://github.com/googleapis/google-cloud-python/blob/main/packages/gcp-sphinx-docfx-yaml/CHANGELOG.md)
- [Commits](https://github.com/googleapis/google-cloud-python/compare/google-auth-oauthlib-v1.3.1...google-auth-oauthlib-v1.4.0)

Updates `boto3` from 1.42.94 to 1.43.20
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.42.94...1.43.20)

Updates `rich-argparse` from 1.7.2 to 1.8.0
- [Release notes](https://github.com/hamdanal/rich-argparse/releases)
- [Changelog](https://github.com/hamdanal/rich-argparse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hamdanal/rich-argparse/compare/v1.7.2...v1.8.0)

Updates `cryptography` from 46.0.7 to 48.0.0
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.0)

---
updated-dependencies:
- dependency-name: pytest-loguru
  dependency-version: 0.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: ruff
  dependency-version: 0.15.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: sphinxcontrib-mermaid
  dependency-version: 2.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: python
- dependency-name: google-api-python-client
  dependency-version: 2.197.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python
- dependency-name: google-auth-httplib2
  dependency-version: 0.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python
- dependency-name: google-auth-oauthlib
  dependency-version: 1.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python
- dependency-name: boto3
  dependency-version: 1.43.20
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python
- dependency-name: rich-argparse
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python
- dependency-name: cryptography
  dependency-version: 48.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: python
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 06:40:09 +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
65 changed files with 5024 additions and 2128 deletions

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -33,14 +33,14 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: bellingcat/auto-archiver

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

@@ -20,11 +20,11 @@ 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

@@ -26,13 +26,13 @@ jobs:
working-directory: ./
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- 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 }}
@@ -40,7 +40,7 @@ jobs:
run: pipx install poetry
- name: Cache Poetry and pip artifacts
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cache/pypoetry

View File

@@ -20,13 +20,13 @@ jobs:
working-directory: ./
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- 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 }}
@@ -34,7 +34,7 @@ jobs:
run: pipx install poetry
- name: Cache Poetry and pip artifacts
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.cache/pypoetry

View File

@@ -1,18 +1,17 @@
FROM webrecorder/browsertrix-crawler:1.6.3 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 apt-get update && \
apt-get install -y --no-install-recommends gcc ffmpeg fonts-noto exiftool python3-tk
apt-get install -y --no-install-recommends gcc ffmpeg fonts-noto exiftool python3-tk
# Poetry and runtime
FROM base AS runtime
@@ -41,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

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

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

@@ -8,7 +8,7 @@
## 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
@@ -26,7 +26,7 @@ pytest -ra -v tests/test_file.py
pytest -ra -v tests/test_file.py::test_function_name
```
3. Some tests require environment variables to be set. You can use the example `.env.test.example` file as a template. Copy it to `.env.test` and fill in the required values. This file will be loaded automatically by `pytest`.
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 .env.test.example .env.test
```
cp tests/.env.test.example tests/.env.test
```

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

3078
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 = "1.1.1"
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"
@@ -28,9 +28,9 @@ dependencies = [
"loguru (>=0.0.0)",
"ffmpeg-python (>=0.0.0)",
"telethon (>=0.0.0)",
"google-api-python-client (>=0.0.0)",
"google-auth-httplib2 (>=0.0.0)",
"google-auth-oauthlib (>=0.0.0)",
"google-api-python-client (>=2.197.0)",
"google-auth-httplib2 (>=0.4.0)",
"google-auth-oauthlib (>=1.4.0)",
"oauth2client (>=0.0.0)",
"pdqhash (>=0.0.0)",
"pillow (>=0.0.0)",
@@ -40,7 +40,7 @@ dependencies = [
"instaloader (>=0.0.0)",
"tqdm (>=0.0.0)",
"jinja2 (>=0.0.0)",
"boto3 (>=1.28.0,<2.0.0)",
"boto3 (>=1.43.20,<2.0.0)",
"dataclasses-json (>=0.0.0)",
"numpy (==2.1.3)",
"requests[socks] (>=0.0.0)",
@@ -48,30 +48,31 @@ dependencies = [
"jsonlines (>=0.0.0)",
"pysubs2 (>=0.0.0)",
"retrying (>=0.0.0)",
"rich-argparse (>=1.6.0,<2.0.0)",
"rich-argparse (>=1.8.0,<2.0.0)",
"ruamel-yaml (>=0.18.10,<0.19.0)",
"rfc3161-client (==1.0.3)",
"cryptography (>44.0.1,<45.0.0)",
"rfc3161-client (>=1.0.5)",
"cryptography (>=48.0.0)",
"opentimestamps (>=0.4.5,<0.5.0)",
"bgutil-ytdlp-pot-provider (>=1.0.0)",
"yt-dlp[curl-cffi,default] (>=2025.5.22,<2026.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]
pytest = "^8.3.4"
autopep8 = "^2.3.1"
pytest-loguru = "^0.4.0"
pytest-loguru = "^0.4.1"
pytest-mock = "^3.14.0"
ruff = "^0.9.10"
ruff = "^0.15.15"
pre-commit = "^4.1.0"
[tool.poetry.group.docs.dependencies]
sphinx = "^8.1.3"
sphinx-autoapi = "^3.4.0"
sphinxcontrib-mermaid = "^1.0.0"
sphinxcontrib-mermaid = "^2.0.2"
sphinx-autobuild = "^2024.10.3"
sphinx-copybutton = "^0.5.2"
myst-parser = "^4.0.0"

File diff suppressed because it is too large Load Diff

View File

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

@@ -467,7 +467,11 @@ 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()
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

View File

@@ -16,6 +16,7 @@ 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):
@@ -72,6 +73,7 @@ class AntibotExtractorEnricher(Extractor, Enricher):
if self.enrich(result):
result.status = "antibot"
return result
return False
def _prepare_user_data_dir(self):
if self.user_data_dir:
@@ -81,11 +83,24 @@ class AntibotExtractorEnricher(Extractor, Enricher):
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) as sb:
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)
@@ -94,16 +109,33 @@ class AntibotExtractorEnricher(Extractor, Enricher):
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)
dropin.open_page(url)
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 self._hit_auth_wall(sb):
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
to_enrich.set_title(sb.get_title())
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)
@@ -274,8 +306,14 @@ class AntibotExtractorEnricher(Extractor, Enricher):
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
sources = sb.execute_script(js_css_selector)
# js_for_css_selectors
for src in sources:
if len(all_urls) >= max_media:

View File

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

View File

@@ -1,3 +1,4 @@
import json
import os
import traceback
from typing import Mapping
@@ -74,8 +75,11 @@ class Dropin:
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("{self.images_selectors()}")).map(el => el.src || el.href).filter(Boolean);
return Array.from(document.querySelectorAll({safe_selector})).map(el => el.src || el.href).filter(Boolean);
"""
def js_for_video_css_selectors(self) -> str:
@@ -84,8 +88,11 @@ class Dropin:
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("{self.video_selectors()}")).map(el => el.src || el.href).filter(Boolean);
return Array.from(document.querySelectorAll({safe_selector})).map(el => el.src || el.href).filter(Boolean);
"""
def open_page(self, url) -> bool:
@@ -103,6 +110,12 @@ class Dropin:
"""
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.

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

@@ -60,6 +60,10 @@ If you are having issues with the extractor, you can review the version of `yt-d
"default": "",
"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 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.",

View File

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

@@ -4,6 +4,7 @@ import datetime
import os
import importlib
import subprocess
import traceback
import zipfile
from typing import Generator, Type
@@ -20,6 +21,7 @@ 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
@@ -202,8 +204,11 @@ 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"Could not download cover image {thumbnail_url}: {e}")
@@ -305,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.get("description"))
result.set_content(video_data.pop("description"))
# extract comments if enabled
if self.comments and video_data.get("comments", []) is not None:
if self.comments and video_data.get("comments", None) is not None:
result.set(
"comments",
[
@@ -353,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)
@@ -406,9 +411,9 @@ 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 {entry}, skipping.")
logger.info(f"No media found for entry {str(entry)[:256]}, skipping.")
return False
return self.add_metadata(data, info_extractor, url, result)
@@ -483,6 +488,13 @@ 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)
# 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:
@@ -502,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:
@@ -513,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()}"
@@ -525,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
@@ -533,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"),
@@ -543,10 +575,12 @@ class GenericExtractor(Extractor):
"--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
@@ -591,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,3 +1,4 @@
import re
import requests
from auto_archiver.utils.custom_logger import logger
@@ -14,12 +15,16 @@ class Tiktok(GenericDropin):
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("Using Tikwm API to attempt to download tiktok video")
@@ -28,56 +33,91 @@ class Tiktok(GenericDropin):
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("Failed to download video")
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

@@ -7,7 +7,10 @@ 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 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):
@@ -28,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"]
@@ -37,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}")
@@ -68,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

@@ -32,26 +32,37 @@ 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:
return self.gsheets_client.open_by_key(self.sheet_id)
@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
def __iter__(self) -> Iterator[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.debug(
f"Skipped worksheet '{worksheet.title}' due to missing required column(s) for {missing_cols}"
)
continue
with logger.contextualize(worksheet=f"{sh.title}:{worksheet.title}"):
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}")

View File

@@ -25,6 +25,9 @@ class HashEnricher(Enricher):
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

@@ -99,7 +99,10 @@ 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:
@@ -202,7 +205,10 @@ class InstagramAPIExtractor(Extractor):
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))]
@@ -345,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"))
@@ -357,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

@@ -25,6 +25,9 @@ class MetaEnricher(Enricher):
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))

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

@@ -16,6 +16,8 @@ class MetadataEnricher(Enricher):
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:
@@ -23,7 +25,6 @@ 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():
@@ -35,3 +36,33 @@ class MetadataEnricher(Enricher):
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

@@ -49,10 +49,18 @@ class TelegramExtractor(Extractor):
if not len(image_urls):
return False
for img_url in image_urls:
result.add_media(Media(self.download_from_url(img_url)))
filename = self.download_from_url(img_url)
if not filename:
logger.warning(f"Failed to download image from {img_url}")
continue
result.add_media(Media(filename))
else:
video_url = video.get("src")
m_video = Media(self.download_from_url(video_url))
video_filename = self.download_from_url(video_url)
if not video_filename:
logger.warning(f"Failed to download video from {video_url}")
return False
m_video = Media(video_filename)
# extract duration from HTML
try:
duration = s.find_all("time")[0].contents[0]

View File

@@ -1,3 +1,4 @@
import asyncio
import os
import shutil
import re
@@ -53,6 +54,16 @@ class TelethonExtractor(Extractor):
logger.debug(f"Making a copy of the session file {base_session_filepath} to {self.session_file}.session")
shutil.copy(base_session_filepath, f"{self.session_file}.session")
# ensure a running event loop exists (Needed when used by Celery workers which may close the default one)
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# initiate the client
self.client = TelegramClient(self.session_file, self.api_id, self.api_hash)
@@ -190,6 +201,9 @@ class TelethonExtractor(Extractor):
)
for i, om_url in enumerate(other_media_urls):
filename = self.download_from_url(om_url, f"{chat}_{group_id}_{i}")
if not filename:
logger.warning(f"Failed to download media from {om_url}")
continue
result.add_media(Media(filename=filename), id=f"{group_id}_{i}")
filename_dest = os.path.join(self.tmp_dir, f"{chat}_{group_id}", str(mp.id))

View File

@@ -20,7 +20,7 @@
# "http://tsa.sinpe.fi.cr/tsaHttp/", # self-signed
# "http://tsa.cra.ge/signserver/tsa?workerName=qtsa", # self-signed
"http://tss.cnbs.gob.hn/TSS/HttpTspServer",
"http://dss.nowina.lu/pki-factory/tsa/good-tsa",
# "http://dss.nowina.lu/pki-factory/tsa/good-tsa",
# "https://freetsa.org/tsr", # self-signed
],
"help": "List of RFC3161 Time Stamp Authorities to use, separate with commas if passed via the command line.",

View File

@@ -4,12 +4,12 @@ from importlib.metadata import version
import hashlib
from slugify import slugify
from retrying import retry
import requests
from auto_archiver.utils.custom_logger import logger
from rfc3161_client import (decode_timestamp_response,TimestampRequestBuilder,TimeStampResponse, VerifierBuilder)
from rfc3161_client import (decode_timestamp_response, TimestampRequestBuilder, TimeStampResponse, VerifierBuilder)
from rfc3161_client import VerificationError as Rfc3161VerificationError
from rfc3161_client.base import HashAlgorithm
from rfc3161_client.tsp import SignedData
from cryptography import x509
from cryptography.hazmat.primitives import serialization
@@ -60,7 +60,6 @@ class TimestampingEnricher(Enricher):
logger.debug(f"No hashes found")
return
hashes_fn = os.path.join(self.tmp_dir, "hashes.txt")
data_to_sign = "\n".join(hashes)
@@ -75,7 +74,7 @@ class TimestampingEnricher(Enricher):
logger.debug(f"Timestamping with {tsa_url=}")
signed: TimeStampResponse = self.sign_data(tsa_url, message)
# fail if there's any issue with the certificates, uses certifi list of trusted CAs or the user-defined `cert_authorities`
root_cert = self.verify_signed(signed, message)
@@ -113,7 +112,7 @@ class TimestampingEnricher(Enricher):
f.write(timestamp_token)
return tst_path
def verify_signed(self, timestamp_response: TimeStampResponse, message: bytes) -> x509.Certificate:
def verify_signed(self, timestamp_response: TimeStampResponse, message: bytes) -> x509.Certificate:
"""
Verify a Signed Timestamp Response is trusted by a known Certificate Authority.
@@ -136,7 +135,7 @@ class TimestampingEnricher(Enricher):
if not cert_authorities:
raise ValueError(f"No trusted roots found in {trusted_root_path}.")
timestamp_certs = self.tst_certs(timestamp_response)
intermediate_certs = timestamp_certs[1:-1]
@@ -148,7 +147,7 @@ class TimestampingEnricher(Enricher):
message_hash = hashlib.sha256(message).digest()
else:
raise ValueError(f"Unsupported hash algorithm: {hash_algorithm}")
for certificate in cert_authorities:
builder = VerifierBuilder()
builder.add_root_certificate(certificate)
@@ -158,7 +157,6 @@ class TimestampingEnricher(Enricher):
verifier = builder.build()
try:
verifier.verify(timestamp_response, message_hash)
return certificate
@@ -171,23 +169,38 @@ class TimestampingEnricher(Enricher):
# see https://github.com/sigstore/sigstore-python/blob/99948d5b80525a5a104e904ffea58169dc6e0629/sigstore/_internal/timestamp.py#L84-L121
timestamp_request = (
TimestampRequestBuilder().data(bytes_data).nonce(nonce=True).build()
)
try:
TimestampRequestBuilder().data(bytes_data).nonce(nonce=True).build()
)
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=2,
)
def sign_with_retry():
response = self.session.post(tsa_url, data=timestamp_request.as_bytes(), timeout=10)
response.raise_for_status()
return response
try:
response = sign_with_retry()
except requests.RequestException as e:
logger.error(f"Error while sending request to {tsa_url=}: {e}")
raise
@retry(
wait_exponential_multiplier=1,
stop_max_attempt_number=2,
)
def decode_with_retry(response):
return decode_timestamp_response(response.content)
# Check that we can parse the response but do not *verify* it
try:
timestamp_response = decode_timestamp_response(response.content)
timestamp_response = decode_with_retry(response)
except ValueError as e:
logger.error(f"Invalid timestamp response from server {tsa_url}: {e}")
raise
return timestamp_response
def tst_certs(self, tsp_response: TimeStampResponse):
signed_data: SignedData = tsp_response.signed_data
certs = [x509.load_der_x509_certificate(c) for c in signed_data.certificates]
@@ -196,7 +209,7 @@ class TimestampingEnricher(Enricher):
if len(certs) == 1:
return certs
while(len(ordered_certs) < len(certs)):
while (len(ordered_certs) < len(certs)):
if len(ordered_certs) == 0:
for cert in certs:
if not [c for c in certs if cert.subject == c.issuer]:
@@ -220,7 +233,7 @@ class TimestampingEnricher(Enricher):
cert_chain = []
for i, cert in enumerate(certificates):
cert_fn = os.path.join(self.tmp_dir, f"{i+1} {str(cert.serial_number)[:20]}.crt")
cert_fn = os.path.join(self.tmp_dir, f"{i + 1} {str(cert.serial_number)[:20]}.crt")
with open(cert_fn, "wb") as f:
f.write(cert.public_bytes(encoding=serialization.Encoding.PEM))
cert_chain.append(Media(filename=cert_fn).set("subject", cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value))

View File

@@ -114,6 +114,9 @@ class TwitterApiExtractor(Extractor):
logger.info(f"Found media {media}")
ext = mimetypes.guess_extension(mimetype)
media.filename = self.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)
result.set_content(

View File

@@ -24,8 +24,7 @@ class WaczExtractorEnricher(Enricher, Extractor):
self.use_docker = os.environ.get("WACZ_ENABLE_DOCKER") or not os.environ.get("RUNNING_IN_DOCKER")
self.docker_in_docker = os.environ.get("WACZ_ENABLE_DOCKER") and os.environ.get("RUNNING_IN_DOCKER")
self.crawl_id = random_str(8)
self.cwd_dind = f"/crawls/crawls{self.crawl_id}"
self.cwd_dind = f"/crawls/crawls{random_str(8)}"
self.browsertrix_home_host = os.environ.get("BROWSERTRIX_HOME_HOST")
self.browsertrix_home_container = os.environ.get("BROWSERTRIX_HOME_CONTAINER") or self.browsertrix_home_host
# create crawls folder if not exists, so it can be safely removed in cleanup
@@ -51,7 +50,8 @@ class WaczExtractorEnricher(Enricher, Extractor):
url = to_enrich.get_url()
collection = self.crawl_id
crawl_id = random_str(8)
collection = crawl_id
browsertrix_home_host = self.browsertrix_home_host or os.path.abspath(self.tmp_dir)
browsertrix_home_container = self.browsertrix_home_container or browsertrix_home_host
@@ -83,8 +83,10 @@ class WaczExtractorEnricher(Enricher, Extractor):
# "--blockAds" # note: this has been known to cause issues on cloudflare protected sites
]
crawl_cwd_dind = os.path.join(self.cwd_dind, crawl_id)
if self.docker_in_docker:
cmd.extend(["--cwd", self.cwd_dind])
os.makedirs(crawl_cwd_dind, exist_ok=True)
cmd.extend(["--cwd", crawl_cwd_dind])
if self.auth_for_site(url):
# there's an auth for this site, but browsertrix only supports username/password auth
@@ -109,7 +111,7 @@ class WaczExtractorEnricher(Enricher, Extractor):
] + cmd
if self.profile:
profile_file = f"profile-{self.crawl_id}.tar.gz"
profile_file = f"profile-{crawl_id}.tar.gz"
profile_fn = os.path.join(browsertrix_home_container, profile_file)
logger.debug(f"Copying {self.profile} to {profile_fn}")
shutil.copyfile(self.profile, profile_fn)
@@ -137,7 +139,7 @@ class WaczExtractorEnricher(Enricher, Extractor):
return False
if self.docker_in_docker:
wacz_fn = os.path.join(self.cwd_dind, "collections", collection, f"{collection}.wacz")
wacz_fn = os.path.join(crawl_cwd_dind, "collections", collection, f"{collection}.wacz")
elif self.use_docker:
wacz_fn = os.path.join(browsertrix_home_container, "collections", collection, f"{collection}.wacz")
else:
@@ -152,7 +154,7 @@ class WaczExtractorEnricher(Enricher, Extractor):
self.extract_media_from_wacz(to_enrich, wacz_fn)
if self.docker_in_docker:
jsonl_fn = os.path.join(self.cwd_dind, "collections", collection, "pages", "pages.jsonl")
jsonl_fn = os.path.join(crawl_cwd_dind, "collections", collection, "pages", "pages.jsonl")
elif self.use_docker:
jsonl_fn = os.path.join(browsertrix_home_container, "collections", collection, "pages", "pages.jsonl")
else:

View File

@@ -2,7 +2,7 @@ import json
from auto_archiver.utils.custom_logger import logger
import time
import requests
from urllib3.exceptions import MaxRetryError
from auto_archiver.core import Extractor, Enricher
from auto_archiver.utils import url as UrlUtil
from auto_archiver.core import Metadata
@@ -45,7 +45,14 @@ class WaybackExtractorEnricher(Enricher, Extractor):
if self.if_not_archived_within:
post_data["if_not_archived_within"] = self.if_not_archived_within
# see https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA for more options
r = requests.post("https://web.archive.org/save/", headers=ia_headers, data=post_data, proxies=proxies)
try:
r = requests.post("https://web.archive.org/save/", headers=ia_headers, data=post_data, proxies=proxies)
except MaxRetryError as e:
logger.warning(
f"MaxRetryError during Wayback POST call to /save, this may be do to a high number of calls leading to rate limiting: {e}"
)
to_enrich.set("wayback", "failed: possible rate limit")
return False
if r.status_code != 200:
logger.error(em := f"Internet archive failed with status of {r.status_code}: {r.json()}")
@@ -76,6 +83,9 @@ class WaybackExtractorEnricher(Enricher, Extractor):
if r_status.status_code == 200 and r_json["status"] == "success":
wayback_url = f"https://web.archive.org/web/{r_json['timestamp']}/{r_json['original_url']}"
elif r_status.status_code != 200 or r_json["status"] != "pending":
if r_json.get("status_ext") in ["error:blocked-url", "error:unauthorized"]:
logger.warning("Wayback cannot currently archive the URL, skipping.")
to_enrich.set("wayback", r_json.get("status_ext"))
logger.error(f"Wayback failed with {r_json}")
return False
except requests.exceptions.RequestException as e:

View File

@@ -2,6 +2,13 @@ from loguru import logger
import json
def type_serializer(obj):
"""Fallback function for objects json can't handle."""
if isinstance(obj, type):
return obj.__name__
return str(obj)
def extract_location(record, short=False):
"""Extracts the file name, function name, and line number from the log record."""
if short:
@@ -35,11 +42,11 @@ def serialize_for_console(record):
subset.pop("time", None)
if not subset:
return ""
return json.dumps(subset, ensure_ascii=False)
return json.dumps(subset, ensure_ascii=False, default=type_serializer)
def serialize(record):
return json.dumps(extract_log_data(record), ensure_ascii=False)
return json.dumps(extract_log_data(record), ensure_ascii=False, default=type_serializer)
def patching(record):

View File

@@ -0,0 +1,273 @@
"""
Deletion Detection Utilities
Provides a best-effort detection of deleted, missing, or unavailable content
across various social media platforms based on presence of expected keywords.
This module helps identify removed content, helps to:
- Document content that existed but was deleted
- Track patterns of content removal
- Preserve metadata about missing content
"""
from typing import Optional, Dict, List
from auto_archiver.utils.custom_logger import logger
from urllib.parse import urlparse
class DeletionIndicators:
"""
Platform-specific indicators that content has been deleted or is unavailable, alongside generic indicators.
"""
# Twitter/X deletion indicators
TWITTER = [
"Hmm...this page doesn't exist",
"Try searching for something else",
"This Tweet is unavailable",
"This account doesn't exist",
"This Tweet has been deleted",
"This account has been suspended",
"Sorry, that page doesn't exist",
"The Tweet you're looking for isn't available",
]
# Facebook deletion indicators
FACEBOOK = [
"This content isn't available",
"Sorry, this content isn't available",
"This content is no longer available",
"The link you followed may be broken",
"Page Not Found",
"Content Not Found",
"This content is no longer on Facebook",
]
# Instagram deletion indicators
INSTAGRAM = [
"Sorry, this page isn't available",
"The link you followed may be broken",
"Media not found or unavailable",
"This post is no longer available",
"This account is private",
]
# TikTok deletion indicators
TIKTOK = [
"Couldn't find this account",
"This video is no longer available",
"This video is currently unavailable",
"Video not found",
"This video may have been deleted",
]
# YouTube deletion indicators
YOUTUBE = [
"This video isn't available anymore",
"Video unavailable",
"This video has been removed",
"This video is no longer available",
"This video is private",
"This video has been removed by the uploader",
"This video has been deleted",
]
# Reddit deletion indicators
REDDIT = [
"this post has been removed",
"this comment has been removed",
"[removed]",
"[deleted]",
"page not found",
"there doesn't seem to be anything here",
]
# VK deletion indicators
VK = [
"Post deleted",
"Page not found",
"Content unavailable",
"Access denied",
]
# Telegram deletion indicators
TELEGRAM = [
"Message not found",
"Deleted message",
"Channel is private",
]
# Generic indicators (work across platforms)
GENERIC = [
"has been removed",
"no longer available",
"content removed",
"access denied",
"page not found",
]
@classmethod
def all_indicators(cls) -> List[str]:
"""Returns all deletion indicators from all platforms."""
return (
cls.TWITTER
+ cls.FACEBOOK
+ cls.INSTAGRAM
+ cls.TIKTOK
+ cls.YOUTUBE
+ cls.REDDIT
+ cls.VK
+ cls.TELEGRAM
+ cls.GENERIC
)
@classmethod
def for_url(cls, url: str) -> List[str]:
"""Returns platform-specific indicators based on URL domain."""
platform = _extract_platform(url)
indicators_map = {
"twitter": cls.TWITTER + cls.GENERIC,
"facebook": cls.FACEBOOK + cls.GENERIC,
"instagram": cls.INSTAGRAM + cls.GENERIC,
"tiktok": cls.TIKTOK + cls.GENERIC,
"youtube": cls.YOUTUBE + cls.GENERIC,
"reddit": cls.REDDIT + cls.GENERIC,
"vk": cls.VK + cls.GENERIC,
"telegram": cls.TELEGRAM + cls.GENERIC,
}
return indicators_map.get(platform, cls.GENERIC)
def detect_deletion(
html_content: str = None,
page_title: str = None,
error_message: str = None,
url: str = None,
video_data: dict = None,
) -> Optional[Dict[str, any]]:
"""
Best-effort deletion detection across multiple signals.
Checks HTML content, page titles, error messages, and video metadata for
indicators that content has been deleted or is unavailable.
Args:
html_content: Raw HTML source of the page
page_title: Browser page title
error_message: Any error message from the extractor
url: The URL being archived (for platform-specific detection)
video_data: Video metadata from yt-dlp or other extractors
Returns:
Dictionary with deletion details if detected, None otherwise.
Format: {
"is_deleted": True,
"indicator": "specific text that was found",
"source": "html|title|error|metadata",
"platform": "twitter|facebook|etc"
}
"""
# Determine indicators to check based on URL
if url:
indicators = DeletionIndicators.for_url(url)
platform = _extract_platform(url)
else:
indicators = DeletionIndicators.all_indicators()
platform = "unknown"
# Check HTML content
if html_content:
for indicator in indicators:
if indicator.lower() in html_content.lower():
logger.info(f"Deletion detected in HTML: '{indicator}' found for {url}")
return {"is_deleted": True, "indicator": indicator, "source": "html_content", "platform": platform}
# Check page title
if page_title:
for indicator in indicators:
if indicator.lower() in page_title.lower():
logger.info(f"Deletion detected in page title: '{indicator}' found for {url}")
return {"is_deleted": True, "indicator": indicator, "source": "page_title", "platform": platform}
# Check error messages
if error_message:
for indicator in indicators:
if indicator.lower() in str(error_message).lower():
logger.info(f"Deletion detected in error: '{indicator}' found for {url}")
return {"is_deleted": True, "indicator": indicator, "source": "error_message", "platform": platform}
# Check video metadata (from yt-dlp)
if video_data:
# Check if yt-dlp flagged it as unavailable
if video_data.get("availability") in ["unavailable", "private", "deleted"]:
logger.info(f"Deletion detected in metadata: availability={video_data.get('availability')}")
return {
"is_deleted": True,
"indicator": f"availability: {video_data.get('availability')}",
"source": "video_metadata",
"platform": platform,
}
# Check description/title for deletion indicators
for key in ["title", "description", "fulltitle"]:
if key in video_data:
for indicator in indicators:
if indicator.lower() in str(video_data[key]).lower():
logger.info(f"Deletion detected in {key}: '{indicator}'")
return {
"is_deleted": True,
"indicator": indicator,
"source": f"video_metadata_{key}",
"platform": platform,
}
return None
def _extract_platform(url: str) -> str:
"""Extracts platform name from URL."""
parsed = urlparse(url)
domain = parsed.netloc
if "twitter.com" in domain or "x.com" in domain:
return "twitter"
elif "facebook.com" in domain or "fb.com" in domain:
return "facebook"
elif "instagram.com" in domain:
return "instagram"
elif "tiktok.com" in domain:
return "tiktok"
elif "youtube.com" in domain or "youtu.be" in domain:
return "youtube"
elif "reddit.com" in domain:
return "reddit"
elif "vk.com" in domain:
return "vk"
elif "t.me" in domain:
return "telegram"
return "unknown"
def flag_as_deleted(metadata, deletion_info: Dict[str, any]) -> None:
"""
Flags metadata object as deleted/unavailable.
Adds tentative deletion information to the metadata object.
Args:
metadata: Metadata object to update
deletion_info: Dictionary from detect_deletion()
"""
metadata.set("deletion_detected", True)
metadata.set("deletion_indicator", deletion_info.get("indicator"))
metadata.set("deletion_source", deletion_info.get("source"))
metadata.set("deletion_platform", deletion_info.get("platform"))
metadata.status = "deleted_or_unavailable"
logger.debug(
f"Content marked as deleted/unavailable: "
f"platform={deletion_info.get('platform')}, "
f"indicator='{deletion_info.get('indicator')}', "
f"source={deletion_info.get('source')}"
)

View File

@@ -120,6 +120,9 @@ def ydl_entry_to_filename(ydl, entry: dict) -> str:
directory = os.path.dirname(base_filename) # '/get/path/to'
basename = os.path.basename(base_filename) # 'file'
for f in os.listdir(directory):
# skip incomplete downloads left behind by yt-dlp
if f.endswith(".part"):
continue
if (
f.startswith(basename)
or (entry_url and os.path.splitext(f)[0] in entry_url)

1
tests/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Core module tests

198
tests/core/test_media.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Tests for the Media class from auto_archiver.core.media
"""
import pytest
from unittest.mock import Mock, patch
from auto_archiver.core.media import Media
class TestMediaBasics:
"""Test basic Media properties and methods."""
def test_media_creation_with_filename(self):
media = Media(filename="test.mp4")
assert media.filename == "test.mp4"
assert media.urls == []
assert media.properties == {}
def test_media_key_property(self):
media = Media(filename="test.mp4", _key="my_key")
assert media.key == "my_key"
def test_media_set_get_properties(self):
media = Media(filename="test.mp4")
result = media.set("author", "John Doe")
assert result is media # returns self for chaining
assert media.get("author") == "John Doe"
assert media.get("nonexistent") is None
assert media.get("nonexistent", "default") == "default"
def test_media_add_url(self):
media = Media(filename="test.mp4")
media.add_url("https://example.com/test.mp4")
assert "https://example.com/test.mp4" in media.urls
media.add_url("https://cdn.example.com/test.mp4")
assert len(media.urls) == 2
class TestMediaMimetype:
"""Test mimetype detection and handling."""
@pytest.mark.parametrize(
"filename,expected_mimetype",
[
("video.mp4", "video/mp4"),
("image.jpg", "image/jpeg"),
("image.png", "image/png"),
("audio.mp3", "audio/mpeg"),
("document.pdf", "application/pdf"),
("text.txt", "text/plain"),
],
)
def test_mimetype_detection(self, filename, expected_mimetype):
media = Media(filename=filename)
assert media.mimetype == expected_mimetype
def test_mimetype_setter(self):
media = Media(filename="file.unknown")
media.mimetype = "custom/type"
assert media.mimetype == "custom/type"
def test_mimetype_empty_filename(self):
media = Media(filename="")
assert media.mimetype == ""
class TestMediaTypeChecks:
"""Test media type checking methods."""
@pytest.mark.parametrize(
"filename,is_video,is_audio,is_image",
[
("video.mp4", True, False, False),
("video.avi", True, False, False),
("audio.mp3", False, True, False),
("audio.wav", False, True, False),
("image.jpg", False, False, True),
("image.png", False, False, True),
("document.pdf", False, False, False),
],
)
def test_type_checks(self, filename, is_video, is_audio, is_image):
media = Media(filename=filename)
assert media.is_video() == is_video
assert media.is_audio() == is_audio
assert media.is_image() == is_image
class TestMediaStore:
"""Test media storage functionality."""
def test_store_with_no_storages(self, caplog):
media = Media(filename="test.mp4")
metadata = Mock()
media.store(metadata, storages=[])
assert "No storages found" in caplog.text
def test_store_with_storage(self):
media = Media(filename="test.mp4")
metadata = Mock()
mock_storage = Mock()
media.store(metadata, url="https://example.com", storages=[mock_storage])
mock_storage.store.assert_called_once()
class TestMediaInnerMedia:
"""Test nested media retrieval."""
def test_all_inner_media_no_nested(self):
media = Media(filename="test.mp4")
inner = list(media.all_inner_media(include_self=False))
assert len(inner) == 0
inner_with_self = list(media.all_inner_media(include_self=True))
assert len(inner_with_self) == 1
assert inner_with_self[0] is media
def test_all_inner_media_with_nested(self):
parent = Media(filename="parent.mp4")
child = Media(filename="child.jpg")
grandchild = Media(filename="grandchild.png")
child.set("thumbnail", grandchild)
parent.set("preview", child)
inner = list(parent.all_inner_media(include_self=False))
assert len(inner) == 2
assert child in inner
assert grandchild in inner
def test_all_inner_media_with_list_property(self):
parent = Media(filename="parent.mp4")
child1 = Media(filename="frame1.jpg")
child2 = Media(filename="frame2.jpg")
parent.set("frames", [child1, child2])
inner = list(parent.all_inner_media(include_self=False))
assert len(inner) == 2
assert child1 in inner
assert child2 in inner
class TestMediaIsStored:
"""Test the is_stored method."""
def test_is_stored_no_urls(self):
media = Media(filename="test.mp4")
storage = Mock()
storage.config = {"steps": {"storages": ["s3", "local"]}}
assert media.is_stored(storage) is False
def test_is_stored_partial_urls(self):
media = Media(filename="test.mp4")
media.add_url("https://s3.example.com/test.mp4")
storage = Mock()
storage.config = {"steps": {"storages": ["s3", "local"]}}
assert media.is_stored(storage) is False
def test_is_stored_full_urls(self):
media = Media(filename="test.mp4")
media.add_url("https://s3.example.com/test.mp4")
media.add_url("file:///local/test.mp4")
storage = Mock()
storage.config = {"steps": {"storages": ["s3", "local"]}}
assert media.is_stored(storage) is True
class TestMediaValidVideo:
"""Test video validation functionality."""
def test_is_valid_video_with_valid_probe(self):
media = Media(filename="test.mp4")
mock_streams = {"streams": [{"duration_ts": 1000}]}
with patch("ffmpeg.probe", return_value=mock_streams):
assert media.is_valid_video() is True
def test_is_valid_video_with_no_duration(self):
media = Media(filename="test.mp4")
mock_streams = {"streams": [{"duration_ts": 0}]}
with patch("ffmpeg.probe", return_value=mock_streams):
assert media.is_valid_video() is False
def test_is_valid_video_with_ffmpeg_error(self):
media = Media(filename="test.mp4")
with patch("ffmpeg.probe", side_effect=Exception("ffmpeg error")):
with patch("os.path.getsize", return_value=100):
# Falls back to file size check, small file
assert media.is_valid_video() is False
with patch("os.path.getsize", return_value=30000):
# Falls back to file size check, larger file
assert media.is_valid_video() is True

View File

@@ -0,0 +1,98 @@
"""
Tests for validators module from auto_archiver.core.validators
"""
import argparse
import json
import pytest
from auto_archiver.core.validators import positive_number, valid_file, json_loader
class TestPositiveNumber:
"""Test the positive_number validator."""
@pytest.mark.parametrize(
"value,expected",
[
(0, 0),
(1, 1),
(100, 100),
(0.5, 0.5),
(999999, 999999),
],
)
def test_positive_values(self, value, expected):
assert positive_number(value) == expected
@pytest.mark.parametrize(
"value",
[
-1,
-100,
-0.5,
-999999,
],
)
def test_negative_values_raise_error(self, value):
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
positive_number(value)
assert "not a positive number" in str(exc_info.value)
class TestValidFile:
"""Test the valid_file validator."""
def test_valid_file_exists(self, tmp_path):
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
result = valid_file(str(test_file))
assert result == str(test_file)
def test_valid_file_not_exists(self):
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
valid_file("/nonexistent/path/to/file.txt")
assert "does not exist" in str(exc_info.value)
def test_valid_file_directory_not_file(self, tmp_path):
# A directory is not a file
with pytest.raises(argparse.ArgumentTypeError) as exc_info:
valid_file(str(tmp_path))
assert "does not exist" in str(exc_info.value)
class TestJsonLoader:
"""Test the json_loader validator."""
@pytest.mark.parametrize(
"json_str,expected",
[
('{"key": "value"}', {"key": "value"}),
('{"number": 123}', {"number": 123}),
('{"list": [1, 2, 3]}', {"list": [1, 2, 3]}),
('{"nested": {"inner": "value"}}', {"nested": {"inner": "value"}}),
("[]", []),
("[1, 2, 3]", [1, 2, 3]),
('"string"', "string"),
("123", 123),
("true", True),
("false", False),
("null", None),
],
)
def test_valid_json(self, json_str, expected):
assert json_loader(json_str) == expected
@pytest.mark.parametrize(
"invalid_json",
[
"{invalid}",
"{'single': 'quotes'}",
"{missing: quotes}",
'{"unclosed": "brace"',
"",
],
)
def test_invalid_json_raises_error(self, invalid_json):
with pytest.raises(json.JSONDecodeError):
json_loader(invalid_json)

View File

@@ -0,0 +1,62 @@
"""
Tests for the ConsoleDb module
"""
import pytest
@pytest.fixture
def console_db(setup_module):
return setup_module("console_db")
class TestConsoleDb:
"""Test the ConsoleDb functionality."""
def test_started_logs_info(self, console_db, make_item, caplog):
"""Test that started() logs an info message."""
item = make_item("https://example.com/test")
with caplog.at_level("INFO"):
console_db.started(item)
assert "STARTED" in caplog.text
assert "example.com" in caplog.text
def test_failed_logs_error(self, console_db, make_item, caplog):
"""Test that failed() logs an error message with reason."""
item = make_item("https://example.com/test")
reason = "Connection timeout"
with caplog.at_level("ERROR"):
console_db.failed(item, reason)
assert "FAILED" in caplog.text
assert "Connection timeout" in caplog.text
def test_aborted_logs_warning(self, console_db, make_item, caplog):
"""Test that aborted() logs a warning message."""
item = make_item("https://example.com/test")
with caplog.at_level("WARNING"):
console_db.aborted(item)
assert "ABORTED" in caplog.text
def test_done_logs_success(self, console_db, make_item, caplog):
"""Test that done() logs a success message."""
item = make_item("https://example.com/test")
with caplog.at_level("INFO"):
console_db.done(item)
assert "DONE" in caplog.text
def test_done_cached(self, console_db, make_item, caplog):
"""Test done() with cached=True (should behave the same)."""
item = make_item("https://example.com/test")
with caplog.at_level("INFO"):
console_db.done(item, cached=True)
assert "DONE" in caplog.text

View File

@@ -0,0 +1,277 @@
import pytest
import requests
import os
from unittest.mock import MagicMock
from auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher import GhostarchiveEnricher
CI = os.getenv("GITHUB_ACTIONS", "") == "true"
# sample HTML responses for mocking
SEARCH_HTML_FOUND = """
<html><body>
<h1>Archives for https://example.com</h1>
<table>
<tr><td><a href="http://ghostarchive.org/archive/Abc12">https://example.com</a></td></tr>
</table>
</body></html>
"""
SEARCH_HTML_NOT_FOUND = """
<html><body>
<h1>Archives for https://example.com</h1>
<p>Page 0 out of 0</p>
<p>No archives for that site.</p>
</body></html>
"""
SAVE_RESPONSE_HTML_WITH_LINK = """
<html><body>
<h1>Archive saved</h1>
<a href="/archive/Xyz99">View archive</a>
</body></html>
"""
ENRICHER_CONFIG = {
"timeout": 120,
"check_existing": True,
"proxy_http": None,
"proxy_https": None,
}
class TestGhostarchiveEnricher:
"""Tests for Ghost Archive Enricher"""
@pytest.fixture(autouse=True)
def setup_enricher(self, setup_module):
self.enricher: GhostarchiveEnricher = setup_module("ghostarchive_enricher", ENRICHER_CONFIG)
def test_search_existing_found(self, mocker):
"""When an existing archive is found, it should be returned."""
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.text = SEARCH_HTML_FOUND
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.requests.get", return_value=mock_response
)
result = self.enricher._search_existing("https://example.com")
assert result == "https://ghostarchive.org/archive/Abc12"
def test_search_existing_not_found(self, mocker):
"""When no existing archive is found, None should be returned."""
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.text = SEARCH_HTML_NOT_FOUND
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.requests.get", return_value=mock_response
)
result = self.enricher._search_existing("https://example.com")
assert result is None
def test_search_existing_request_error(self, mocker):
"""When search request fails, None should be returned."""
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.requests.get",
side_effect=requests.exceptions.ConnectionError("connection failed"),
)
result = self.enricher._search_existing("https://example.com")
assert result is None
def test_search_existing_non_200(self, mocker):
"""When search returns non-200, None should be returned."""
mock_response = mocker.Mock()
mock_response.status_code = 503
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.requests.get", return_value=mock_response
)
result = self.enricher._search_existing("https://example.com")
assert result is None
def test_submit_url_success_redirect(self, mocker):
"""Successful submission via headless browser should return archive URL."""
mock_sb = MagicMock()
mock_sb.get_current_url.return_value = "https://ghostarchive.org/archive/NewId1"
mock_sb.__enter__ = MagicMock(return_value=mock_sb)
mock_sb.__exit__ = MagicMock(return_value=False)
mocker.patch("auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.SB", return_value=mock_sb)
result = self.enricher._submit_url("https://example.com")
assert result == "https://ghostarchive.org/archive/NewId1"
mock_sb.type.assert_called_once()
mock_sb.click.assert_called_once()
def test_submit_url_success_redirect_strips_query(self, mocker):
"""Redirect URL query params should be stripped."""
mock_sb = MagicMock()
mock_sb.get_current_url.return_value = "https://ghostarchive.org/archive/NewId1?wr=false"
mock_sb.__enter__ = MagicMock(return_value=mock_sb)
mock_sb.__exit__ = MagicMock(return_value=False)
mocker.patch("auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.SB", return_value=mock_sb)
result = self.enricher._submit_url("https://example.com")
assert result == "https://ghostarchive.org/archive/NewId1"
def test_submit_url_success_html_fallback(self, mocker):
"""When browser doesn't redirect, should parse page source for archive link."""
mock_sb = MagicMock()
mock_sb.get_current_url.return_value = "https://ghostarchive.org/archive2"
mock_sb.get_page_source.return_value = SAVE_RESPONSE_HTML_WITH_LINK
mock_sb.__enter__ = MagicMock(return_value=mock_sb)
mock_sb.__exit__ = MagicMock(return_value=False)
# make timeout=0 so the polling loop exits immediately and falls through to HTML parsing
self.enricher.timeout = 0
mocker.patch("auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.SB", return_value=mock_sb)
result = self.enricher._submit_url("https://example.com")
assert result == "https://ghostarchive.org/archive/Xyz99"
def test_submit_url_browser_error(self, mocker):
"""Browser error during submission should return None."""
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.SB",
side_effect=Exception("browser failed to start"),
)
result = self.enricher._submit_url("https://example.com")
assert result is None
def test_proxy_configuration(self, mocker):
"""Proxies should be passed to search requests when configured."""
self.enricher.proxy_http = "http://proxy:8080"
self.enricher.proxy_https = "https://proxy:8443"
mock_get = mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.requests.get",
)
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.text = SEARCH_HTML_FOUND
mock_get.return_value = mock_response
result = self.enricher._search_existing("https://example.com")
call_kwargs = mock_get.call_args
assert call_kwargs.kwargs.get("proxies") == {"http": "http://proxy:8080", "https": "https://proxy:8443"}
assert result is not None
def test_parse_archive_url_with_replay_links(self):
"""Parser should ignore /replay/ links and only return /archive/ links."""
html = """
<html><body>
<a href="/archive/replay/w/id-abc/mp_/https://example.com">replay</a>
<a href="/archive/Valid1">valid</a>
</body></html>
"""
result = self.enricher._parse_archive_url(html)
assert result == "https://ghostarchive.org/archive/Valid1"
def test_parse_archive_url_no_links(self):
"""Parser should return None when no archive links found."""
html = "<html><body><p>No archive here</p></body></html>"
result = self.enricher._parse_archive_url(html)
assert result is None
def test_enrich_sets_ghostarchive_on_metadata(self, mocker, make_item):
"""enrich() should set 'ghostarchive' key on the metadata object."""
mocker.patch.object(self.enricher, "_search_existing", return_value="https://ghostarchive.org/archive/Enr1")
item = make_item("https://example.com")
result = self.enricher.enrich(item)
assert result is True
assert item.get("ghostarchive") == "https://ghostarchive.org/archive/Enr1"
def test_enrich_skips_if_already_enriched(self, mocker, make_item):
"""enrich() should skip if ghostarchive key is already set."""
mock_search = mocker.patch.object(self.enricher, "_search_existing")
item = make_item("https://example.com", ghostarchive="https://ghostarchive.org/archive/Old1")
result = self.enricher.enrich(item)
assert result is True
mock_search.assert_not_called()
def test_enrich_returns_false_on_failure(self, mocker, make_item):
"""enrich() should return False when both search and submit fail."""
mocker.patch.object(self.enricher, "_search_existing", return_value=None)
mocker.patch.object(self.enricher, "_submit_url", return_value=None)
item = make_item("https://example.com")
result = self.enricher.enrich(item)
assert result is False
def test_enrich_skips_auth_wall(self, mocker, make_item):
"""enrich() should skip URLs behind auth walls."""
mocker.patch(
"auto_archiver.modules.ghostarchive_enricher.ghostarchive_enricher.UrlUtil.is_auth_wall", return_value=True
)
item = make_item("https://example.com/login")
result = self.enricher.enrich(item)
assert result is False
def test_enrich_with_existing_archive(self, mocker, make_item):
"""enrich() should use existing archive when check_existing is True."""
mocker.patch.object(self.enricher, "_search_existing", return_value="https://ghostarchive.org/archive/Exist1")
mock_submit = mocker.patch.object(self.enricher, "_submit_url")
item = make_item("https://example.com")
result = self.enricher.enrich(item)
assert result is True
assert item.get("ghostarchive") == "https://ghostarchive.org/archive/Exist1"
mock_submit.assert_not_called()
def test_enrich_submits_when_no_existing(self, mocker, make_item):
"""enrich() should submit URL when no existing archive found."""
mocker.patch.object(self.enricher, "_search_existing", return_value=None)
mocker.patch.object(self.enricher, "_submit_url", return_value="https://ghostarchive.org/archive/New42")
item = make_item("https://example.com")
result = self.enricher.enrich(item)
assert result is True
assert item.get("ghostarchive") == "https://ghostarchive.org/archive/New42"
def test_enrich_skips_check_existing_when_disabled(self, mocker, make_item):
"""enrich() should skip search when check_existing is False."""
self.enricher.check_existing = False
mock_search = mocker.patch.object(self.enricher, "_search_existing")
mocker.patch.object(self.enricher, "_submit_url", return_value="https://ghostarchive.org/archive/Direct1")
item = make_item("https://example.com")
result = self.enricher.enrich(item)
assert result is True
mock_search.assert_not_called()
@pytest.mark.download
def test_real_search_existing(self, setup_module):
"""Integration test: search for an existing archive on Ghost Archive."""
enricher = setup_module("ghostarchive_enricher", ENRICHER_CONFIG)
# example.com is commonly archived
result = enricher._search_existing("https://example.com")
# we just check it doesn't crash; result may or may not be found
assert result is None or result.startswith("https://ghostarchive.org/archive/")
@pytest.mark.download
@pytest.mark.skipif(CI, reason="Avoid submitting a real task on every CI run")
def test_real_submit_example_com(self, setup_module, make_item):
"""Integration test: submit example.com to Ghost Archive and verify enrichment."""
enricher = setup_module("ghostarchive_enricher", ENRICHER_CONFIG)
item = make_item("https://example.com")
result = enricher.enrich(item)
assert result is True
archive_url = item.get("ghostarchive")
assert archive_url is not None
assert archive_url.startswith("https://ghostarchive.org/archive/")

View File

@@ -0,0 +1,72 @@
"""
Tests for the JsonEnricher module
"""
import json
import os
import pytest
@pytest.fixture
def json_enricher(setup_module):
return setup_module("json_enricher")
class TestJsonEnricher:
"""Test the JsonEnricher functionality."""
def test_enrich_creates_json_file(self, json_enricher, make_item):
"""Test that enrich creates a metadata.json file."""
item = make_item("https://example.com/test")
item.set("title", "Test Title")
item.set("description", "Test description")
json_enricher.enrich(item)
# Check that a media with id 'metadata_json' was added
json_media = item.get_media_by_id("metadata_json")
assert json_media is not None
assert json_media.filename.endswith("metadata.json")
assert os.path.exists(json_media.filename)
def test_enrich_json_content(self, json_enricher, make_item):
"""Test that the JSON content is correct."""
item = make_item("https://example.com/test")
item.set("title", "Test Title")
item.set("custom_field", "custom_value")
json_enricher.enrich(item)
json_media = item.get_media_by_id("metadata_json")
with open(json_media.filename, "r", encoding="utf-8") as f:
content = json.load(f)
# The to_dict() returns nested structure: {status, metadata: {...}, media: [...]}
assert content["metadata"]["title"] == "Test Title"
assert content["metadata"]["custom_field"] == "custom_value"
assert content["metadata"]["url"] == "https://example.com/test"
def test_enrich_handles_special_characters(self, json_enricher, make_item):
"""Test that special characters are handled correctly."""
item = make_item("https://example.com/test")
item.set("title", "Test with émojis 🎉 and üñíçödé")
json_enricher.enrich(item)
json_media = item.get_media_by_id("metadata_json")
with open(json_media.filename, "r", encoding="utf-8") as f:
content = json.load(f)
# Access the nested metadata structure
assert "émojis 🎉" in content["metadata"]["title"]
assert "üñíçödé" in content["metadata"]["title"]
def test_enrich_empty_metadata(self, json_enricher, make_item):
"""Test enriching metadata with minimal content."""
item = make_item("https://example.com/minimal")
json_enricher.enrich(item)
json_media = item.get_media_by_id("metadata_json")
assert json_media is not None
assert os.path.exists(json_media.filename)

View File

@@ -56,6 +56,19 @@ def test_enrich_sets_metadata(enricher, mocker):
assert metadata.media == [media1, media2]
def test_enrich_no_metadata_selection(enricher, mocker):
media1 = mocker.Mock(filename="img1.jpg")
media2 = mocker.Mock(filename="img2.jpg")
metadata = mocker.Mock()
metadata.media = [media1, media2]
enricher.get_metadata = lambda f: {"key": "value"} if f == "img1.jpg" else {}
enricher.look_for_keys = ["no-key"]
enricher.enrich(metadata)
media1.set.assert_called_once_with("metadata", {})
media2.set.assert_not_called()
assert metadata.media == [media1, media2]
def test_enrich_empty_media(enricher, mocker):
metadata = mocker.Mock()
metadata.media = []
@@ -71,7 +84,9 @@ def test_get_metadata_error_handling(enricher, mocker):
assert "Error occurred: " in mock_log.call_args[0][0]
def test_metadata_pickle(enricher, unpickle, mocker):
# TODO depends on the expected functionality
"""
def test_default_metadata_pickle(enricher, unpickle, mocker):
mock_run = mocker.patch("subprocess.run")
# Uses pickled values
mock_run.return_value = unpickle("metadata_enricher_exif.pickle")
@@ -79,6 +94,39 @@ def test_metadata_pickle(enricher, unpickle, mocker):
expected = unpickle("metadata_enricher_ytshort_expected.pickle")
enricher.enrich(metadata)
expected_media = expected.media
print(expected_media)
actual_media = metadata.media
assert len(expected_media) == len(actual_media)
assert actual_media[0].properties.get("metadata") == expected_media[0].properties.get("metadata")
"""
def test_metadata_pickle_megapixel(enricher, unpickle, mocker):
mock_run = mocker.patch("subprocess.run")
mock_run.return_value = unpickle("metadata_enricher_exif.pickle")
metadata = unpickle("metadata_enricher_ytshort_input.pickle")
enricher.look_for_keys = ["megapixels"]
enricher.enrich(metadata)
actual_media = metadata.media
assert actual_media[0].properties.get("metadata") == {"Megapixels": "0.922"}
def test_metadata_specify_datetime_and_metapixels(enricher, unpickle, mocker):
mock_run = mocker.patch("subprocess.run")
mock_run.return_value = unpickle("metadata_enricher_exif.pickle")
metadata = unpickle("metadata_enricher_ytshort_input.pickle")
enricher.look_for_keys = ["datetime", "megapixels", "image height"]
enricher.enrich(metadata)
actual_media = metadata.media
assert actual_media[0].properties.get("metadata") == {
"File Modification Date/Time": "2025:02:18 19:42:50+00:00",
"File Access Date/Time": "2025:02:18 19:42:50+00:00",
"File Inode Change Date/Time": "2025:02:18 19:42:50+00:00",
"Megapixels": "0.922",
"Image Height": "720",
}

View File

@@ -5,6 +5,9 @@ from auto_archiver.modules.antibot_extractor_enricher.antibot_extractor_enricher
from .test_extractor_base import TestExtractorBase
CI = os.getenv("GITHUB_ACTIONS", "") == "true"
class DummySB:
def __init__(self, url="", title="", visible_texts=None, visible_elements=None):
self._url = url
@@ -50,15 +53,17 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
}
@pytest.mark.download
@pytest.mark.flaky(reruns=2, reruns_delay=5)
@pytest.mark.parametrize(
"url,in_title,in_text,image_count,video_count",
"url,in_title,in_text,image_count,video_count,skip_ci",
[
(
"https://en.wikipedia.org/wiki/Western_barn_owl",
"western barn owl",
"Tyto alba",
5,
3, # Reduced due to Wikipedia rate limiting (429 errors)
0,
False,
),
(
"https://www.bellingcat.com/news/2025/04/29/open-sources-show-myanmar-junta-airstrike-damages-despite-post-earthquake-ceasefire/",
@@ -66,6 +71,7 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
"Bellingcat has geolocated",
5,
0,
False,
),
(
"https://www.bellingcat.com/news/2025/03/27/gaza-israel-palestine-shot-killed-injured-destroyed-dangerous-drone-journalists-in-gaza/",
@@ -73,6 +79,7 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
"continued the work of Gazan journalists",
5,
1,
False,
),
(
"https://www.bellingcat.com/about/general-information",
@@ -80,6 +87,7 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
"Stichting Bellingcat",
0, # SVGs are ignored
0,
False,
),
(
"https://vk.com/wikipedia?from=search&w=wall-36156673_20451",
@@ -87,13 +95,27 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
"16 сентября 1985 года лейблом EMI Records.",
5,
0,
False,
),
(
"https://www.tiktok.com/@tracy_2424/photo/7418200173953830162",
"TikTok",
"Dito ko lang",
1,
0,
True,
),
],
)
def test_download_pages_with_media(self, setup_module, make_item, url, in_title, in_text, image_count, video_count):
def test_download_pages_with_media(
self, setup_module, make_item, url, in_title, in_text, image_count, video_count, skip_ci
):
"""
Test downloading pages with media.
"""
if CI and skip_ci:
pytest.skip("Skipping test in CI environment")
self.extractor = setup_module(
self.extractor_module,
self.config
@@ -107,6 +129,7 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
item = make_item(url)
result = self.extractor.download(item)
assert result, f"download() returned {result!r} — Selenium may have failed (e.g., window close timeout)"
assert result.status == "antibot", "Expected status to be 'antibot'"
# Check title contains all required words (case-insensitive)
@@ -121,9 +144,9 @@ class TestAntibotExtractorEnricher(TestExtractorBase):
)
image_media = [m for m in result.media if m.is_image() and not m.get("id") == "screenshot"]
assert len(image_media) == image_count, f"Expected {image_count} image items, got {len(image_media)}"
assert len(image_media) >= image_count, f"Expected at least {image_count} image items, got {len(image_media)}"
video_media = [m for m in result.media if m.is_video()]
assert len(video_media) == video_count, f"Expected {video_count} video items, got {len(video_media)}"
assert len(video_media) >= video_count, f"Expected at least {video_count} video items, got {len(video_media)}"
for expected_id in ["screenshot", "pdf", "html_source_code"]:
assert any(m.get("id") == expected_id for m in result.media), (

View File

@@ -48,8 +48,6 @@ class TestGenericExtractor(TestExtractorBase):
("https://www.youtube.com/watch?v=5qap5aO4i9A", ["youtube"]),
("https://www.tiktok.com/@funnycats0ftiktok/video/7345101300750748970?lang=en", ["tiktok"]),
("https://www.instagram.com/p/CU1J9JYJ9Zz/", ["instagram"]),
("https://www.facebook.com/nytimes/videos/10160796550110716", ["facebook"]),
("https://www.facebook.com/BylineFest/photos/t.100057299682816/927879487315946/", ["facebook"]),
],
)
def test_suitable_extractors(self, url, suitable_extractors):
@@ -148,6 +146,7 @@ class TestGenericExtractor(TestExtractorBase):
def test_bluesky_download_video(self, make_item):
item = make_item("https://bsky.app/profile/bellingcat.com/post/3le2l4gsxlk2i")
result = self.extractor.download(item)
assert result.get_url() == "https://bsky.app/profile/bellingcat.com/post/3le2l4gsxlk2i"
assert result is not False
@pytest.mark.skipif(not TEST_TRUTH_SOCIAL, reason="Truth social download tests disabled in environment variables.")

View File

@@ -1,3 +1,4 @@
import asyncio
import os
from datetime import date
@@ -60,3 +61,53 @@ def test_valid_url_regex(url, expected, get_lazy_module):
def test_invite_pattern_regex(invite, expected, get_lazy_module):
match = TelethonExtractor.invite_pattern.search(invite)
assert bool(match) == expected
def test_setup_with_closed_event_loop(get_lazy_module, tmp_path, mocker):
"""
Simulate the Celery worker scenario where the asyncio event loop is closed
before setup() runs. The fix should create a new event loop so that
TelegramClient.start() does not raise 'Event loop is closed'.
"""
# create a session file so setup doesn't fail on missing file
session_file = tmp_path / "test.session"
session_file.touch()
# close the current event loop to simulate a Celery worker environment
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.close()
lazy_module = get_lazy_module("telethon_extractor")
module = lazy_module.load(
{"telethon_extractor": {"session_file": str(session_file), "api_id": 123, "api_hash": "ABC"}}
)
# setup should have succeeded and a new open event loop should exist
new_loop = asyncio.get_event_loop()
assert not new_loop.is_closed()
assert module.client is not None
def test_setup_with_no_event_loop(get_lazy_module, tmp_path, mocker):
"""
Simulate the scenario where there is no current event loop at all
(e.g. running in a non-main thread). The fix should create one.
"""
session_file = tmp_path / "test.session"
session_file.touch()
# Remove the current event loop entirely
# In Python 3.12+, get_event_loop() in a non-main thread raises RuntimeError
mocker.patch("asyncio.get_event_loop", side_effect=RuntimeError("no current event loop"))
new_loop_mock = mocker.MagicMock()
new_loop_mock.is_closed.return_value = False
mocker.patch("asyncio.new_event_loop", return_value=new_loop_mock)
set_loop = mocker.patch("asyncio.set_event_loop")
lazy_module = get_lazy_module("telethon_extractor")
lazy_module.load({"telethon_extractor": {"session_file": str(session_file), "api_id": 123, "api_hash": "ABC"}})
# a new event loop should have been created and set
asyncio.new_event_loop.assert_called_once()
set_loop.assert_called_once_with(new_loop_mock)

View File

@@ -55,6 +55,7 @@ class TestTiktokTikwmExtractor(TestExtractorBase):
("https://www.tiktok.com/@ggs68taiwan.official/video/7441821351142362375", True),
("https://www.tiktok.com/t/ZP8YQ8e5j/", True),
("https://vt.tiktok.com/ZSMTJeqRP/", True),
("https://tiktok.com/@user/photo/123?lang=en", True),
],
)
def test_is_suitable(self, url, is_suitable, tiktok_dropin):
@@ -68,10 +69,7 @@ class TestTiktokTikwmExtractor(TestExtractorBase):
mock_get.assert_called_once()
mock_get.return_value.json.assert_called_once()
# first message is just the 'Skipping using ytdlp to download files for TikTok' message
assert (
"failed to parse JSON response from tikwm.com for url='https://www.tiktok.com/@example/video/1234'"
in caplog.text
)
assert "Failed to parse JSON response from tikwm.com" in caplog.text
mock_get.return_value.json.side_effect = Exception
with caplog.at_level("ERROR"):
@@ -79,10 +77,7 @@ class TestTiktokTikwmExtractor(TestExtractorBase):
mock_get.assert_called()
assert mock_get.call_count == 2
assert mock_get.return_value.json.call_count == 2
assert (
"failed to parse JSON response from tikwm.com for url='https://www.tiktok.com/@example/video/1234'"
in caplog.text
)
assert "Failed to parse JSON response from tikwm.com" in caplog.text
@pytest.mark.parametrize(
"response",
@@ -98,27 +93,30 @@ class TestTiktokTikwmExtractor(TestExtractorBase):
assert self.extractor.download(make_item(self.VALID_EXAMPLE_URL)) is False
mock_get.assert_called_once()
mock_get.return_value.json.assert_called_once()
assert "failed to get a valid response from tikwm.com" in caplog.text
assert "Unable to download with tikwm.com: " in caplog.text
@pytest.mark.parametrize(
"response,has_vid",
"response,is_success",
[
({"data": {"id": 123}}, False),
({"data": {"wmplay": "url"}}, True),
({"data": {"play": "url"}}, True),
({"data": {"id": 123, "images": []}}, False),
({"data": {"wmplay": "url", "images": ["img1.jpg"]}}, True),
({"data": {"play": "url", "images": ["img1.jpg"]}}, True),
({"data": {"images": ["img1.jpg"]}}, True),
],
)
def test_correct_extraction(self, mock_get, make_item, response, has_vid, mocker):
def test_correct_extraction(self, mock_get, make_item, response, is_success, mocker):
data = {k: v for k, v in response.get("data", {}).items()}
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"msg": "success", **response}
result = self.extractor.download(make_item(self.VALID_EXAMPLE_URL))
if not has_vid:
assert result is False
else:
total_media = len(data.get("images", [])) + (1 if data.get("wmplay", data.get("play")) else 0)
if is_success:
assert result.is_success()
assert len(result.media) == 1
assert len(result.media) == total_media
else:
assert result is False
mock_get.assert_called()
assert mock_get.call_count == 1 + int(has_vid)
assert mock_get.call_count == 1 + total_media
mock_get.return_value.json.assert_called_once()
def test_correct_data_extracted(self, mock_get, make_item):
@@ -142,7 +140,9 @@ class TestTiktokTikwmExtractor(TestExtractorBase):
assert len(result.media) == 2
assert result.get_title() == "Title"
assert result.get("author") == "Author"
assert result.get("api_data") == {"other": "data", "id": 123}
assert result.get("other") == "data"
assert result.get("comments") is None
assert result.get("api_data") == {"id": 123, "other": "data"}
assert result.media[1].get("duration") == 60
assert result.get("timestamp") == datetime.fromtimestamp(1736301699, tz=timezone.utc)

View File

@@ -0,0 +1,238 @@
"""
Tests for the Twitter dropin extractor with fxtwitter fallback
"""
import pytest
from unittest.mock import Mock, patch
from auto_archiver.modules.generic_extractor.twitter import Twitter
@pytest.fixture
def twitter_dropin():
return Twitter()
class TestTwitterFxTwitterFallback:
"""Test the fxtwitter API fallback functionality."""
@pytest.fixture
def mock_fxtwitter_video_response(self):
return {
"code": 200,
"message": "OK",
"tweet": {
"url": "https://x.com/user/status/123456789",
"id": "123456789",
"text": "Test tweet with video",
"author": {
"id": "111",
"name": "Test User",
"screen_name": "testuser",
},
"created_at": "Sun Feb 08 18:45:00 +0000 2026",
"media": {
"all": [
{
"type": "video",
"url": "https://video.twimg.com/test.mp4",
"variants": [
{"url": "https://video.twimg.com/test.m3u8", "content_type": "application/x-mpegURL"},
{
"url": "https://video.twimg.com/test_480.mp4",
"content_type": "video/mp4",
"bitrate": 632000,
},
{
"url": "https://video.twimg.com/test_720.mp4",
"content_type": "video/mp4",
"bitrate": 2176000,
},
],
}
],
"videos": [
{
"url": "https://video.twimg.com/test.mp4",
"variants": [
{"url": "https://video.twimg.com/test.m3u8", "content_type": "application/x-mpegURL"},
{
"url": "https://video.twimg.com/test_480.mp4",
"content_type": "video/mp4",
"bitrate": 632000,
},
{
"url": "https://video.twimg.com/test_720.mp4",
"content_type": "video/mp4",
"bitrate": 2176000,
},
],
}
],
},
},
}
@pytest.fixture
def mock_fxtwitter_photo_response(self):
return {
"code": 200,
"message": "OK",
"tweet": {
"url": "https://x.com/user/status/123456790",
"id": "123456790",
"text": "Test tweet with photo",
"author": {
"id": "111",
"name": "Test User",
"screen_name": "testuser",
},
"created_at": "Mon Feb 09 10:30:00 +0000 2026",
"media": {
"all": [
{
"type": "photo",
"url": "https://pbs.twimg.com/media/test.jpg?name=orig",
}
],
"photos": [
{
"type": "photo",
"url": "https://pbs.twimg.com/media/test.jpg?name=orig",
}
],
},
},
}
def test_fetch_fxtwitter_video(self, twitter_dropin, mock_fxtwitter_video_response):
"""Test fetching a tweet with video via fxtwitter API."""
with patch("requests.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_fxtwitter_video_response
mock_get.return_value = mock_response
result = twitter_dropin._fetch_fxtwitter("123456789")
assert result["user"]["name"] == "Test User"
assert result["created_at"] == "Sun Feb 08 18:45:00 +0000 2026"
assert result["full_text"] == "Test tweet with video"
assert len(result["entities"]["media"]) == 1
assert result["entities"]["media"][0]["type"] == "video"
assert "video_info" in result["entities"]["media"][0]
assert len(result["entities"]["media"][0]["video_info"]["variants"]) == 3
def test_fetch_fxtwitter_photo(self, twitter_dropin, mock_fxtwitter_photo_response):
"""Test fetching a tweet with photo via fxtwitter API."""
with patch("requests.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_fxtwitter_photo_response
mock_get.return_value = mock_response
result = twitter_dropin._fetch_fxtwitter("123456790")
assert result["user"]["name"] == "Test User"
assert result["created_at"] == "Mon Feb 09 10:30:00 +0000 2026"
assert result["full_text"] == "Test tweet with photo"
assert len(result["entities"]["media"]) == 1
assert result["entities"]["media"][0]["type"] == "photo"
assert result["entities"]["media"][0]["media_url_https"] == "https://pbs.twimg.com/media/test.jpg?name=orig"
def test_fetch_fxtwitter_no_media(self, twitter_dropin):
"""Test fetching a text-only tweet via fxtwitter API."""
mock_response_data = {
"code": 200,
"message": "OK",
"tweet": {
"id": "123456791",
"text": "Just text, no media",
"author": {"name": "Text Only User"},
"created_at": "Tue Feb 10 12:00:00 +0000 2026",
"media": {},
},
}
with patch("requests.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
mock_get.return_value = mock_response
result = twitter_dropin._fetch_fxtwitter("123456791")
assert result["user"]["name"] == "Text Only User"
assert result["full_text"] == "Just text, no media"
assert result["entities"]["media"] == []
def test_fetch_fxtwitter_api_error(self, twitter_dropin):
"""Test handling of fxtwitter API errors."""
with patch("requests.get") as mock_get:
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
with pytest.raises(Exception):
twitter_dropin._fetch_fxtwitter("nonexistent")
class TestTwitterChooseVariant:
"""Test the video variant selection logic."""
def test_choose_highest_quality_video(self, twitter_dropin):
"""Test that the highest quality video variant is selected."""
variants = [
{"url": "https://video.twimg.com/vid/320x240/test.mp4", "content_type": "video/mp4"},
{"url": "https://video.twimg.com/vid/1280x720/test.mp4", "content_type": "video/mp4"},
{"url": "https://video.twimg.com/vid/640x480/test.mp4", "content_type": "video/mp4"},
]
result = twitter_dropin.choose_variant(variants)
assert result["url"] == "https://video.twimg.com/vid/1280x720/test.mp4"
def test_choose_variant_fallback_for_non_mp4(self, twitter_dropin):
"""Test fallback when no mp4 variant is available."""
variants = [
{"url": "https://video.twimg.com/test.m3u8", "content_type": "application/x-mpegURL"},
]
result = twitter_dropin.choose_variant(variants)
assert result["url"] == "https://video.twimg.com/test.m3u8"
def test_choose_variant_prefers_mp4(self, twitter_dropin):
"""Test that mp4 is preferred over other formats when quality is equal."""
variants = [
{"url": "https://video.twimg.com/test.m3u8", "content_type": "application/x-mpegURL"},
{"url": "https://video.twimg.com/vid/1280x720/test.mp4", "content_type": "video/mp4"},
]
result = twitter_dropin.choose_variant(variants)
assert result["content_type"] == "video/mp4"
@pytest.mark.download
class TestTwitterFxTwitterLive:
"""Live integration tests for fxtwitter API - requires network access."""
@pytest.mark.parametrize(
"tweet_id,expected_media_type",
[
("2020569571682312581", "video"), # Video tweet
("2020410438198890618", "video"), # Video tweet
("2020341585502957801", "photo"), # Photo tweet
],
)
def test_fetch_real_tweets(self, twitter_dropin, tweet_id, expected_media_type):
"""Test fetching real tweets from fxtwitter API."""
result = twitter_dropin._fetch_fxtwitter(tweet_id)
assert result["user"]["name"] # Author should be non-empty
assert result["created_at"] # Should have timestamp
assert result["full_text"] # Should have text content
media = result["entities"]["media"]
assert len(media) >= 1
assert media[0]["type"] == expected_media_type

View File

@@ -0,0 +1,70 @@
"""
Tests for the CLIFeeder module
"""
import pytest
from auto_archiver.modules.cli_feeder.cli_feeder import CLIFeeder
from auto_archiver.core.consts import SetupError
from auto_archiver.core.metadata import Metadata
@pytest.fixture
def cli_feeder_instance():
"""Create a CLIFeeder instance with mocked config."""
def _create(urls):
feeder = CLIFeeder()
# Mock the config structure that cli_feeder expects
feeder.config = {"urls": urls}
feeder.name = "cli_feeder"
feeder.tmp_dir = "/tmp"
return feeder
return _create
class TestCLIFeeder:
"""Test the CLIFeeder functionality."""
def test_iter_yields_metadata_for_urls(self, cli_feeder_instance):
"""Test that iteration yields Metadata objects for each URL."""
urls = ["https://example.com/1", "https://example.com/2", "https://example.com/3"]
feeder = cli_feeder_instance(urls)
feeder.setup()
items = list(feeder)
assert len(items) == 3
assert all(isinstance(item, Metadata) for item in items)
assert items[0].get_url() == "https://example.com/1"
assert items[1].get_url() == "https://example.com/2"
assert items[2].get_url() == "https://example.com/3"
def test_iter_single_url(self, cli_feeder_instance):
"""Test iteration with a single URL."""
feeder = cli_feeder_instance(["https://example.com/single"])
feeder.setup()
items = list(feeder)
assert len(items) == 1
assert items[0].get_url() == "https://example.com/single"
def test_setup_raises_without_urls(self, cli_feeder_instance):
"""Test that setup raises SetupError when no URLs provided."""
feeder = cli_feeder_instance([])
with pytest.raises(SetupError) as exc_info:
feeder.setup()
assert "No URLs provided" in str(exc_info.value)
def test_setup_raises_with_none_urls(self, cli_feeder_instance):
"""Test that setup raises SetupError when urls is None."""
feeder = cli_feeder_instance(None)
with pytest.raises(SetupError) as exc_info:
feeder.setup()
assert "No URLs provided" in str(exc_info.value)

View File

@@ -0,0 +1,43 @@
"""
Tests for the MuteFormatter module
"""
import pytest
from auto_archiver.core.metadata import Metadata
@pytest.fixture
def mute_formatter(setup_module):
return setup_module("mute_formatter")
class TestMuteFormatter:
"""Test the MuteFormatter functionality."""
def test_format_returns_none(self, mute_formatter, make_item):
"""Test that format always returns None (mutes output)."""
item = make_item("https://example.com/test")
item.set("title", "Test Title")
result = mute_formatter.format(item)
assert result is None
def test_format_with_empty_metadata(self, mute_formatter):
"""Test format with empty metadata."""
item = Metadata().set_url("https://example.com/empty")
result = mute_formatter.format(item)
assert result is None
def test_format_with_media(self, mute_formatter, make_item):
"""Test that format still returns None even with media attached."""
from auto_archiver.core.media import Media
item = make_item("https://example.com/with-media")
item.add_media(Media(filename="test.mp4"))
result = mute_formatter.format(item)
assert result is None

View File

@@ -86,6 +86,22 @@ def test_media_management(basic_metadata, media_file):
assert basic_metadata.get_media_by_id("m1") == media1
def test_remove_duplicate_skips_missing_files(basic_metadata, media_file, tmp_path):
"""Missing files should be dropped instead of crashing with FileNotFoundError."""
real_file = tmp_path / "exists.txt"
real_file.write_text("content")
valid = media_file(filename=str(real_file), hash_value="abc")
missing = media_file(filename="/nonexistent/path/gone.mp4")
basic_metadata.add_media(valid, "valid")
basic_metadata.add_media(missing, "missing")
assert len(basic_metadata.media) == 2
basic_metadata.remove_duplicate_media_by_hash()
assert len(basic_metadata.media) == 1
assert basic_metadata.get_media_by_id("valid") == valid
def test_success():
m = Metadata()
assert not m.is_success()

View File

@@ -0,0 +1,259 @@
"""
Tests for handling Media objects with None filename.
When download_from_url fails, it returns None. Various enrichers and
the metadata deduplication logic must gracefully handle Media objects
where filename is None, rather than crashing with TypeError.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from auto_archiver.core.metadata import Metadata, Media
from auto_archiver.modules.hash_enricher import HashEnricher
from auto_archiver.modules.meta_enricher import MetaEnricher
# ── HashEnricher ──────────────────────────────────────────────────────
class TestHashEnricherNoneFilename:
"""hash_enricher should skip media with None filename without crashing."""
@pytest.fixture(autouse=True)
def setup(self, setup_module):
self.enricher = setup_module(HashEnricher, {"algorithm": "SHA-256", "chunksize": 100})
def test_skips_none_filename(self):
m = Metadata().set_url("https://example.com")
media = Media(filename=None)
media.set("src", "https://example.com/video.mp4")
m.add_media(media)
# Should not raise
self.enricher.enrich(m)
# No hash should be set
assert m.media[0].get("hash") is None
def test_hashes_valid_skips_none(self, tmp_path):
"""Mix of valid and None-filename media: only valid ones get hashed."""
valid_file = tmp_path / "test.txt"
valid_file.write_text("hello world")
m = Metadata().set_url("https://example.com")
m.add_media(Media(filename=str(valid_file)))
m.add_media(Media(filename=None))
self.enricher.enrich(m)
assert m.media[0].get("hash") is not None
assert m.media[1].get("hash") is None
def test_all_none_filenames(self):
"""All media have None filename enricher should not crash."""
m = Metadata().set_url("https://example.com")
m.add_media(Media(filename=None))
m.add_media(Media(filename=None))
self.enricher.enrich(m)
assert len(m.media) == 2
for media in m.media:
assert media.get("hash") is None
# ── MetaEnricher ──────────────────────────────────────────────────────
class TestMetaEnricherNoneFilename:
"""meta_enricher should skip media with None filename without crashing."""
@pytest.fixture(autouse=True)
def setup(self, setup_module):
self.enricher = setup_module(MetaEnricher, {})
def test_skips_none_filename(self):
m = Metadata().set_url("https://example.com")
m.set("_processed_at", datetime.now(timezone.utc))
media = Media(filename=None)
media.set("src", "https://example.com/video.mp4")
m.add_media(media)
# Should not raise
self.enricher.enrich(m)
assert m.get("total_bytes") == 0
def test_sizes_valid_skips_none(self, tmp_path):
"""Mix of valid and None-filename media: only valid ones get sized."""
valid_file = tmp_path / "test.txt"
valid_file.write_text("A" * 500)
m = Metadata().set_url("https://example.com")
m.set("_processed_at", datetime.now(timezone.utc))
m.add_media(Media(filename=str(valid_file)))
m.add_media(Media(filename=None))
self.enricher.enrich(m)
assert m.media[0].get("bytes") == 500
assert m.media[1].get("bytes") is None
assert m.get("total_bytes") == 500
# ── Metadata.remove_duplicate_media_by_hash ───────────────────────────
class TestRemoveDuplicateMediaNoneFilename:
"""remove_duplicate_media_by_hash should keep media with None filename."""
def test_none_filename_kept(self):
m = Metadata().set_url("https://example.com")
none_media = Media(filename=None)
none_media.set("src", "https://example.com/video.mp4")
m.add_media(none_media)
m.remove_duplicate_media_by_hash()
assert len(m.media) == 1
assert m.media[0].filename is None
def test_none_and_valid_mixed(self, tmp_path):
"""None-filename media is kept alongside valid-filename media."""
valid_file = tmp_path / "test.txt"
valid_file.write_text("content")
m = Metadata().set_url("https://example.com")
m.add_media(Media(filename=str(valid_file)))
none_media = Media(filename=None)
none_media.set("src", "https://example.com/video.mp4")
m.add_media(none_media)
m.remove_duplicate_media_by_hash()
assert len(m.media) == 2
def test_multiple_none_filename_all_kept(self):
"""Multiple None-filename media are all kept (can't deduplicate without file)."""
m = Metadata().set_url("https://example.com")
m.add_media(Media(filename=None))
m.add_media(Media(filename=None))
m.remove_duplicate_media_by_hash()
assert len(m.media) == 2
# ── Twitter dropin create_metadata ────────────────────────────────────
class TestTwitterDropinNoneFilename:
"""Twitter dropin should skip media when download_from_url returns None."""
@pytest.fixture
def twitter_dropin(self):
from auto_archiver.modules.generic_extractor.twitter import Twitter
return Twitter()
def test_create_metadata_skips_failed_photo_download(self, twitter_dropin):
"""When download_from_url returns None for a photo, it's not added to media."""
tweet = {
"user": {"name": "Test User"},
"created_at": "Sun Feb 08 18:45:00 +0000 2026",
"full_text": "Test tweet with photo",
"entities": {
"media": [
{"type": "photo", "media_url_https": "https://pbs.twimg.com/media/test.jpg"},
]
},
}
mock_archiver = MagicMock()
mock_archiver.download_from_url.return_value = None # simulate failed download
result = twitter_dropin.create_metadata(tweet, None, mock_archiver, "https://x.com/test/status/123")
# The result should have no media since the download failed
assert len(result.media) == 0
def test_create_metadata_skips_failed_video_download(self, twitter_dropin):
"""When download_from_url returns None for a video, it's not added to media."""
tweet = {
"user": {"name": "Test User"},
"created_at": "Sun Feb 08 18:45:00 +0000 2026",
"full_text": "Test tweet with video",
"entities": {
"media": [
{
"type": "video",
"video_info": {
"variants": [
{
"url": "https://video.twimg.com/vid/1280x720/test.mp4",
"content_type": "video/mp4",
},
]
},
},
]
},
}
mock_archiver = MagicMock()
mock_archiver.download_from_url.return_value = None
result = twitter_dropin.create_metadata(tweet, None, mock_archiver, "https://x.com/test/status/123")
assert len(result.media) == 0
def test_create_metadata_keeps_successful_download(self, twitter_dropin, tmp_path):
"""When download_from_url succeeds, media is added."""
tweet = {
"user": {"name": "Test User"},
"created_at": "Sun Feb 08 18:45:00 +0000 2026",
"full_text": "Test tweet with photo",
"entities": {
"media": [
{"type": "photo", "media_url_https": "https://pbs.twimg.com/media/test.jpg"},
]
},
}
test_file = tmp_path / "test.jpg"
test_file.write_text("fake image data")
mock_archiver = MagicMock()
mock_archiver.download_from_url.return_value = str(test_file)
result = twitter_dropin.create_metadata(tweet, None, mock_archiver, "https://x.com/test/status/123")
assert len(result.media) == 1
assert result.media[0].filename == str(test_file)
def test_create_metadata_mixed_downloads(self, twitter_dropin, tmp_path):
"""One download succeeds, one fails only successful one is kept."""
tweet = {
"user": {"name": "Test User"},
"created_at": "Sun Feb 08 18:45:00 +0000 2026",
"full_text": "Test tweet with two photos",
"entities": {
"media": [
{"type": "photo", "media_url_https": "https://pbs.twimg.com/media/test1.jpg"},
{"type": "photo", "media_url_https": "https://pbs.twimg.com/media/test2.jpg"},
]
},
}
test_file = tmp_path / "test1.jpg"
test_file.write_text("fake image data")
mock_archiver = MagicMock()
# First call succeeds, second fails
mock_archiver.download_from_url.side_effect = [str(test_file), None]
result = twitter_dropin.create_metadata(tweet, None, mock_archiver, "https://x.com/test/status/123")
assert len(result.media) == 1
assert result.media[0].filename == str(test_file)

View File

@@ -1,5 +1,6 @@
import pytest
from argparse import ArgumentParser, ArgumentTypeError
from requests.exceptions import SSLError
from auto_archiver.core.orchestrator import ArchivingOrchestrator
from auto_archiver.version import __version__
from auto_archiver.core.config import read_yaml, store_yaml
@@ -256,3 +257,34 @@ def test_load_failed_extractor_cleanup(test_args, mocker, caplog):
assert "Error during setup of modules: Test exception" in caplog.text
# make sure the 'cleanup' is called
assert "cleanup" in caplog.text
def test_check_for_updates_ssl_error(orchestrator, mocker):
"""check_for_updates should not raise when the HTTP request fails."""
mocker.patch(
"auto_archiver.core.orchestrator.requests.get",
side_effect=SSLError("SSL handshake failed"),
)
# should not raise
orchestrator.check_for_updates()
def test_check_for_updates_timeout(orchestrator, mocker):
"""check_for_updates should not raise on connection timeout."""
from requests.exceptions import ConnectionError
mocker.patch(
"auto_archiver.core.orchestrator.requests.get",
side_effect=ConnectionError("Connection refused"),
)
orchestrator.check_for_updates()
def test_check_for_updates_new_version_available(orchestrator, mocker):
"""check_for_updates should not raise when a newer version exists."""
mocker.patch(
"auto_archiver.core.orchestrator.requests.get",
return_value=mocker.Mock(json=lambda: {"info": {"version": "99.0.0"}}),
)
# should complete without error
orchestrator.check_for_updates()

View File

@@ -0,0 +1,147 @@
"""
Tests for deletion detection utilities.
These tests verify the current best-effort by the auto-archiver
to detect when content has been deleted or is unavailable across
various platforms.
"""
from auto_archiver.utils.deletion_detection import detect_deletion, flag_as_deleted, DeletionIndicators
from auto_archiver.core.metadata import Metadata
class TestDeletionIndicators:
"""Test the deletion indicator lists for various platforms."""
def test_twitter_indicators(self):
"""Verify Twitter deletion indicators are comprehensive."""
assert "Hmm...this page doesn't exist" in DeletionIndicators.TWITTER
assert "Try searching for something else" in DeletionIndicators.TWITTER
assert "This Tweet is unavailable" in DeletionIndicators.TWITTER
def test_platform_specific_indicators(self):
"""Test that platform-specific indicators are returned based on URL."""
twitter_indicators = DeletionIndicators.for_url("https://twitter.com/user/status/123")
assert any("page doesn't exist" in ind.lower() for ind in twitter_indicators)
instagram_indicators = DeletionIndicators.for_url("https://instagram.com/p/ABC123")
assert any("page isn't available" in ind.lower() for ind in instagram_indicators)
class TestDetectDeletion:
"""Test the detect_deletion function with various inputs."""
def test_detect_deletion_in_html_twitter(self):
"""Test detection of Twitter's deleted post page."""
html = "<html><body>Hmm...this page doesn't exist. Try searching for something else.</body></html>"
url = "https://twitter.com/user/status/123"
result = detect_deletion(html_content=html, url=url)
assert result is not None
assert result["is_deleted"] is True
assert result["platform"] == "twitter"
assert result["source"] == "html_content"
assert "page doesn't exist" in result["indicator"].lower()
def test_detect_deletion_in_page_title(self):
"""Test detection via page title."""
title = "Page Not Found"
url = "https://facebook.com/post/123"
result = detect_deletion(page_title=title, url=url)
assert result is not None
assert result["is_deleted"] is True
assert result["source"] == "page_title"
def test_detect_deletion_in_error_message(self):
"""Test detection via error messages."""
error = "yt_dlp.utils.DownloadError: This video is no longer available"
url = "https://youtube.com/watch?v=abc123"
result = detect_deletion(error_message=error, url=url)
assert result is not None
assert result["is_deleted"] is True
assert result["platform"] == "youtube"
assert result["source"] == "error_message"
def test_detect_deletion_in_video_metadata(self):
"""Test detection via yt-dlp video metadata."""
video_data = {"availability": "unavailable", "title": "Private video"}
url = "https://youtube.com/watch?v=test123"
result = detect_deletion(video_data=video_data, url=url)
assert result is not None
assert result["is_deleted"] is True
assert result["source"] == "video_metadata"
assert "availability" in result["indicator"]
def test_no_deletion_detected(self):
"""Test that normal content is not flagged as deleted."""
html = "<html><body><h1>Welcome to my page</h1><p>This is normal content.</p></body></html>"
title = "My Normal Page"
url = "https://example.com/page"
result = detect_deletion(html_content=html, page_title=title, url=url)
assert result is None
def test_instagram_media_not_found(self):
"""Test Instagram-specific deletion message."""
error = "Media not found or unavailable"
url = "https://instagram.com/p/ABC123"
result = detect_deletion(error_message=error, url=url)
assert result is not None
assert result["platform"] == "instagram"
assert "not found" in result["indicator"].lower()
def test_reddit_removed_content(self):
"""Test Reddit [removed] and [deleted] markers."""
html = "<div class='comment'>[removed]</div>"
url = "https://reddit.com/r/test/comments/abc123"
result = detect_deletion(html_content=html, url=url)
assert result is not None
assert result["platform"] == "reddit"
class TestFlagAsDeleted:
"""Test the flag_as_deleted function."""
def test_flag_metadata_as_deleted(self):
"""Verify that metadata is properly flagged with deletion info."""
metadata = Metadata()
deletion_info = {
"is_deleted": True,
"indicator": "This Tweet is unavailable",
"source": "html_content",
"platform": "twitter",
}
flag_as_deleted(metadata, deletion_info)
assert metadata.get("deletion_detected") is True
assert metadata.get("deletion_indicator") == "This Tweet is unavailable"
assert metadata.get("deletion_source") == "html_content"
assert metadata.get("deletion_platform") == "twitter"
assert metadata.status == "deleted_or_unavailable"
def test_metadata_contains_deletion_context(self):
"""Verify investigators have full context about the deletion."""
metadata = Metadata()
deletion_info = {
"is_deleted": True,
"indicator": "Video has been removed by the uploader",
"source": "error_message",
"platform": "youtube",
}
flag_as_deleted(metadata, deletion_info)
assert "deletion_indicator" in metadata.metadata
assert "uploader" in metadata.get("deletion_indicator")

View File

@@ -14,6 +14,7 @@ from auto_archiver.utils.misc import (
calculate_file_hash,
random_str,
get_timestamp,
ydl_entry_to_filename,
)
@@ -139,3 +140,47 @@ class TestMiscUtils:
def test_invalid_timestamp_returns_none(self):
assert get_timestamp("invalid-date") is None
class TestYdlEntryToFilename:
"""Tests for ydl_entry_to_filename, especially .part file filtering."""
def _make_mock_ydl(self, prepared_filename):
class MockYDL:
def prepare_filename(self, entry):
return prepared_filename
return MockYDL()
def test_returns_exact_file_if_exists(self, tmp_path):
video = tmp_path / "video.mp4"
video.write_bytes(b"data")
ydl = self._make_mock_ydl(str(video))
assert ydl_entry_to_filename(ydl, {}) == str(video)
def test_skips_part_file_returns_complete(self, tmp_path):
"""Simulates yt-dlp leaving a .part file from a failed format
while a complete .webm exists."""
(tmp_path / "f5U3IKfoSYs.f399.mp4.part").write_bytes(b"incomplete")
webm = tmp_path / "f5U3IKfoSYs.webm"
webm.write_bytes(b"complete video")
# ydl.prepare_filename returns the expected .mp4 which doesn't exist
ydl = self._make_mock_ydl(str(tmp_path / "f5U3IKfoSYs.mp4"))
result = ydl_entry_to_filename(ydl, {})
assert result == str(webm)
assert not result.endswith(".part")
def test_skips_part_file_returns_false_if_no_other_match(self, tmp_path):
"""Only a .part file exists — should return False."""
(tmp_path / "video.f399.mp4.part").write_bytes(b"incomplete")
ydl = self._make_mock_ydl(str(tmp_path / "video.mp4"))
assert ydl_entry_to_filename(ydl, {}) is False
def test_returns_false_when_no_files_match(self, tmp_path):
(tmp_path / "unrelated.txt").write_bytes(b"data")
ydl = self._make_mock_ydl(str(tmp_path / "video.mp4"))
assert ydl_entry_to_filename(ydl, {}) is False