Compare commits

...

42 Commits

Author SHA1 Message Date
msramalho
b2adceff25 Bump version to v0.6.5 for release 2023-08-24 12:43:49 +01:00
msramalho
92a0a92b47 closes #86 2023-08-24 12:43:28 +01:00
msramalho
bf3c04b3fc Bump version to v0.6.4 for release 2023-08-18 21:25:17 +01:00
msramalho
7eebecdb2c update dependencies 2023-08-18 21:25:13 +01:00
msramalho
b17b5953dd closes #59 2023-08-17 18:11:58 +01:00
msramalho
ceb717ea65 exclude vk emojis 2023-08-17 18:11:26 +01:00
msramalho
6e4fb76940 exclude ok resource images from wacz enricher 2023-08-09 11:26:46 +01:00
msramalho
810a31b1f0 fix: whisper handle error http code 2023-08-08 18:06:48 +01:00
msramalho
8b15d733b1 adds whisper endpoints 2023-08-05 14:03:57 +01:00
msramalho
ca37d54b7f Bump version to v0.6.3 for release 2023-08-05 13:58:39 +01:00
msramalho
a1742b5565 fixing whisper enricher 2023-08-05 13:57:09 +01:00
msramalho
60a1f3a27a minor fixes 2023-07-31 16:08:48 +01:00
msramalho
31c07a02e1 Bump version to v0.6.2 for release 2023-07-28 13:10:14 +01:00
msramalho
bd231488ff parameter fix 2023-07-28 13:10:06 +01:00
msramalho
fb197f1064 excluding telegram embeds 2023-07-28 12:57:15 +01:00
msramalho
ec1a78e973 Bump version to v0.6.1 for release 2023-07-28 12:51:37 +01:00
msramalho
139bdec051 excludes files from perceptual hash 2023-07-28 12:51:24 +01:00
msramalho
f15a70f859 missing hash_enricher import 2023-07-28 12:51:04 +01:00
msramalho
419eaef449 fixes unsued tmp_dir 2023-07-28 12:50:52 +01:00
msramalho
1695954c98 new metadata enricher 2023-07-28 12:46:30 +01:00
msramalho
aa71c85a98 improving ignored content from waczs 2023-07-28 12:19:14 +01:00
msramalho
7a5c9c65bd detects duplicates before storing, eg: wacz getting media already fetched by another archiver 2023-07-28 10:51:48 +01:00
msramalho
fc93ebaba0 cleanup 2023-07-28 10:49:39 +01:00
msramalho
1b44a302cd removing some reverse search engines 2023-07-28 10:49:20 +01:00
msramalho
1368f7aebc feat: making grayscale a toggle 2023-07-28 10:49:03 +01:00
msramalho
e3a0003a47 adding WACZ screenshots 2023-07-27 21:36:25 +01:00
msramalho
59551b3b20 minor improvements: finding best twitter image quality 2023-07-27 21:36:15 +01:00
msramalho
f086d89111 new escape message 2023-07-27 20:14:59 +01:00
msramalho
3dd3775cbd removes rearchiving logic 2023-07-27 20:14:50 +01:00
msramalho
1e66a2c905 Bump version to v0.6.0 for release 2023-07-27 15:42:29 +01:00
msramalho
e8f44b652e minor improvements 2023-07-27 15:42:23 +01:00
msramalho
dd034da844 feat: WACZ enricher can now be probed for media, and used as an archiver OR enricher 2023-07-27 15:42:10 +01:00
msramalho
65e3c99483 Bump version to v0.5.28 for release 2023-07-26 16:13:14 +01:00
msramalho
888ad8f004 fix: twitter hack videos extension detection 2023-07-26 16:12:56 +01:00
msramalho
086a9e6c84 fix: remove unnecessary log 2023-07-11 12:17:15 +01:00
msramalho
4d80ee6f02 Bump version to v0.5.27 for release 2023-07-11 12:16:06 +01:00
msramalho
92569ae6be fix: telegram archiver was outdated for images 2023-07-11 12:15:56 +01:00
msramalho
abaf86c776 Bump version to v0.5.26 for release 2023-07-02 18:42:59 +02:00
msramalho
8005a1955a fixes #82 twitter api walls 2023-07-02 18:42:43 +02:00
msramalho
b7889a182d readme update 2023-06-26 18:18:46 +01:00
msramalho
04f827f183 Bump version to v0.5.25 for release 2023-06-26 18:15:45 +01:00
msramalho
485901da3c security update 2023-06-26 18:15:19 +01:00
27 changed files with 1527 additions and 910 deletions

View File

@@ -9,7 +9,7 @@ RUN pip install --upgrade pip && \
pip install pipenv && \ pip install pipenv && \
add-apt-repository ppa:mozillateam/ppa && \ add-apt-repository ppa:mozillateam/ppa && \
apt-get update && \ apt-get update && \
apt-get install -y gcc ffmpeg fonts-noto && \ apt-get install -y gcc ffmpeg fonts-noto exiftool && \
apt-get install -y --no-install-recommends firefox-esr && \ apt-get install -y --no-install-recommends firefox-esr && \
ln -s /usr/bin/firefox-esr /usr/bin/firefox && \ ln -s /usr/bin/firefox-esr /usr/bin/firefox && \
wget https://github.com/mozilla/geckodriver/releases/download/v0.33.0/geckodriver-v0.33.0-linux64.tar.gz && \ wget https://github.com/mozilla/geckodriver/releases/download/v0.33.0/geckodriver-v0.33.0-linux64.tar.gz && \

11
Pipfile
View File

@@ -28,14 +28,17 @@ python-twitter-v2 = "*"
instaloader = "*" instaloader = "*"
tqdm = "*" tqdm = "*"
jinja2 = "*" jinja2 = "*"
cryptography = "==38.0.4" cryptography = "*"
dataclasses-json = "*" dataclasses-json = "*"
yt-dlp = ">=2023.2.17" yt-dlp = "*"
vk-url-scraper = "*" vk-url-scraper = "*"
uwsgi = "*"
requests = {extras = ["socks"], version = "*"} requests = {extras = ["socks"], version = "*"}
# wacz = "==0.4.8"
numpy = "*" numpy = "*"
warcio = "*"
# pywb and uwsgi are needed for browsertrix to run in docker
# wacz = "==0.4.8"
uwsgi = "*"
pywb = "*"
[requires] [requires]
python_version = "3.10" python_version = "3.10"

1854
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -197,7 +197,8 @@ Outputs:
* **Title**: Post title * **Title**: Post title
* **Text**: Post text * **Text**: Post text
* **Screenshot**: Link to screenshot of post * **Screenshot**: Link to screenshot of post
* **Hash**: Hash of archived HTML file (which contains hashes of post media) * **Hash**: Hash of archived HTML file (which contains hashes of post media) - for checksums/verification
* **Perceptual Hash**: Perceptual hashes of found images - these can be used for de-duplication of content
* **WACZ**: Link to a WACZ web archive of post * **WACZ**: Link to a WACZ web archive of post
* **ReplayWebpage**: Link to a ReplayWebpage viewer of the WACZ archive * **ReplayWebpage**: Link to a ReplayWebpage viewer of the WACZ archive
@@ -228,7 +229,7 @@ Use `python -m src.auto_archiver --config secrets/orchestration.yaml` to run fro
#### Docker development #### Docker development
working with docker locally: working with docker locally:
* `docker build . -t auto-archiver` to build a local image * `docker build . -t auto-archiver` to build a local image
* `docker run --rm -v $PWD/secrets:/app/secrets auto-archiver pipenv run python3 -m auto_archiver --config secrets/orchestration.yaml` * `docker run --rm -v $PWD/secrets:/app/secrets auto-archiver --config secrets/orchestration.yaml`
* to use local archive, also create a volume `-v` for it by adding `-v $PWD/local_archive:/app/local_archive` * to use local archive, also create a volume `-v` for it by adding `-v $PWD/local_archive:/app/local_archive`

View File

@@ -12,13 +12,15 @@ steps:
# - tiktok_archiver # - tiktok_archiver
- youtubedl_archiver - youtubedl_archiver
# - wayback_archiver_enricher # - wayback_archiver_enricher
# - wacz_archiver_enricher
enrichers: enrichers:
- hash_enricher - hash_enricher
# - metadata_enricher
# - screenshot_enricher # - screenshot_enricher
# - thumbnail_enricher # - thumbnail_enricher
# - wayback_archiver_enricher # - wayback_archiver_enricher
# - wacz_enricher # - wacz_archiver_enricher
# - pdq_hash_enricher # - pdq_hash_enricher # if you want to calculate hashes for thumbnails, include this after thumbnail_enricher
formatter: html_formatter # defaults to mute_formatter formatter: html_formatter # defaults to mute_formatter
storages: storages:
- local_storage - local_storage
@@ -95,7 +97,7 @@ configurations:
secret: "wayback secret" secret: "wayback secret"
hash_enricher: hash_enricher:
algorithm: "SHA3-512" # can also be SHA-256 algorithm: "SHA3-512" # can also be SHA-256
wacz_enricher: wacz_archiver_enricher:
profile: secrets/profile.tar.gz profile: secrets/profile.tar.gz
local_storage: local_storage:
save_to: "./local_archive" save_to: "./local_archive"

View File

@@ -27,11 +27,6 @@ class Archiver(Step):
# used to clean unnecessary URL parameters OR unfurl redirect links # used to clean unnecessary URL parameters OR unfurl redirect links
return url return url
def is_rearchivable(self, url: str) -> bool:
# archivers can signal if it does not make sense to rearchive a piece of content
# default is rearchiving
return True
def _guess_file_type(self, path: str) -> str: def _guess_file_type(self, path: str) -> str:
""" """
Receives a URL or filename and returns global mimetype like 'image' or 'video' Receives a URL or filename and returns global mimetype like 'image' or 'video'
@@ -56,6 +51,7 @@ class Archiver(Step):
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36'
} }
d = requests.get(url, headers=headers) d = requests.get(url, headers=headers)
assert d.status_code == 200, f"got response code {d.status_code} for {url=}"
with open(to_filename, 'wb') as f: with open(to_filename, 'wb') as f:
f.write(d.content) f.write(d.content)
return to_filename return to_filename

View File

@@ -19,10 +19,6 @@ class TelegramArchiver(Archiver):
def configs() -> dict: def configs() -> dict:
return {} return {}
def is_rearchivable(self, url: str) -> bool:
# telegram posts are static
return False
def download(self, item: Metadata) -> Metadata: def download(self, item: Metadata) -> Metadata:
url = item.get_url() url = item.get_url()
# detect URLs that we definitely cannot handle # detect URLs that we definitely cannot handle
@@ -48,7 +44,7 @@ class TelegramArchiver(Archiver):
video = s.find("video") video = s.find("video")
if video is None: if video is None:
logger.warning("could not find video") logger.warning("could not find video")
image_tags = s.find_all(class_="js-message_photo") image_tags = s.find_all(class_="tgme_widget_message_photo_wrap")
image_urls = [] image_urls = []
for im in image_tags: for im in image_tags:
@@ -57,10 +53,10 @@ class TelegramArchiver(Archiver):
if not len(image_urls): return False if not len(image_urls): return False
for img_url in image_urls: for img_url in image_urls:
result.add_media(Media(self.download_from_url(img_url))) result.add_media(Media(self.download_from_url(img_url, item=item)))
else: else:
video_url = video.get('src') video_url = video.get('src')
m_video = Media(self.download_from_url(video_url)) m_video = Media(self.download_from_url(video_url, item=item))
# extract duration from HTML # extract duration from HTML
try: try:
duration = s.find_all('time')[0].contents[0] duration = s.find_all('time')[0].contents[0]

View File

@@ -38,10 +38,6 @@ class TelethonArchiver(Archiver):
} }
} }
def is_rearchivable(self, url: str) -> bool:
# telegram posts are static
return False
def setup(self) -> None: def setup(self) -> None:
""" """
1. trigger login process for telegram or proceed if already saved in a session file 1. trigger login process for telegram or proceed if already saved in a session file

View File

@@ -16,10 +16,6 @@ class TiktokArchiver(Archiver):
def configs() -> dict: def configs() -> dict:
return {} return {}
def is_rearchivable(self, url: str) -> bool:
# TikTok posts are static
return False
def download(self, item: Metadata) -> Metadata: def download(self, item: Metadata) -> Metadata:
url = item.get_url() url = item.get_url()
if 'tiktok.com' not in url: if 'tiktok.com' not in url:

View File

@@ -6,6 +6,7 @@ from slugify import slugify
from . import Archiver from . import Archiver
from ..core import Metadata, Media from ..core import Metadata, Media
from ..utils import UrlUtil
class TwitterArchiver(Archiver): class TwitterArchiver(Archiver):
@@ -36,10 +37,6 @@ class TwitterArchiver(Archiver):
# https://twitter.com/MeCookieMonster/status/1617921633456640001?s=20&t=3d0g4ZQis7dCbSDg-mE7-w # https://twitter.com/MeCookieMonster/status/1617921633456640001?s=20&t=3d0g4ZQis7dCbSDg-mE7-w
return self.link_clean_pattern.sub("\\1", url) return self.link_clean_pattern.sub("\\1", url)
def is_rearchivable(self, url: str) -> bool:
# Twitter posts are static (for now)
return False
def download(self, item: Metadata) -> Metadata: def download(self, item: Metadata) -> Metadata:
""" """
if this url is archivable will download post info and look for other posts from the same group with media. if this url is archivable will download post info and look for other posts from the same group with media.
@@ -77,7 +74,7 @@ class TwitterArchiver(Archiver):
media.set("src", variant.url) media.set("src", variant.url)
mimetype = variant.contentType mimetype = variant.contentType
elif type(tweet_media) == Photo: elif type(tweet_media) == Photo:
media.set("src", tweet_media.fullUrl.replace('name=large', 'name=orig')) media.set("src", UrlUtil.twitter_best_quality_url(tweet_media.fullUrl))
mimetype = "image/jpeg" mimetype = "image/jpeg"
else: else:
logger.warning(f"Could not get media URL of {tweet_media}") logger.warning(f"Could not get media URL of {tweet_media}")
@@ -90,20 +87,22 @@ class TwitterArchiver(Archiver):
def download_alternative(self, item: Metadata, url: str, tweet_id: str) -> Metadata: def download_alternative(self, item: Metadata, url: str, tweet_id: str) -> Metadata:
""" """
CURRENTLY STOPPED WORKING Hack alternative working again.
https://stackoverflow.com/a/71867055/6196010 (OUTDATED URL)
https://github.com/JustAnotherArchivist/snscrape/issues/996#issuecomment-1615937362
next to test: https://cdn.embedly.com/widgets/media.html?&schema=twitter&url=https://twitter.com/bellingcat/status/1674700676612386816
""" """
return False
# https://stackoverflow.com/a/71867055/6196010
logger.debug(f"Trying twitter hack for {url=}") logger.debug(f"Trying twitter hack for {url=}")
result = Metadata() result = Metadata()
hack_url = f"https://cdn.syndication.twimg.com/tweet?id={tweet_id}" hack_url = f"https://cdn.syndication.twimg.com/tweet-result?id={tweet_id}"
r = requests.get(hack_url) r = requests.get(hack_url)
if r.status_code != 200: return False if r.status_code != 200: return False
tweet = r.json() tweet = r.json()
urls = [] urls = []
for p in tweet["photos"]: for p in tweet.get("photos", []):
urls.append(p["url"]) urls.append(p["url"])
# 1 tweet has 1 video max # 1 tweet has 1 video max
@@ -113,14 +112,19 @@ class TwitterArchiver(Archiver):
logger.debug(f"Twitter hack got {urls=}") logger.debug(f"Twitter hack got {urls=}")
for u in urls: for i, u in enumerate(urls):
media = Media() media = Media(filename="")
u = UrlUtil.twitter_best_quality_url(u)
media.set("src", u) media.set("src", u)
media.filename = self.download_from_url(u, f'{slugify(url)}_{i}', item) ext = ""
if (mtype := mimetypes.guess_type(UrlUtil.remove_get_parameters(u))[0]):
ext = mimetypes.guess_extension(mtype)
media.filename = self.download_from_url(u, f'{slugify(url)}_{i}{ext}', item)
result.add_media(media) result.add_media(media)
result.set_content(json.dumps(tweet, ensure_ascii=False)).set_timestamp(datetime.strptime(tweet["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ")) result.set_title(tweet.get("text")).set_content(json.dumps(tweet, ensure_ascii=False)).set_timestamp(datetime.strptime(tweet["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ"))
return result return result.success("twitter-hack")
def get_username_tweet_id(self, url): def get_username_tweet_id(self, url):
# detect URLs that we definitely cannot handle # detect URLs that we definitely cannot handle

View File

@@ -27,10 +27,6 @@ class VkArchiver(Archiver):
"session_file": {"default": "secrets/vk_config.v2.json", "help": "valid VKontakte password"}, "session_file": {"default": "secrets/vk_config.v2.json", "help": "valid VKontakte password"},
} }
def is_rearchivable(self, url: str) -> bool:
# VK content is static
return False
def download(self, item: Metadata) -> Metadata: def download(self, item: Metadata) -> Metadata:
url = item.get_url() url = item.get_url()

View File

@@ -1,10 +1,15 @@
from __future__ import annotations from __future__ import annotations
import os
import traceback
from typing import Any, List from typing import Any, List
from dataclasses import dataclass, field from dataclasses import dataclass, field
from dataclasses_json import dataclass_json, config from dataclasses_json import dataclass_json, config
import mimetypes import mimetypes
import ffmpeg
from ffmpeg._run import Error
from .context import ArchivingContext from .context import ArchivingContext
from loguru import logger from loguru import logger
@@ -77,3 +82,20 @@ class Media:
def is_image(self) -> bool: def is_image(self) -> bool:
return self.mimetype.startswith("image") return self.mimetype.startswith("image")
def is_valid_video(self) -> bool:
# checks for video streams with ffmpeg, or min file size for a video
# self.is_video() should be used together with this method
try:
streams = ffmpeg.probe(self.filename, select_streams='v')['streams']
logger.warning(f"STREAMS FOR {self.filename} {streams}")
return any(s.get("duration_ts", 0) > 0 for s in streams)
except Error: return False # ffmpeg errors when reading bad files
except Exception as e:
logger.error(e)
logger.error(traceback.format_exc())
try:
fsize = os.path.getsize(self.filename)
return fsize > 20_000
except: pass
return True

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
from typing import Any, List, Union, Dict from typing import Any, List, Union, Dict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from dataclasses_json import dataclass_json, config from dataclasses_json import dataclass_json, config
@@ -16,7 +17,6 @@ class Metadata:
status: str = "no archiver" status: str = "no archiver"
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
media: List[Media] = field(default_factory=list) media: List[Media] = field(default_factory=list)
rearchivable: bool = True # defaults to true, archivers can overwrite
def __post_init__(self): def __post_init__(self):
self.set("_processed_at", datetime.datetime.utcnow()) self.set("_processed_at", datetime.datetime.utcnow())
@@ -29,7 +29,6 @@ class Metadata:
if overwrite_left: if overwrite_left:
if right.status and len(right.status): if right.status and len(right.status):
self.status = right.status self.status = right.status
self.rearchivable |= right.rearchivable
for k, v in right.metadata.items(): for k, v in right.metadata.items():
assert k not in self.metadata or type(v) == type(self.get(k)) assert k not in self.metadata or type(v) == type(self.get(k))
if type(v) not in [dict, list, set] or k not in self.metadata: if type(v) not in [dict, list, set] or k not in self.metadata:
@@ -44,6 +43,7 @@ class Metadata:
def store(self: Metadata, override_storages: List = None): def store(self: Metadata, override_storages: List = None):
# calls .store for all contained media. storages [Storage] # calls .store for all contained media. storages [Storage]
self.remove_duplicate_media_by_hash()
storages = override_storages or ArchivingContext.get("storages") storages = override_storages or ArchivingContext.get("storages")
for media in self.media: for media in self.media:
media.store(override_storages=storages, url=self.get_url()) media.store(override_storages=storages, url=self.get_url())
@@ -124,6 +124,27 @@ class Metadata:
if m.get("id") == id: return m if m.get("id") == id: return m
return default return default
def remove_duplicate_media_by_hash(self) -> None:
# iterates all media, calculates a hash if it's missing and deletes duplicates
def calculate_hash_in_chunks(hash_algo, chunksize, filename) -> str:
# taken from hash_enricher, cannot be isolated to misc due to circular imports
with open(filename, "rb") as f:
while True:
buf = f.read(chunksize)
if not buf: break
hash_algo.update(buf)
return hash_algo.hexdigest()
media_hashes = set()
new_media = []
for m in self.media:
h = m.get("hash")
if not h: h = calculate_hash_in_chunks(hashlib.sha256(), int(1.6e7), m.filename)
if len(h) and h in media_hashes: continue
media_hashes.add(h)
new_media.append(m)
self.media = new_media
def get_first_image(self, default=None) -> Media: def get_first_image(self, default=None) -> Media:
for m in self.media: for m in self.media:
if "image" in m.mimetype: return m if "image" in m.mimetype: return m

View File

@@ -62,11 +62,7 @@ class ArchivingOrchestrator:
result.set_url(url) result.set_url(url)
if original_url != url: result.set("original_url", original_url) if original_url != url: result.set("original_url", original_url)
# 2 - rearchiving logic + notify start to DB # 2 - notify start to DB
# archivers can signal whether the content is rearchivable: eg: tweet vs webpage
for a in self.archivers: result.rearchivable |= a.is_rearchivable(url)
logger.debug(f"{result.rearchivable=} for {url=}")
# signal to DB that archiving has started # signal to DB that archiving has started
# and propagate already archived if it exists # and propagate already archived if it exists
cached_result = None cached_result = None
@@ -78,7 +74,7 @@ class ArchivingOrchestrator:
d.started(result) d.started(result)
if (local_result := d.fetch(result)): if (local_result := d.fetch(result)):
cached_result = (cached_result or Metadata()).merge(local_result) cached_result = (cached_result or Metadata()).merge(local_result)
if cached_result and not cached_result.rearchivable: if cached_result:
logger.debug("Found previously archived entry") logger.debug("Found previously archived entry")
for d in self.databases: for d in self.databases:
d.done(cached_result) d.done(cached_result)
@@ -109,6 +105,7 @@ class ArchivingOrchestrator:
# looks for Media in result.media and also result.media[x].properties (as list or dict values) # looks for Media in result.media and also result.media[x].properties (as list or dict values)
result.store() result.store()
# 6 - format and store formatted if needed # 6 - format and store formatted if needed
# enrichers typically need access to already stored URLs etc # enrichers typically need access to already stored URLs etc
if (final_media := self.formatter.format(result)): if (final_media := self.formatter.format(result)):

View File

@@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from inspect import ClassFoundException from inspect import ClassFoundException
from typing import Type from typing import Type
from abc import ABC from abc import ABC
# from collections.abc import Iterable
@dataclass @dataclass

View File

@@ -67,7 +67,7 @@ class GsheetsDb(Database):
batch_if_valid('title', item.get_title()) batch_if_valid('title', item.get_title())
batch_if_valid('text', item.get("content", "")) batch_if_valid('text', item.get("content", ""))
batch_if_valid('timestamp', item.get_timestamp()) batch_if_valid('timestamp', item.get_timestamp())
batch_if_valid('hash', media.get("hash", "not-calculated")) if media: batch_if_valid('hash', media.get("hash", "not-calculated"))
# merge all pdq hashes into a single string, if present # merge all pdq hashes into a single string, if present
pdq_hashes = [] pdq_hashes = []

View File

@@ -3,6 +3,7 @@ from .screenshot_enricher import ScreenshotEnricher
from .wayback_enricher import WaybackArchiverEnricher from .wayback_enricher import WaybackArchiverEnricher
from .hash_enricher import HashEnricher from .hash_enricher import HashEnricher
from .thumbnail_enricher import ThumbnailEnricher from .thumbnail_enricher import ThumbnailEnricher
from .wacz_enricher import WaczEnricher from .wacz_enricher import WaczArchiverEnricher
from .whisper_enricher import WhisperEnricher from .whisper_enricher import WhisperEnricher
from .pdq_hash_enricher import PdqHashEnricher from .pdq_hash_enricher import PdqHashEnricher
from .metadata_enricher import MetadataEnricher

View File

@@ -23,7 +23,7 @@ class HashEnricher(Enricher):
def configs() -> dict: def configs() -> dict:
return { return {
"algorithm": {"default": "SHA-256", "help": "hash algorithm to use", "choices": ["SHA-256", "SHA3-512"]}, "algorithm": {"default": "SHA-256", "help": "hash algorithm to use", "choices": ["SHA-256", "SHA3-512"]},
"chunksize": {"default": 1.6e7, "help": "number of bytes to use when reading files in chunks (if this value is too large you will run out of RAM), default is 16MB"}, "chunksize": {"default": int(1.6e7), "help": "number of bytes to use when reading files in chunks (if this value is too large you will run out of RAM), default is 16MB"},
} }
def enrich(self, to_enrich: Metadata) -> None: def enrich(self, to_enrich: Metadata) -> None:
@@ -34,7 +34,7 @@ class HashEnricher(Enricher):
if len(hd := self.calculate_hash(m.filename)): if len(hd := self.calculate_hash(m.filename)):
to_enrich.media[i].set("hash", f"{self.algorithm}:{hd}") to_enrich.media[i].set("hash", f"{self.algorithm}:{hd}")
def calculate_hash(self, filename): def calculate_hash(self, filename) -> str:
hash = None hash = None
if self.algorithm == "SHA-256": if self.algorithm == "SHA-256":
hash = hashlib.sha256() hash = hashlib.sha256()

View File

@@ -0,0 +1,47 @@
import subprocess
import traceback
from loguru import logger
from . import Enricher
from ..core import Metadata
class MetadataEnricher(Enricher):
"""
Extracts metadata information from files using exiftool.
"""
name = "metadata_enricher"
def __init__(self, config: dict) -> None:
# without this STEP.__init__ is not called
super().__init__(config)
@staticmethod
def configs() -> dict:
return {}
def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url()
logger.debug(f"extracting EXIF metadata for {url=}")
for i, m in enumerate(to_enrich.media):
if len(md := self.get_metadata(m.filename)):
to_enrich.media[i].set("metadata", md)
def get_metadata(self, filename: str) -> dict:
try:
# Run ExifTool command to extract metadata from the file
cmd = ['exiftool', filename]
result = subprocess.run(cmd, capture_output=True, text=True)
# Process the output to extract individual metadata fields
metadata = {}
for line in result.stdout.splitlines():
field, value = line.strip().split(':', 1)
metadata[field.strip()] = value.strip()
return metadata
except FileNotFoundError:
logger.error("[exif_enricher] ExifTool not found. Make sure ExifTool is installed and added to PATH.")
except Exception as e:
logger.error(f"Error occurred: {e}: {traceback.format_exc()}")
return {}

View File

@@ -1,6 +1,7 @@
import traceback
import pdqhash import pdqhash
import numpy as np import numpy as np
from PIL import Image from PIL import Image, UnidentifiedImageError
from loguru import logger from loguru import logger
from . import Enricher from . import Enricher
@@ -25,18 +26,27 @@ class PdqHashEnricher(Enricher):
def enrich(self, to_enrich: Metadata) -> None: def enrich(self, to_enrich: Metadata) -> None:
url = to_enrich.get_url() url = to_enrich.get_url()
logger.debug(f"calculating perceptual hashes for {url=}") logger.debug(f"calculating perceptual hashes for {url=}")
media_with_hashes = []
for m in to_enrich.media: for m in to_enrich.media:
for media in m.all_inner_media(True): for media in m.all_inner_media(True):
if media.is_image() and media.get("id") != "screenshot" and len(hd := self.calculate_pdq_hash(media.filename)): media_id = media.get("id", "")
media.set("pdq_hash", hd) if media.is_image() and "screenshot" not in media_id and "warc-file-" not in media_id and len(hd := self.calculate_pdq_hash(media.filename)):
media.set("pdq_hash", hd)
media_with_hashes.append(media.filename)
logger.debug(f"calculated '{len(media_with_hashes)}' perceptual hashes for {url=}: {media_with_hashes}")
def calculate_pdq_hash(self, filename): def calculate_pdq_hash(self, filename):
# returns a hexadecimal string with the perceptual hash for the given filename # returns a hexadecimal string with the perceptual hash for the given filename
with Image.open(filename) as img: try:
# convert the image to RGB with Image.open(filename) as img:
image_rgb = np.array(img.convert("RGB")) # convert the image to RGB
# compute the 256-bit PDQ hash (we do not store the quality score) image_rgb = np.array(img.convert("RGB"))
hash_array, _ = pdqhash.compute(image_rgb) # compute the 256-bit PDQ hash (we do not store the quality score)
hash = "".join(str(b) for b in hash_array) hash_array, _ = pdqhash.compute(image_rgb)
return hex(int(hash, 2))[2:] hash = "".join(str(b) for b in hash_array)
return hex(int(hash, 2))[2:]
except UnidentifiedImageError as e:
logger.error(f"Image {filename=} is likely corrupted or in unsupported format {e}: {traceback.format_exc()}")
return ""

View File

@@ -1,16 +1,23 @@
import mimetypes
import os, shutil, subprocess, uuid import os, shutil, subprocess, uuid
from zipfile import ZipFile
from loguru import logger from loguru import logger
from warcio.archiveiterator import ArchiveIterator
from ..core import Media, Metadata, ArchivingContext from ..core import Media, Metadata, ArchivingContext
from . import Enricher from . import Enricher
from ..archivers import Archiver
from ..utils import UrlUtil from ..utils import UrlUtil
class WaczEnricher(Enricher): class WaczArchiverEnricher(Enricher, Archiver):
""" """
Submits the current URL to the webarchive and returns a job_id or completed archive Uses https://github.com/webrecorder/browsertrix-crawler to generate a .WACZ archive of the URL
If used with [profiles](https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)
it can become quite powerful for archiving private content.
When used as an archiver it will extract the media from the .WACZ archive so it can be enriched.
""" """
name = "wacz_enricher" name = "wacz_archiver_enricher"
def __init__(self, config: dict) -> None: def __init__(self, config: dict) -> None:
# without this STEP.__init__ is not called # without this STEP.__init__ is not called
@@ -20,11 +27,22 @@ class WaczEnricher(Enricher):
def configs() -> dict: def configs() -> dict:
return { return {
"profile": {"default": None, "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)."}, "profile": {"default": None, "help": "browsertrix-profile (for profile generation see https://github.com/webrecorder/browsertrix-crawler#creating-and-using-browser-profiles)."},
"timeout": {"default": 90, "help": "timeout for WACZ generation in seconds"}, "timeout": {"default": 120, "help": "timeout for WACZ generation in seconds"},
"ignore_auth_wall": {"default": True, "help": "skip URL if it is behind authentication wall, set to False if you have browsertrix profile configured for private content."}, "extract_media": {"default": True, "help": "If enabled all the images/videos/audio present in the WACZ archive will be extracted into separate Media. The .wacz file will be kept untouched."}
} }
def download(self, item: Metadata) -> Metadata:
# this new Metadata object is required to avoid duplication
result = Metadata()
result.merge(item)
if self.enrich(result):
return result.success("wacz")
def enrich(self, to_enrich: Metadata) -> bool: def enrich(self, to_enrich: Metadata) -> bool:
if to_enrich.get_media_by_id("browsertrix"):
logger.info(f"WACZ enricher had already been executed: {to_enrich.get_media_by_id('browsertrix')}")
return True
url = to_enrich.get_url() url = to_enrich.get_url()
collection = str(uuid.uuid4())[0:8] collection = str(uuid.uuid4())[0:8]
@@ -39,6 +57,7 @@ class WaczEnricher(Enricher):
"--scopeType", "page", "--scopeType", "page",
"--generateWACZ", "--generateWACZ",
"--text", "--text",
"--screenshot", "fullPage",
"--collection", collection, "--collection", collection,
"--id", collection, "--id", collection,
"--saveState", "never", "--saveState", "never",
@@ -61,6 +80,7 @@ class WaczEnricher(Enricher):
"--scopeType", "page", "--scopeType", "page",
"--generateWACZ", "--generateWACZ",
"--text", "--text",
"--screenshot", "fullPage",
"--collection", collection, "--collection", collection,
"--behaviors", "autoscroll,autoplay,autofetch,siteSpecific", "--behaviors", "autoscroll,autoplay,autofetch,siteSpecific",
"--behaviorTimeout", str(self.timeout), "--behaviorTimeout", str(self.timeout),
@@ -79,8 +99,6 @@ class WaczEnricher(Enricher):
logger.error(f"WACZ generation failed: {e}") logger.error(f"WACZ generation failed: {e}")
return False return False
if os.getenv('RUNNING_IN_DOCKER'): if os.getenv('RUNNING_IN_DOCKER'):
filename = os.path.join("collections", collection, f"{collection}.wacz") filename = os.path.join("collections", collection, f"{collection}.wacz")
else: else:
@@ -91,3 +109,81 @@ class WaczEnricher(Enricher):
return False return False
to_enrich.add_media(Media(filename), "browsertrix") to_enrich.add_media(Media(filename), "browsertrix")
if self.extract_media:
self.extract_media_from_wacz(to_enrich, filename)
return True
def extract_media_from_wacz(self, to_enrich: Metadata, wacz_filename: str) -> None:
"""
Receives a .wacz archive, and extracts all relevant media from it, adding them to to_enrich.
"""
logger.info(f"WACZ extract_media flag is set, extracting media from {wacz_filename=}")
# unzipping the .wacz
tmp_dir = ArchivingContext.get_tmp_dir()
unzipped_dir = os.path.join(tmp_dir, "unzipped")
with ZipFile(wacz_filename, 'r') as z_obj:
z_obj.extractall(path=unzipped_dir)
# if warc is split into multiple gzip chunks, merge those
warc_dir = os.path.join(unzipped_dir, "archive")
warc_filename = os.path.join(tmp_dir, "merged.warc")
with open(warc_filename, 'wb') as outfile:
for filename in sorted(os.listdir(warc_dir)):
if filename.endswith('.gz'):
chunk_file = os.path.join(warc_dir, filename)
with open(chunk_file, 'rb') as infile:
shutil.copyfileobj(infile, outfile)
# get media out of .warc
counter = 0
seen_urls = set()
with open(warc_filename, 'rb') as warc_stream:
for record in ArchiveIterator(warc_stream):
# only include fetched resources
if record.rec_type == "resource": # screenshots
fn = os.path.join(tmp_dir, f"warc-file-{counter}.png")
with open(fn, "wb") as outf: outf.write(record.raw_stream.read())
m = Media(filename=fn)
to_enrich.add_media(m, "browsertrix-screenshot")
counter += 1
if record.rec_type != 'response': continue
record_url = record.rec_headers.get_header('WARC-Target-URI')
if not UrlUtil.is_relevant_url(record_url):
logger.debug(f"Skipping irrelevant URL {record_url} but it's still present in the WACZ.")
continue
if record_url in seen_urls:
logger.debug(f"Skipping already seen URL {record_url}.")
continue
# filter by media mimetypes
content_type = record.http_headers.get("Content-Type")
if not content_type: continue
if not any(x in content_type for x in ["video", "image", "audio"]): continue
# create local file and add media
ext = mimetypes.guess_extension(content_type)
warc_fn = f"warc-file-{counter}{ext}"
fn = os.path.join(tmp_dir, warc_fn)
record_url_best_qual = UrlUtil.twitter_best_quality_url(record_url)
with open(fn, "wb") as outf: outf.write(record.raw_stream.read())
m = Media(filename=fn)
m.set("src", record_url)
# if a link with better quality exists, try to download that
if record_url_best_qual != record_url:
try:
m.filename = self.download_from_url(record_url_best_qual, warc_fn, to_enrich)
m.set("src", record_url_best_qual)
m.set("src_alternative", record_url)
except Exception as e: logger.warning(f"Unable to download best quality URL for {record_url=} got error {e}, using original in WARC.")
# remove bad videos
if m.is_video() and not m.is_valid_video(): continue
to_enrich.add_media(m, warc_fn)
counter += 1
seen_urls.add(record_url)
logger.info(f"WACZ extract_media finished, found {counter} relevant media file(s)")

View File

@@ -23,11 +23,13 @@ class WaybackArchiverEnricher(Enricher, Archiver):
def configs() -> dict: def configs() -> dict:
return { return {
"timeout": {"default": 15, "help": "seconds to wait for successful archive confirmation from wayback, if more than this passes the result contains the job_id so the status can later be checked manually."}, "timeout": {"default": 15, "help": "seconds to wait for successful archive confirmation from wayback, if more than this passes the result contains the job_id so the status can later be checked manually."},
"if_not_archived_within": {"default": None, "help": "only tell wayback to archive if no archive is available before the number of seconds specified, use None to ignore this option. For more information: https://docs.google.com/document/d/1Nsv52MvSjbLb2PCpHlat0gkzw0EvtSgpKHu4mk0MnrA"},
"key": {"default": None, "help": "wayback API key. to get credentials visit https://archive.org/account/s3.php"}, "key": {"default": None, "help": "wayback API key. to get credentials visit https://archive.org/account/s3.php"},
"secret": {"default": None, "help": "wayback API secret. to get credentials visit https://archive.org/account/s3.php"} "secret": {"default": None, "help": "wayback API secret. to get credentials visit https://archive.org/account/s3.php"}
} }
def download(self, item: Metadata) -> Metadata: def download(self, item: Metadata) -> Metadata:
# this new Metadata object is required to avoid duplication
result = Metadata() result = Metadata()
result.merge(item) result.merge(item)
if self.enrich(result): if self.enrich(result):
@@ -49,7 +51,11 @@ class WaybackArchiverEnricher(Enricher, Archiver):
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"LOW {self.key}:{self.secret}" "Authorization": f"LOW {self.key}:{self.secret}"
} }
r = requests.post('https://web.archive.org/save/', headers=ia_headers, data={'url': url}) post_data = {'url': url}
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)
if r.status_code != 200: if r.status_code != 200:
logger.error(em := f"Internet archive failed with status of {r.status_code}: {r.json()}") logger.error(em := f"Internet archive failed with status of {r.status_code}: {r.json()}")

View File

@@ -18,17 +18,18 @@ class WhisperEnricher(Enricher):
def __init__(self, config: dict) -> None: def __init__(self, config: dict) -> None:
# without this STEP.__init__ is not called # without this STEP.__init__ is not called
super().__init__(config) super().__init__(config)
assert type(self.api_endpoint) == str and len(self.api_endpoint) > 0, "please provide a value for the whisper_enricher api_endpoint"
assert type(self.api_key) == str and len(self.api_key) > 0, "please provide a value for the whisper_enricher api_key" assert type(self.api_key) == str and len(self.api_key) > 0, "please provide a value for the whisper_enricher api_key"
self.timeout = int(self.timeout) self.timeout = int(self.timeout)
@staticmethod @staticmethod
def configs() -> dict: def configs() -> dict:
return { return {
"api_endpoint": {"default": "https://whisper.spoettel.dev/api/v1", "help": "WhisperApi api endpoint"}, "api_endpoint": {"default": None, "help": "WhisperApi api endpoint, eg: https://whisperbox-api.com/api/v1, a deployment of https://github.com/bellingcat/whisperbox-transcribe."},
"api_key": {"default": None, "help": "WhisperApi api key for authentication"}, "api_key": {"default": None, "help": "WhisperApi api key for authentication"},
"include_srt": {"default": False, "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players)."}, "include_srt": {"default": False, "help": "Whether to include a subtitle SRT (SubRip Subtitle file) for the video (can be used in video players)."},
"timeout": {"default": 90, "help": "How many seconds to wait at most for a successful job completion."}, "timeout": {"default": 90, "help": "How many seconds to wait at most for a successful job completion."},
"action": {"default": "translation", "help": "which Whisper operation to execute", "choices": ["transcript", "translation", "language_detection"]}, "action": {"default": "translate", "help": "which Whisper operation to execute", "choices": ["transcribe", "translate", "language_detection"]},
} }
@@ -56,9 +57,12 @@ class WhisperEnricher(Enricher):
for i, m in enumerate(to_enrich.media): for i, m in enumerate(to_enrich.media):
if m.is_video() or m.is_audio(): if m.is_video() or m.is_audio():
job_id = to_enrich.media[i].get("whisper_model")["job_id"] job_id = to_enrich.media[i].get("whisper_model", {}).get("job_id")
if not job_id: continue
to_enrich.media[i].set("whisper_model", { to_enrich.media[i].set("whisper_model", {
"job_id": job_id, "job_id": job_id,
"job_status_check": f"{self.api_endpoint}/jobs/{job_id}",
"job_artifacts_check": f"{self.api_endpoint}/jobs/{job_id}/artifacts",
**(job_results[job_id] if job_results[job_id] else {"result": "incomplete or failed job"}) **(job_results[job_id] if job_results[job_id] else {"result": "incomplete or failed job"})
}) })
# append the extracted text to the content of the post so it gets written to the DBs like gsheets text column # append the extracted text to the content of the post so it gets written to the DBs like gsheets text column
@@ -76,6 +80,7 @@ class WhisperEnricher(Enricher):
"type": self.action, "type": self.action,
# "language": "string" # may be a config # "language": "string" # may be a config
} }
logger.debug(f"calling API with {payload=}")
response = requests.post(f'{self.api_endpoint}/jobs', json=payload, headers={'Authorization': f'Bearer {self.api_key}'}) response = requests.post(f'{self.api_endpoint}/jobs', json=payload, headers={'Authorization': f'Bearer {self.api_key}'})
assert response.status_code == 201, f"calling the whisper api {self.api_endpoint} returned a non-success code: {response.status_code}" assert response.status_code == 201, f"calling the whisper api {self.api_endpoint} returned a non-success code: {response.status_code}"
logger.debug(response.json()) logger.debug(response.json())

View File

@@ -65,11 +65,12 @@
} }
/* Disable grayscale on hover */ /* Disable grayscale on hover */
img:hover, /* img:hover,
video:hover { video:hover {
-webkit-filter: grayscale(0); -webkit-filter: grayscale(0);
filter: none; filter: none;
} } */
.collapsible { .collapsible {
background-color: #777; background-color: #777;
@@ -101,57 +102,68 @@
<body> <body>
<div id="notification"></div> <div id="notification"></div>
<h2>Archived media for <a href="{{ url }}">{{ url }}</a></h2> <h2>Archived media for <a href="{{ url }}">{{ url }}</a></h2>
{% if title | string | length > 0 %}
<p><b>title:</b> '<span class="copy">{{ title }}</span>'</p> <p><b>title:</b> '<span class="copy">{{ title }}</span>'</p>
{% endif %}
<h2 class="center">content {{ media | length }} item(s)</h2> <h2 class="center">content {{ media | length }} item(s)</h2>
<form class="center">
<label>
<input type="checkbox" id="safe-media-view" checked>
Safe Media View
</label>
</form>
<table class="content"> <table class="content">
<tr> <tr>
<th>about</th> <th>about</th>
<th>preview(s)</th> <th>preview(s)</th>
</tr> </tr>
{% for m in media %} <tbody>
<tr> {% for m in media %}
<td> <tr>
<ul> <td>
<li><b>key:</b> <span class="copy">{{ m.key }}</span></li> <ul>
<li><b>type:</b> <span class="copy">{{ m.mimetype }}</span></li> <li><b>key:</b> <span class="copy">{{ m.key }}</span></li>
<li><b>type:</b> <span class="copy">{{ m.mimetype }}</span></li>
{% for prop in m.properties %} {% for prop in m.properties %}
{% if m.properties[prop] | is_list %} {% if m.properties[prop] | is_list %}
<p></p>
<div>
<b class="collapsible" title="expand">{{ prop }}:</b>
<p></p> <p></p>
<div class="collapsible-content"> <div>
{% for subprop in m.properties[prop] %} <b class="collapsible" title="expand">{{ prop }}:</b>
{% if subprop | is_media %} <p></p>
{{ macros.display_media(subprop, true, url) }} <div class="collapsible-content">
{% for subprop in m.properties[prop] %}
{% if subprop | is_media %}
{{ macros.display_media(subprop, true, url) }}
<ul> <ul>
{% for subprop_prop in subprop.properties %} {% for subprop_prop in subprop.properties %}
<li><b>{{ subprop_prop }}:</b> {{ macros.copy_urlize(subprop.properties[subprop_prop]) }}</li> <li><b>{{ subprop_prop }}:</b>
{{ macros.copy_urlize(subprop.properties[subprop_prop]) }}</li>
{% endfor %}
</ul>
{% else %}
{{ subprop }}
{% endif %}
{% endfor %} {% endfor %}
</ul> </div>
{% else %}
{{ subprop }}
{% endif %}
{% endfor %}
</div> </div>
</div> <p></p>
<p></p> {% elif m.properties[prop] | string | length > 1 %}
{% elif m.properties[prop] | string | length > 1 %} <li><b>{{ prop }}:</b> {{ macros.copy_urlize(m.properties[prop]) }}</li>
<li><b>{{ prop }}:</b> {{ macros.copy_urlize(m.properties[prop]) }}</li> {% endif %}
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
</td> </td>
<td> <td>
{{ macros.display_media(m, true, url) }} {{ macros.display_media(m, true, url) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody>
</table> </table>
<h2 class="center">metadata</h2> <h2 class="center">metadata</h2>
<table class="metadata"> <table class="metadata">
@@ -220,6 +232,49 @@
} }
}); });
} }
// logic for enabled/disabled greyscale
// Get references to the checkboxes and images/videos
const safeImageViewCheckbox = document.getElementById('safe-media-view');
const imagesVideos = document.querySelectorAll('img, video');
// Function to toggle grayscale effect
function toggleGrayscale() {
imagesVideos.forEach(element => {
if (safeImageViewCheckbox.checked) {
// Enable grayscale effect
element.style.filter = 'grayscale(1)';
element.style.webkitFilter = 'grayscale(1)';
} else {
// Disable grayscale effect
element.style.filter = 'none';
element.style.webkitFilter = 'none';
}
});
}
// Add event listener to the checkbox to trigger the toggleGrayscale function
safeImageViewCheckbox.addEventListener('change', toggleGrayscale);
// Handle the hover effect using JavaScript
imagesVideos.forEach(element => {
element.addEventListener('mouseenter', () => {
// Disable grayscale effect on hover
element.style.filter = 'none';
element.style.webkitFilter = 'none';
});
element.addEventListener('mouseleave', () => {
// Re-enable grayscale effect if checkbox is checked
if (safeImageViewCheckbox.checked) {
element.style.filter = 'grayscale(1)';
element.style.webkitFilter = 'grayscale(1)';
}
});
});
// Call the function on page load to apply the initial state
toggleGrayscale();
</script> </script>
</html> </html>

View File

@@ -16,10 +16,7 @@ No URL available for {{ m.key }}.
<a href="https://lens.google.com/uploadbyurl?url={{ url | quote }}">Google Lens</a>,&nbsp; <a href="https://lens.google.com/uploadbyurl?url={{ url | quote }}">Google Lens</a>,&nbsp;
<a href="https://yandex.ru/images/touch/search?rpt=imageview&url={{ url | quote }}">Yandex</a>,&nbsp; <a href="https://yandex.ru/images/touch/search?rpt=imageview&url={{ url | quote }}">Yandex</a>,&nbsp;
<a href="https://www.bing.com/images/search?view=detailv2&iss=sbi&form=SBIVSP&sbisrc=UrlPaste&q=imgurl:{{ url | quote }}">Bing</a>,&nbsp; <a href="https://www.bing.com/images/search?view=detailv2&iss=sbi&form=SBIVSP&sbisrc=UrlPaste&q=imgurl:{{ url | quote }}">Bing</a>,&nbsp;
<a href="https://www.tineye.com/search/?url={{ url | quote }}">Tineye</a>,&nbsp; <a href="https://www.tineye.com/search/?url={{ url | quote }}">Tineye</a>
<a href="https://iqdb.org/?url={{ url | quote }}">IQDB</a>,&nbsp;
<a href="https://saucenao.com/search.php?db=999&url={{ url | quote }}">SauceNAO</a>,&nbsp;
<a href="https://imgops.com/{{ url | quote }}">IMGOPS</a>
</div> </div>
<p></p> <p></p>
</div> </div>

View File

@@ -1,14 +1,15 @@
import re import re
from urllib.parse import urlparse, urlunparse
class UrlUtil: class UrlUtil:
telegram_private = re.compile(r"https:\/\/t\.me(\/c)\/(.+)\/(\d+)") telegram_private = re.compile(r"https:\/\/t\.me(\/c)\/(.+)\/(\d+)")
is_istagram = re.compile(r"https:\/\/www\.instagram\.com") is_istagram = re.compile(r"https:\/\/www\.instagram\.com")
@staticmethod @staticmethod
def clean(url): return url def clean(url: str) -> str: return url
@staticmethod @staticmethod
def is_auth_wall(url): def is_auth_wall(url: str) -> bool:
""" """
checks if URL is behind an authentication wall meaning steps like wayback, wacz, ... may not work checks if URL is behind an authentication wall meaning steps like wayback, wacz, ... may not work
""" """
@@ -17,3 +18,59 @@ class UrlUtil:
return False return False
@staticmethod
def remove_get_parameters(url: str) -> str:
# http://example.com/file.mp4?t=1 -> http://example.com/file.mp4
# useful for mimetypes to work
parsed_url = urlparse(url)
new_url = urlunparse(parsed_url._replace(query=''))
return new_url
@staticmethod
def is_relevant_url(url: str) -> bool:
"""
Detect if a detected media URL is recurring and therefore irrelevant to a specific archive. Useful, for example, for the enumeration of the media files in WARC files which include profile pictures, favicons, etc.
"""
clean_url = UrlUtil.remove_get_parameters(url)
# favicons
if "favicon" in url: return False
# ifnore icons
if clean_url.endswith(".ico"): return False
# ignore SVGs
if UrlUtil.remove_get_parameters(url).endswith(".svg"): return False
# twitter profile pictures
if "twimg.com/profile_images" in url: return False
if "twimg.com" in url and "/default_profile_images" in url: return False
# instagram profile pictures
if "https://scontent.cdninstagram.com/" in url and "150x150" in url: return False
# instagram recurring images
if "https://static.cdninstagram.com/rsrc.php/" in url: return False
# telegram
if "https://telegram.org/img/emoji/" in url: return False
# youtube
if "https://www.youtube.com/s/gaming/emoji/" in url: return False
if "https://yt3.ggpht.com" in url and "default-user=" in url: return False
if "https://www.youtube.com/s/search/audio/" in url: return False
# ok
if " https://ok.ru/res/i/" in url: return False
# vk
if "https://vk.com/emoji/" in url: return False
if "vk.com/images/" in url: return False
if "vk.com/images/reaction/" in url: return False
return True
@staticmethod
def twitter_best_quality_url(url: str) -> str:
"""
some twitter image URLs point to a less-than best quality
this returns the URL pointing to the highest (original) quality
"""
return re.sub(r"name=(\w+)", "name=orig", url, 1)

View File

@@ -1,9 +1,9 @@
_MAJOR = "0" _MAJOR = "0"
_MINOR = "5" _MINOR = "6"
# On main and in a nightly release the patch should be one ahead of the last # On main and in a nightly release the patch should be one ahead of the last
# released build. # released build.
_PATCH = "24" _PATCH = "5"
# This is mainly for nightly builds which have the suffix ".dev$DATE". See # This is mainly for nightly builds which have the suffix ".dev$DATE". See
# https://semver.org/#is-v123-a-semantic-version for the semantics. # https://semver.org/#is-v123-a-semantic-version for the semantics.
_SUFFIX = "" _SUFFIX = ""