This commit is contained in:
Mateusz Gruszczyński committed 2026-10-23 15:26:08 +02:00
1 parent bb941cff43
commit 6a821c7512
7 files changed
+1768 -326

No files matched your search

+163 -1
View File
@@ -1,3 +1,165 @@
# compare_filesize_drupal6
# Drupal 6 – duże obrazy, lokalizacja tematów i bezpieczna optymalizacja
Rozbudowana wersja `compare_filesize_drupal6`. Działa **poza Drupalem**, w Pythonie 3.9+; potrzebuje dostępu do MySQL/MariaDB i katalogu plików.
## Co robi
- Wyszukuje największe pliki i największe rozdzielczości (JPG, PNG, GIF – GIF wyłącznie do analizy).
- Wskazuje, **w jakim temacie, poście lub komentarzu** pojawia się obraz. Raport pokazuje `nid`, `cid`, tytuł, typ zawartości, URL do wpisu, URL do edycji i źródło powiązania.
- Szuka połączeń przez `upload.fid` i pola `*_fid` w tabelach CCK/ImageField/FileField; obsługuje aktualne rewizje (`vid`) i opcjonalne tabele załączników komentarzy (`cid` + `fid`).
- Szuka obrazów w `node_revisions.body` i `node_revisions.teaser`, `comments.comment`, opcjonalnie też w tekstowych polach CCK. Rozróżnia **pewne** dopasowania identyfikatora `fid` lub pełnej ścieżki od **możliwych** trafień po samej nazwie pliku.
- Generuje link do oryginalnego pliku (jeżeli publiczny) i pomocniczy link do Drupal Search (jeżeli moduł Search jest aktywny).
- Generuje CSV (polskie kolumny, Excel, UTF-8 BOM) i JSON.
- Symuluje dokładną optymalizację w **dry-run**, bez zapisu źródłowego pliku i bazy.
- Przy `--apply` zachowuje oryginał w oddzielnym katalogu, przygotowuje nowy obraz do atomowej podmiany, zapisuje `files.filesize`, a przy błędzie próbuje odtworzyć oryginał i wartości bazy.
- Wykrywa duplikaty SHA-256 i pliki na dysku, których nie ma w analizowanym zestawieniu z bazy.
## Instalacja
```bash
unzip compare_filesize_drupal6-extended.zip
cd compare_filesize_drupal6
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp config.example.py config.py
```
W `config.py` ustaw:
```python
DB_CONFIG = {
'host': 'localhost',
'port': 3306,
'database': 'drupal6_db',
'user': 'drupal_user',
'password': 'twoje_haslo',
'charset': 'utf8',
}
FILES_BASE_PATH = '/var/www/drupal/sites/default/files'
PUBLIC_FILES_PATH = 'sites/default/files'
DRUPAL_ROOT = '/var/www/drupal'
DRUPAL_BASE_URL = 'https://forum.example.org'
DB_TABLE_PREFIX = ''
```
**Ważne:** `FILES_BASE_PATH` to lokalizacja rzeczywistych plików, a `PUBLIC_FILES_PATH` to fragment zapisany zwykle w `files.filepath`. Jeśli pliki są np. w `/var/www/drupal/files`, zmień obie wartości. Można też podać je w CLI: `--public-files-path`, `--drupal-root`. Gdy Drupal pracuje bez clean URLs, dodaj `--no-clean-urls`.
## 1. Znajdź tematy i komentarze z największymi obrazami
```bash
python main.py --find-usage --top 50 --report-csv najwieksze-z-linkami.csv
```
Przykładowe wiersze (fikcyjne):
```text
1. 18.42 MB 4200x5900 fid=1234 scans/mapa.jpg
-> Archiwalne skany (forum, nid=812, pewne)
LINK: https://forum.example.org/node/812
EDYCJA: https://forum.example.org/node/812/edit
SKAD: upload.fid, tresc wpisu (sciezka)
-> Zdjęcie w komentarzu (forum, nid=920, cid=42, pewne)
LINK: https://forum.example.org/node/920#comment-42
```
CSV zawiera jedną linię dla każdego znalezionego miejsca. Gdy powiązania brak, w CSV zostaje plik, jego `fid`, rozmiar i propozycja wyszukiwania nazwą w Drupal Search.
### Tylko skany lub wybrane pliki
```bash
python main.py --find-usage --top 100 --path-contains skan --report-csv skany.csv
python main.py --find-usage --fid 1234 --report-csv jeden-obraz.csv
python main.py --find-usage --min-size-mb 5 --usage-scope all --report-csv wieksze-niz-5mb.csv
```
`--usage-scope top` szuka tylko w `--top N` największych obrazach; `optimizable` – w obrazach przekraczających limity; `all` – w całym filtrowanym zestawieniu. Przy bardzo dużych bazach `all` może oznaczać długi skan treści. `--no-text-scan` wyłącza szukanie w treściach i zostawia szybsze powiązania `fid`. `--scan-cck-text` rozszerza skan na tekstowe pola CCK.
## 2. Dokładny dry-run optymalizacji
```bash
python main.py \
--find-usage --usage-scope optimizable \
--optimize --dry-run --optimize-top 20 \
--max-width 2400 --max-height 3200 --max-file-mb 2 \
--report-csv plan.csv --report-json plan.json
```
Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu.
## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę
**Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie:
```bash
python main.py \
--optimize --apply \
--optimize-top 10 --max-changes 10 \
--backup-dir /srv/backups/drupal6-images \
--max-width 2400 --max-height 3200 --max-file-mb 2 \
--report-csv wykonane.csv --report-json wykonane.json
```
Każda udana zmiana:
1. Zapisuje oryginał w osobnym katalogu (automatycznie tworzony podfolder uruchomienia).
2. Zastępuje obraz tylko jeżeli nowy plik jest mniejszy o co najmniej `--min-gain-percent` (domyślnie 5%).
3. Aktualizuje `files.filesize` dla wszystkich `fid` wskazujących na tę samą ścieżkę.
4. W przypadku błędu SQL próbuje odtworzyć oryginalny plik i rozmiar w DB (także przy MyISAM – kompensacja aktualizacji).
`--apply` jest wymagane do każdego zapisu. Samo `--optimize` lub `--update-db` nie wykonuje zmian. **Realna optymalizacja zawsze aktualizuje rozmiar w DB**, także bez `--update-db`.
Jeżeli chcesz wyłącznie zsynchronizować rozmiary plików w istniejącym `files`:
```bash
python main.py --update-db --dry-run
python main.py --update-db --apply
```
`--update-db` w połączeniu z `--optimize --apply` dodatkowo koryguje rozmiary tych plików, które *nie zostały* zoptymalizowane.
## Zasady jakości i bezpieczeństwa
- **JPEG:** zachowuje rozszerzenie i ścieżkę, skaluje proporcjonalnie filtrem LANCZOS, zapisuje z wysoką jakością 92/89/86/85 (`--jpeg-quality`, `--min-jpeg-quality`). Nie schodzi poniżej skonfigurowanej jakości, nawet gdy cel 2 MB nie zostanie osiągnięty. JPEG jest formatem stratnym – każda ponowna kompresja może zmienić piksele.
- **PNG:** zachowuje format oraz kanał przezroczystości, używa kompresji **bezstratnej**. Nie konwertuje PNG do JPEG i nie zmienia rozszerzeń, bo mogłoby to popsuć stare linki w Drupalu. Duży PNG ze skanem może pozostać duży, jeśli kompresja bezstratna nic nie da. Konwersja do JPEG wymagałaby osobnej, kontrolowanej migracji i aktualizacji odnośników.
- **Skan tekstowy:** domyślny limit **2400 × 3200 px** zamiast starego 1920 × 1080. Przed decyzją sprawdź czytelność drobnego druku w próbce i w razie potrzeby zwiększ limity lub pomiń plik.
- **GIF/animacje, nieobsługiwane formaty, niezgodności formatu i rozszerzenia:** pomijane przy zapisie. Symlinki i hardlinki też są pomijane.
- Kopia oryginału wymagana jest przy `--optimize --apply`, a lokalizacja kopii **musi być poza** `FILES_BASE_PATH` i poza katalogiem WWW Drupala (jeśli ustawiono `DRUPAL_ROOT`).
- Weryfikacja czasu modyfikacji i inode chroni przed zmianą pliku w trakcie skanowania; zalecane jest wykonywanie pracy w oknie serwisowym.
- Zachowuje ICC, DPI, EXIF (z prawidłową obsługą orientacji) i tekstowe metadane PNG w obsługiwanych formatach, ale nie gwarantuje zachowania każdego niestandardowego segmentu metadanych.
- Aktualizowany jest tylko `files.filesize` – nie są zmieniane ścieżki, `fid`, treści postów ani załączniki. Zmiana dużego obrazka może wymagać ręcznego odświeżenia wygenerowanych miniatur przez ImageCache.
- Kopia wykonana przez samo narzędzie obejmuje **obraz**, nie całą bazę. MyISAM nie zapewnia transakcji; procedura przywracania jest best-effort. Pełny backup DB i plików przed zmianami jest obowiązkowy.
## Pozostałe opcje
```bash
python main.py --find-duplicates
python main.py --show-deleted --exclude-folders imagecache styles
python main.py --find-usage --no-clean-urls --site-url https://example.org/drupal
python main.py --help
```
Uwaga do `--show-deleted`: pokazuje pliki na dysku **bez pasującego wpisu `files` w analizowanym zestawieniu**, nie zaś potwierdzenie, że plik naprawdę nie występuje w treści. Przy filtrach (`--path-contains`, `--min-size-mb`, `--fid`) plik odfiltrowany może zostać pokazany jako „spoza zestawienia”. Nic nie jest usuwane.
### Ograniczenia wyszukiwania
- Powiązania po `fid` są najbardziej jednoznaczne; wyszukiwanie po samej nazwie pliku bywa niejednoznaczne – w raporcie `pewnosc=mozliwe`.
- Tekst przeszukiwany jest w **aktualnych rewizjach** węzłów i komentarzach. Nie szuka w archiwalnych rewizjach ani w niestandardowych zewnętrznych modułach przechowywania treści bez włączonego skanu CCK.
- Link do nieopublikowanego wpisu lub komentarza może wymagać konta z prawami administracyjnymi. Raport to zaznacza.
- **Drupal 6 stronicuje komentarze**: `node/NID#comment-CID` może nie przewinąć do komentarza z dalszej strony. Jeżeli masz zainstalowany moduł [Permalink dla Drupal 6](https://www.drupal.org/project/permalink), dodaj `--comment-permalinks`, aby generować `comment/CID#comment-CID`, które kieruje na właściwą stronę komentarzy. Bez tego modułu nie używaj tej flagi. Niezależnie od typu linku raport pokazuje `nid` i `cid`.
- Nie ma gwarancji odnalezienia obrazów w niestandardowych relacjach tworzonych przez własne moduły, w CSS i w HTML poza bazą.
- W przypadku prywatnego systemu plików adres bezpośredni do pliku może nie działać; odnośnik do tematu nadal powinien wskazać miejsce.
## Testy
```bash
python -m unittest discover -s tests -v
```
Testy z lokalną, symulowaną bazą weryfikują powiązania Upload/CCK, obecne rewizje, komentarze, brak zmian w dry-run, aktualizację `files.filesize`, backup, przywracanie po błędzie SQL, format PNG i ścieżki katalogów. **Nie zastępują próby na kopii konkretnej instalacji Drupal 6**.
### Źródła struktury bazy Drupal 6
- [Oficjalny migrator Drupal 6 Upload](https://api.drupal.org/api/drupal/core%21modules%21file%21src%21Plugin%21migrate%21source%21d6%21Upload.php/8.9.x) – `upload.fid`, `upload.nid`, `upload.vid`.
- [Oficjalny migrator komentarzy D6](https://api.drupal.org/api/drupal/core%21modules%21comment%21src%21Plugin%21migrate%21source%21d6%21Comment.php/class/Comment/main) – tabela `comments`, `cid`, `nid`, status komentarza.
- [CCK 6](https://api.druphelp.com/api/cck/content.module/6) – `content_type_*`, `content_field_*`.
+12 -3
View File
@@ -1,10 +1,19 @@
# config.py
# Skopiuj do config.py i dostosuj. Nigdy nie dodawaj config.py do Git.
DB_CONFIG = {
'host': 'localhost',
'port': 3306,
'database': 'drupal6_db',
'user': 'drupal_user',
'password': 'secure_password',
# Stary MySQL/Drupal 6 czesto wymaga utf8 zamiast utf8mb4.
'charset': 'utf8',
}
FILES_BASE_PATH = '/ścieżka/do/drupala/sites/default/files/'
# Katalog, w ktorym fizycznie znajduja sie obrazy (moze byc inny w Drupal 6).
FILES_BASE_PATH = '/var/www/drupal/sites/default/files'
# Publiczny fragment sciezki do files/ zapisywany w tabeli files.filepath:
PUBLIC_FILES_PATH = 'sites/default/files'
# Katalog projektu Drupal: opcjonalny, pomaga dla sciezek pelnych.
DRUPAL_ROOT = '/var/www/drupal'
DRUPAL_BASE_URL = 'https://forum.example.org'
DB_TABLE_PREFIX = '' # np. 'drupal_'
+325
View File
@@ -0,0 +1,325 @@
"""Wyszukiwanie powiazan plikow z wpisami, tematami forum i komentarzami Drupal 6.
Powiazania 'bezposrednie' to identyfikatory fid (Upload, CCK/FileField/ImageField).
Powiazania z tekstu to poszlaki: pelna sciezka (mocniejsze) lub sama nazwa.
"""
from __future__ import annotations
import html
import os
import re
from collections import defaultdict
from urllib.parse import quote
IDENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
def sql_ident(value):
if not IDENT.fullmatch(value):
raise ValueError(f'Niedozwolona nazwa tabeli/kolumny: {value!r}')
return f'`{value}`'
def chunks(seq, size=250):
seq = list(seq)
for start in range(0, len(seq), size):
yield seq[start:start + size]
def node_url(base_url, nid, cid=None, *, clean_urls=True, comment_permalinks=False):
if cid is not None and comment_permalinks:
base = (base_url.rstrip('/') + '/') if base_url else ''
if clean_urls:
return f'{base}comment/{int(cid)}#comment-{int(cid)}'
return f'{base}?q=comment/{int(cid)}#comment-{int(cid)}'
if not base_url:
result = f'node/{int(nid)}'
elif clean_urls:
result = f'{base_url.rstrip("/")}/node/{int(nid)}'
else:
result = f'{base_url.rstrip("/")}/?q=node/{int(nid)}'
if cid is not None:
result += f'#comment-{int(cid)}'
return result
def search_url(base_url, filename, *, clean_urls=True):
if not base_url:
return ''
term = quote(filename, safe='')
if clean_urls:
return f'{base_url.rstrip("/")}/search/node/{term}'
return f'{base_url.rstrip("/")}/?q=search/node/{term}'
class ReferenceMatcher:
"""Jedno przejscie przez tresc na liste interesujacych plikow.
Dopasowanie tylko nazwy pliku ma pewnosc 'mozliwe', nigdy 'pewne'.
"""
def __init__(self, images, basename_match=True):
variants = defaultdict(list)
for image in images:
fid = int(image['fid'])
file_path = (image.get('db_filepath') or '').replace('\\', '/').lstrip('/')
relative = (image.get('rel_path') or '').replace('\\', '/').lstrip('/')
for path in (file_path, relative):
if path and '/' in path:
for variant in (path, quote(path, safe='/')):
variants[variant.casefold()].append((fid, 'sciezka'))
name = os.path.basename(relative or file_path)
if basename_match and name and len(name) >= 5:
for variant in (name, quote(name, safe='')):
variants[variant.casefold()].append((fid, 'nazwa'))
self.variants = dict(variants)
# Najpierw dluzsze wzorce, np. 'uploads/scan.jpg' przed 'scan.jpg'.
self.pattern = None
if variants:
ordered = sorted(variants, key=lambda x: (-len(x), x))
self.pattern = re.compile('|'.join(re.escape(x) for x in ordered), re.IGNORECASE)
def match(self, text):
if not self.pattern or not text:
return {}
text = html.unescape(str(text))
found = {}
for occurrence in self.pattern.finditer(text):
word = occurrence.group(0).casefold()
for fid, kind in self.variants.get(word, []):
if kind == 'nazwa':
before = text[occurrence.start() - 1] if occurrence.start() else ''
after = text[occurrence.end()] if occurrence.end() < len(text) else ''
if before and (before.isalnum() or before in '._-'):
continue
if after and (after.isalnum() or after in '._-'):
continue
# Preferuj trafienie przez pelna sciezke od samej nazwy.
if fid not in found or (found[fid] == 'nazwa' and kind == 'sciezka'):
found[fid] = kind
return found
class DrupalReferenceLookup:
def __init__(self, connection, *, table_prefix='', base_url='', clean_urls=True, comment_permalinks=False):
self.connection = connection
self.prefix = table_prefix
self.base_url = base_url
self.clean_urls = clean_urls
self.comment_permalinks = comment_permalinks
if table_prefix and not IDENT.fullmatch(table_prefix):
raise ValueError('Nieprawidlowy prefix tabel')
self.tables = set()
self.columns = {}
def table(self, name):
return f'{self.prefix}{name}'
def inspect_schema(self):
with self.connection.cursor() as cursor:
cursor.execute('SHOW TABLES')
self.tables = {str(next(iter(row.values()))) for row in cursor.fetchall()}
if self.table('node') not in self.tables:
raise RuntimeError('Nie znaleziono tabeli node; sprawdz --table-prefix')
for table in sorted(self.tables):
if self.prefix and not table.startswith(self.prefix):
continue
if not IDENT.fullmatch(table):
continue
cursor.execute(f'SHOW COLUMNS FROM {sql_ident(table)}')
self.columns[table] = {row['Field']: row['Type'].lower() for row in cursor.fetchall()}
def _add(self, refs, fid, row, source, confidence='pewne', cid=None):
fid = int(fid)
nid = int(row['nid'])
cid = int(cid) if cid is not None else None
key = (nid, cid)
if key not in refs[fid]:
refs[fid][key] = {
'nid': nid,
'cid': cid,
'title': row.get('title') or '(bez tytulu)',
'node_type': row.get('node_type') or '',
'published': int(row.get('node_status', 1)) == 1,
'comment_published': (int(row['comment_status']) == 0 if row.get('comment_status') is not None else None),
'url': node_url(self.base_url, nid, cid, clean_urls=self.clean_urls,
comment_permalinks=self.comment_permalinks),
'edit_url': node_url(self.base_url, nid, clean_urls=self.clean_urls) + '/edit' if self.clean_urls else (
f'{self.base_url.rstrip("/")}/?q=node/{nid}/edit' if self.base_url else f'node/{nid}/edit'),
'sources': [],
'confidence': confidence,
}
ref = refs[fid][key]
if source not in ref['sources']:
ref['sources'].append(source)
if confidence == 'pewne':
ref['confidence'] = 'pewne'
def _direct_relations(self, refs, fids):
node = sql_ident(self.table('node'))
# Upload + kazda tabela z nid i fid lub XXX_fid (CCK, filefield...).
for table, cols in sorted(self.columns.items()):
if not table.startswith(self.prefix):
continue
if 'nid' not in cols or table == self.table('node'):
continue
fid_cols = [col for col in cols if col == 'fid' or col.endswith('_fid')]
if not fid_cols:
continue
if not (table == self.table('upload') or table.startswith(self.table('content_')) or 'fid' in cols or any(col.startswith('field_') for col in fid_cols)):
continue
try:
with self.connection.cursor() as cursor:
for fid_column in fid_cols:
revision_join = ' AND n.vid = t.vid' if 'vid' in cols else ''
for batch in chunks(fids):
placeholders = ','.join(['%s'] * len(batch))
query = (
f'SELECT n.nid AS nid, n.title AS title, n.type AS node_type, '
f'n.status AS node_status, t.{sql_ident(fid_column)} AS fid, '
f'{"t.cid" if "cid" in cols else "NULL"} AS cid '
f'FROM {sql_ident(table)} t JOIN {node} n ON n.nid = t.nid'
f'{revision_join} WHERE t.{sql_ident(fid_column)} IN ({placeholders})'
)
cursor.execute(query, tuple(batch))
for row in cursor.fetchall():
if row.get('fid') is not None:
source = f'{table}.{fid_column}'
self._add(refs, row['fid'], row, source, cid=row.get('cid'))
except Exception as exc:
print(f'[WARN] Nie udalo sie odczytac powiazan {table}: {exc}')
def _comment_direct_relations(self, refs, fids):
# W niektorych instalacjach zalaczniki do komentarzy maja tylko cid+fid.
comments = self.table('comments')
if comments not in self.columns:
return
for table, cols in sorted(self.columns.items()):
if table in (comments, self.table('node')) or 'cid' not in cols or 'nid' in cols:
continue
fid_cols = [col for col in cols if col == 'fid' or col.endswith('_fid')]
if not fid_cols:
continue
# Nie interpretuj losowych tabel jako zalacznikow do komentarzy.
if 'comment' not in table:
continue
try:
with self.connection.cursor() as cursor:
for fid_column in fid_cols:
for batch in chunks(fids):
placeholders = ','.join(['%s'] * len(batch))
query = (
f'SELECT n.nid AS nid, n.title AS title, n.type AS node_type, '
f'n.status AS node_status, c.status AS comment_status, '
f'c.cid AS cid, t.{sql_ident(fid_column)} AS fid '
f'FROM {sql_ident(table)} t '
f'JOIN {sql_ident(comments)} c ON c.cid = t.cid '
f'JOIN {sql_ident(self.table("node"))} n ON n.nid = c.nid '
f'WHERE t.{sql_ident(fid_column)} IN ({placeholders})'
)
cursor.execute(query, tuple(batch))
for row in cursor.fetchall():
if row.get('fid') is not None:
self._add(refs, row['fid'], row, f'{table}.{fid_column}', cid=row['cid'])
except Exception as exc:
print(f'[WARN] Nie udalo sie odczytac zalacznikow do komentarzy {table}: {exc}')
def _stream_query(self, query):
# Kursor strumieniowy nie pobiera do RAM calego body z wielkiej bazy.
import pymysql
cursor = self.connection.cursor(pymysql.cursors.SSDictCursor)
try:
cursor.execute(query)
while True:
batch = cursor.fetchmany(200)
if not batch:
break
for row in batch:
yield row
finally:
cursor.close()
def _scan_body(self, refs, matcher):
revision = self.table('node_revisions')
if revision not in self.columns or 'body' not in self.columns[revision]:
print('[WARN] Brak node_revisions.body - pomijam tekst wpisow')
return
teaser_field = ('r.teaser AS teaser' if 'teaser' in self.columns[revision]
else 'NULL AS teaser')
query = (
f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, '
f'r.body AS body, {teaser_field} FROM {sql_ident(self.table("node"))} n '
f'JOIN {sql_ident(revision)} r ON r.vid = n.vid AND r.nid = n.nid '
f'WHERE (r.body IS NOT NULL AND r.body <> \'\')'
+ (" OR (r.teaser IS NOT NULL AND r.teaser <> '')" if 'teaser' in self.columns[revision] else '')
)
for row in self._stream_query(query):
for field, source in (('body', 'tresc wpisu'), ('teaser', 'zajawka wpisu')):
for fid, kind in matcher.match(row.get(field)).items():
confidence = 'pewne' if kind == 'sciezka' else 'mozliwe'
self._add(refs, fid, row, f'{source} ({kind})', confidence)
def _scan_comments(self, refs, matcher):
table = self.table('comments')
if table not in self.columns or 'comment' not in self.columns[table]:
return
subject_field = ('c.subject AS comment_subject' if 'subject' in self.columns[table]
else 'NULL AS comment_subject')
query = (
f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, '
f'c.cid, c.status AS comment_status, c.comment, {subject_field} '
f'FROM {sql_ident(table)} c '
f'JOIN {sql_ident(self.table("node"))} n ON n.nid = c.nid '
)
for row in self._stream_query(query):
for field, source in (('comment', 'komentarz'), ('comment_subject', 'tytul komentarza')):
for fid, kind in matcher.match(row.get(field)).items():
self._add(refs, fid, row, f'{source} ({kind})',
'pewne' if kind == 'sciezka' else 'mozliwe', row['cid'])
def _scan_cck_text(self, refs, matcher):
# Opcjonalne: tekst moze byc w polach niestandardowych CCK.
for table, cols in sorted(self.columns.items()):
if not table.startswith(self.table('content_')) or 'nid' not in cols:
continue
columns = [col for col, typ in cols.items() if (
'text' in typ or 'varchar' in typ
) and col not in ('nid', 'vid')]
if not columns:
continue
revision_join = ' AND n.vid = t.vid' if 'vid' in cols else ''
for col in columns:
query = (
f'SELECT n.nid, n.title, n.type AS node_type, n.status AS node_status, '
f't.{sql_ident(col)} AS value FROM {sql_ident(table)} t '
f'JOIN {sql_ident(self.table("node"))} n ON n.nid = t.nid'
f'{revision_join} WHERE t.{sql_ident(col)} IS NOT NULL'
)
try:
for row in self._stream_query(query):
for fid, kind in matcher.match(row.get('value')).items():
self._add(refs, fid, row, f'{table}.{col} ({kind})', 'pewne' if kind == 'sciezka' else 'mozliwe')
except Exception as exc:
print(f'[WARN] Nie udalo sie przeskanowac pola {table}.{col}: {exc}')
def lookup(self, images, *, scan_text=True, scan_cck_text=False, basename_match=True):
if not images:
return {}
self.inspect_schema()
fids = sorted({int(img['fid']) for img in images})
refs = defaultdict(dict)
self._direct_relations(refs, fids)
self._comment_direct_relations(refs, fids)
if scan_text:
matcher = ReferenceMatcher(images, basename_match=basename_match)
if matcher.pattern:
print('[INFO] Szukam odnosnikow w aktualnych tresciach wpisow i komentarzy...')
self._scan_body(refs, matcher)
self._scan_comments(refs, matcher)
if scan_cck_text:
self._scan_cck_text(refs, matcher)
return {
fid: sorted(per_location.values(), key=lambda ref: (ref['nid'], ref['cid'] or 0))
for fid, per_location in refs.items()
}
+153
View File
@@ -0,0 +1,153 @@
"""Zachowawcza, bezstratna dla PNG / wysokiej jakosci dla JPEG, optymalizacja.
Ten modul NIGDY nie nadpisuje pliku zrodlowego: tworzy jedynie propozycje
w pliku tymczasowym. main.py odpowiada za backup, podmiane i transakcje SQL.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from PIL import Image, ImageOps, PngImagePlugin
class ImageOptimizationError(Exception):
pass
def _image_save_kwargs(source, image_format):
kwargs = {}
if source.info.get('icc_profile'):
kwargs['icc_profile'] = source.info['icc_profile']
if source.info.get('dpi'):
kwargs['dpi'] = source.info['dpi']
if image_format == 'PNG':
pnginfo = PngImagePlugin.PngInfo()
text_chunks = getattr(source, 'text', {}) or {}
for key, value in text_chunks.items():
if isinstance(key, str) and isinstance(value, str):
pnginfo.add_text(key, value)
if text_chunks:
kwargs['pnginfo'] = pnginfo
return kwargs
def create_optimized_candidate(
path,
*,
max_width=2400,
max_height=3200,
max_bytes=2 * 1024 * 1024,
jpeg_quality=92,
min_jpeg_quality=85,
min_gain_percent=5.0,
temp_dir=None,
):
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}.
Status: ready, skip, error. Wlasnosc pliku temp_path przekazywana wywolujacemu:
wywolujacy MUSI usunac plik, takze przy bledzie.
"""
orig_size = os.path.getsize(path)
result = {
'status': 'skip', 'reason': '', 'before': orig_size,
'after': orig_size, 'width': 0, 'height': 0, 'quality': None,
'temp_path': None,
}
temp_path = None
try:
with Image.open(path) as source:
fmt = source.format
ext = Path(path).suffix.lower()
if fmt not in ('JPEG', 'PNG'):
result['reason'] = f'format {fmt or "nieznany"} nie jest bezpiecznie obslugiwany'
return result
if fmt == 'JPEG' and ext not in ('.jpg', '.jpeg'):
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
return result
if fmt == 'PNG' and ext != '.png':
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
return result
if getattr(source, 'is_animated', False) and getattr(source, 'n_frames', 1) > 1:
result['reason'] = 'animacja (nie modyfikujemy)'
return result
original_width, original_height = source.size
result['width'], result['height'] = original_width, original_height
if original_width <= max_width and original_height <= max_height and orig_size <= max_bytes:
result['reason'] = 'obraz juz spelnia limity'
return result
# EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
working = ImageOps.exif_transpose(source)
if (fmt == 'PNG' and working.mode == 'P' and
(working.width > max_width or working.height > max_height)):
# Skalowanie obrazu paletowego przed konwersja daje nearest-neighbor.
# Konwertuj najpierw, aby LANCZOS pracowal na pelnych kolorach.
working = working.convert('RGBA' if 'transparency' in working.info else 'RGB')
working.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
result['width'], result['height'] = working.size
kwargs = _image_save_kwargs(source, fmt)
fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='.d6-image-', dir=temp_dir)
os.close(fd)
if fmt == 'JPEG':
if working.mode not in ('RGB', 'L', 'CMYK'):
if working.mode == 'RGBA' or 'transparency' in working.info:
result['reason'] = 'JPEG z kanalem alfa (nie modyfikujemy)'
return result
working = working.convert('RGB')
# Pobieraj EXIF juz po prawidlowej korekcji orientacji.
try:
exif = working.getexif()
if exif:
kwargs['exif'] = exif.tobytes()
except (ValueError, TypeError, OSError):
# Uszkodzone EXIF nie powinno blokowac zapisu obrazu.
pass
qualities = list(range(jpeg_quality, min_jpeg_quality - 1, -3))
if min_jpeg_quality not in qualities:
qualities.append(min_jpeg_quality)
best_quality = None
best_size = orig_size
for quality in qualities:
working.save(
temp_path, format='JPEG', optimize=True,
progressive=True, subsampling=0, quality=quality, **kwargs,
)
size = os.path.getsize(temp_path)
if size < best_size:
best_quality, best_size = quality, size
if size <= max_bytes and 100.0 * (orig_size - size) / max(1, orig_size) >= min_gain_percent:
break
# Jezeli nie udalo sie zejsc pod limit, zastosuj najmniejsza
# akceptowalna jakosc. Plik wynikowy bedzie ostatnim kandydatem.
if best_quality != quality:
working.save(
temp_path, format='JPEG', optimize=True,
progressive=True, subsampling=0, quality=best_quality or min_jpeg_quality,
**kwargs,
)
result['quality'] = best_quality
else:
working.save(temp_path, format='PNG', optimize=True, compress_level=9, **kwargs)
result['after'] = os.path.getsize(temp_path)
gain_percent = 100.0 * (orig_size - result['after']) / max(1, orig_size)
if result['after'] >= orig_size or gain_percent < min_gain_percent:
result['reason'] = f'za mala oszczednosc ({gain_percent:.1f}% < {min_gain_percent:.1f}%)'
return result
result['status'] = 'ready'
result['reason'] = 'osiagnieto limit' if result['after'] <= max_bytes else 'zmniejszono; nadal powyzej limitu'
result['temp_path'] = temp_path
temp_path = None # przekazujemy plik wywolujacemu
return result
except Exception as exc:
result['status'] = 'error'
result['reason'] = f'{type(exc).__name__}: {exc}'
return result
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
+734 -320
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,2 +1,2 @@
pymysql
Pillow
PyMySQL>=1.0,<2
Pillow>=9.1
+379
View File
@@ -0,0 +1,379 @@
import io
import os
import re
import sys
import tempfile
import types
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import main
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
from image_optimizer import create_optimized_candidate
class FakeCursor:
def __init__(self, conn):
self.conn = conn
self.results = []
self.index = 0
self.rowcount = 0
def __enter__(self):
return self
def __exit__(self, *_):
return False
def close(self):
pass
def execute(self, sql, params=None):
params = params or ()
self.index = 0
self.rowcount = 0
self.results = []
if sql == 'SHOW TABLES':
self.results = [{'Tables_in_test': table} for table in self.conn.schemas]
elif sql.startswith('SHOW COLUMNS FROM'):
table = re.search(r'`([^`]+)`', sql).group(1)
self.results = [{'Field': name, 'Type': data_type} for name, data_type in self.conn.schemas[table].items()]
elif sql.startswith('SELECT fid, filepath, filesize FROM'):
self.results = [dict(row) for row in self.conn.files]
elif sql.startswith('SELECT filesize, filepath FROM'):
self.results = [dict(row) for row in self.conn.files if row['fid'] == params[0]]
elif sql.startswith('UPDATE `files` SET filesize'):
if self.conn.fail_updates:
self.conn.fail_updates -= 1
raise RuntimeError('Simulated DB error')
size, fid, path = params
for row in self.conn.files:
if row['fid'] == fid and row['filepath'] == path:
if row['filesize'] != size:
row['filesize'] = size
self.rowcount = 1
break
return
elif 'FROM `upload` t JOIN' in sql or 'FROM `content_field_scan` t JOIN' in sql:
table = 'upload' if 'FROM `upload`' in sql else 'content_field_scan'
field = 'fid' if table == 'upload' else 'field_scan_fid'
for entry in self.conn.relations.get(table, []):
node = self.conn.nodes.get(entry['nid'])
if not node or (entry.get('vid') and node['vid'] != entry['vid']):
continue
if entry[field] in params:
self.results.append({
'nid': node['nid'], 'title': node['title'],
'node_type': node['type'], 'node_status': node['status'],
'fid': entry[field], 'cid': entry.get('cid'),
})
elif 'FROM `comment_upload` t' in sql:
for entry in self.conn.relations.get('comment_upload', []):
comment = next(c for c in self.conn.comments if c['cid'] == entry['cid'])
node = self.conn.nodes[comment['nid']]
if entry['fid'] in params:
self.results.append({'nid': node['nid'], 'title': node['title'],
'node_type': node['type'], 'node_status': node['status'],
'comment_status': comment['status'],
'cid': comment['cid'], 'fid': entry['fid']})
elif 'r.body AS body' in sql:
for node in self.conn.nodes.values():
for revision in self.conn.revisions:
if revision['nid'] == node['nid'] and revision['vid'] == node['vid']:
self.results.append({
'nid': node['nid'], 'title': node['title'], 'node_type': node['type'],
'node_status': node['status'], 'body': revision['body'],
})
elif 'c.cid, c.status AS comment_status' in sql:
for comment in self.conn.comments:
node = self.conn.nodes[comment['nid']]
self.results.append({
'nid': node['nid'], 'title': node['title'], 'node_type': node['type'],
'node_status': node['status'], 'cid': comment['cid'],
'comment_status': comment['status'], 'comment': comment['comment'],
})
else:
raise AssertionError(f'Unexpected fake SQL: {sql} / {params}')
self.rowcount = len(self.results)
def fetchall(self):
return list(self.results)
def fetchone(self):
return self.results[0] if self.results else None
def fetchmany(self, count):
result = self.results[self.index:self.index + count]
self.index += len(result)
return result
class FakeConnection:
def __init__(self):
self.schemas = {
'files': {'fid': 'int', 'filepath': 'varchar(255)', 'filesize': 'int'},
'node': {'nid': 'int', 'vid': 'int', 'title': 'varchar(255)', 'type': 'varchar(32)', 'status': 'tinyint'},
'node_revisions': {'nid': 'int', 'vid': 'int', 'body': 'longtext'},
'upload': {'fid': 'int', 'nid': 'int', 'vid': 'int'},
'content_field_scan': {'field_scan_fid': 'int', 'nid': 'int', 'vid': 'int'},
'comments': {'cid': 'int', 'nid': 'int', 'comment': 'longtext', 'status': 'tinyint'},
'comment_upload': {'cid': 'int', 'fid': 'int'},
}
self.nodes = {
11: {'nid': 11, 'vid': 101, 'title': 'Stary skan mapy', 'type': 'forum', 'status': 1},
12: {'nid': 12, 'vid': 102, 'title': 'Zdjecia z wakacji', 'type': 'story', 'status': 1},
13: {'nid': 13, 'vid': 103, 'title': 'Odpowiedz na forum', 'type': 'forum', 'status': 0},
}
self.relations = {
'upload': [{'nid': 11, 'vid': 101, 'fid': 7}, {'nid': 13, 'vid': 999, 'fid': 7}],
'content_field_scan': [{'nid': 12, 'vid': 102, 'field_scan_fid': 8}],
'comment_upload': [{'cid': 55, 'fid': 8}],
}
self.revisions = [
{'nid': 11, 'vid': 101, 'body': '<img src="/sites/default/files/scans/mapa.jpg">'},
{'nid': 12, 'vid': 102, 'body': '<p>Wspomnienia.</p>'},
{'nid': 13, 'vid': 103, 'body': 'Zobacz mapa.jpg tutaj.'},
{'nid': 13, 'vid': 999, 'body': 'NIEAKTUALNE /sites/default/files/scans/mapa.jpg'},
]
self.comments = [
{'cid': 55, 'nid': 12, 'comment': 'Znalazlem /sites/default/files/scans/mapa.jpg', 'status': 1},
]
self.files = []
self.fail_updates = 0
self.commits = 0
self.rollbacks = 0
def __enter__(self):
return self
def __exit__(self, *_):
return False
def cursor(self, *_):
return FakeCursor(self)
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
class TestLinks(unittest.TestCase):
def test_links(self):
self.assertEqual(node_url('https://example.org/forum/', 42), 'https://example.org/forum/node/42')
self.assertEqual(node_url('https://example.org/forum', 42, 66), 'https://example.org/forum/node/42#comment-66')
self.assertEqual(node_url('https://example.org/forum', 42, clean_urls=False), 'https://example.org/forum/?q=node/42')
self.assertIn('skan%20mapa.jpg', search_url('https://example.org', 'skan mapa.jpg'))
self.assertEqual(node_url('https://example.org', 42, 66, comment_permalinks=True),
'https://example.org/comment/66#comment-66')
def test_matcher_precision(self):
matcher = ReferenceMatcher([
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'},
])
self.assertEqual(matcher.match('<img src="/sites/default/files/scans/mapa.jpg">')[7], 'sciezka')
self.assertEqual(matcher.match('mapa.jpg')[7], 'nazwa')
self.assertFalse(matcher.match('supermapa.jpg'))
self.assertFalse(ReferenceMatcher([
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'}
], basename_match=False).match('mapa.jpg'))
def test_schema_references_current_only_and_comment_status(self):
conn = FakeConnection()
lookup = DrupalReferenceLookup(conn, base_url='https://forum.example.org')
files = [
{'fid': 7, 'db_filepath': 'sites/default/files/scans/mapa.jpg', 'rel_path': 'scans/mapa.jpg'},
{'fid': 8, 'db_filepath': 'sites/default/files/inne.jpg', 'rel_path': 'inne.jpg'},
]
fake_pymysql = types.SimpleNamespace(cursors=types.SimpleNamespace(SSDictCursor=object))
with patch.dict(sys.modules, {'pymysql': fake_pymysql}):
with redirect_stdout(io.StringIO()):
result = lookup.lookup(files)
self.assertEqual({x['nid'] for x in result[7]}, {11, 12, 13})
self.assertEqual({x['nid'] for x in result[8]}, {12})
attached_to_comment = [x for x in result[8] if x['cid'] == 55][0]
self.assertIn('comment_upload.fid', attached_to_comment['sources'])
direct = [x for x in result[7] if x['nid'] == 11][0]
self.assertIn('upload.fid', direct['sources'])
self.assertIn('tresc wpisu (sciezka)', direct['sources'])
self.assertEqual(direct['confidence'], 'pewne')
self.assertTrue(direct['url'].endswith('/node/11'))
comment = [x for x in result[7] if x['cid'] == 55][0]
self.assertFalse(comment['comment_published'])
self.assertEqual(comment['url'], 'https://forum.example.org/node/12#comment-55')
possible = [x for x in result[7] if x['nid'] == 13][0]
self.assertFalse(possible['published'])
self.assertEqual(possible['confidence'], 'mozliwe')
self.assertNotIn('upload.fid', possible['sources']) # nieaktualna rewizja
class TestFiles(unittest.TestCase):
def setUp(self):
self.workdir = tempfile.TemporaryDirectory()
self.addCleanup(self.workdir.cleanup)
self.root = Path(self.workdir.name)
self.site_root = self.root / 'drupal'
self.folder = self.site_root / 'sites' / 'default' / 'files'
(self.folder / 'scans').mkdir(parents=True)
self.image = self.folder / 'scans' / 'mapa.jpg'
src = Image.effect_noise((950, 1250), 115).convert('RGB')
src.save(self.image, 'JPEG', quality=99)
self.conn = FakeConnection()
self.conn.files = [{
'fid': 7, 'filepath': 'sites/default/files/scans/mapa.jpg', 'filesize': 12345,
}]
self.cfg = types.SimpleNamespace(
FILES_BASE_PATH=str(self.folder), PUBLIC_FILES_PATH='sites/default/files',
DRUPAL_ROOT=str(self.site_root), DRUPAL_BASE_URL='https://forum.example.org',
)
def args(self, *overrides):
return main.build_parser().parse_args(list(overrides))
def test_resolve_prevent_traversal(self):
valid, rel = main.resolve_filepath('sites/default/files/scans/mapa.jpg', str(self.folder))
self.assertEqual(valid, str(self.image))
self.assertEqual(rel, 'scans/mapa.jpg')
bad, _ = main.resolve_filepath('../../etc/shadow', str(self.folder))
self.assertIsNone(bad)
outside, _ = main.resolve_filepath('/etc/passwd', str(self.folder))
self.assertIsNone(outside)
def test_backup_must_not_be_in_webroot(self):
with self.assertRaises(ValueError):
main._safe_backup_dir(str(self.site_root / 'backup'),
str(self.folder), str(self.site_root))
def test_dry_run_no_changes_and_preview(self):
args = self.args('--optimize', '--update-db', '--dry-run', '--max-width', '600',
'--max-height', '800', '--max-file-mb', '0.5')
images, missing, prefix = main.collect_images(self.conn, self.cfg, args)
self.assertFalse(missing)
before = self.image.read_bytes()
with redirect_stdout(io.StringIO()):
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
self.assertEqual(self.image.read_bytes(), before)
self.assertEqual(self.conn.files[0]['filesize'], 12345)
self.assertEqual(stats['optimized'], 0)
self.assertEqual(stats['planned'], 1)
self.assertEqual(images[0]['optimize_status'], 'planned')
self.assertLess(images[0]['after_size'], images[0]['before_size'])
def test_apply_backup_db_and_restore_on_sql_failure(self):
args = self.args('--optimize', '--apply', '--max-width', '600', '--max-height', '800',
'--max-file-mb', '0.5', '--backup-dir', str(self.root / 'backups'))
before = self.image.read_bytes()
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
with redirect_stdout(io.StringIO()):
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
self.assertEqual(stats['optimized'], 1)
self.assertEqual(stats['errors'], 0)
self.assertLess(self.image.stat().st_size, len(before))
self.assertEqual(self.conn.files[0]['filesize'], self.image.stat().st_size)
self.assertEqual(Path(images[0]['backup_file']).read_bytes(), before)
with Image.open(self.image) as img:
self.assertLessEqual(img.width, 600)
self.assertLessEqual(img.height, 800)
# Ponownie uruchomienie z bladem w DB: plik i metadata wracaja.
self.conn.fail_updates = 1
self.conn.files[0]['filesize'] = 12345
second_before = self.image.read_bytes()
args2 = self.args('--optimize', '--apply', '--max-width', '400', '--max-height', '550',
'--max-file-mb', '0.3', '--backup-dir', str(self.root / 'backups'))
images2, _, prefix = main.collect_images(self.conn, self.cfg, args2)
with redirect_stdout(io.StringIO()):
stats2 = main.process_changes(self.conn, self.cfg, args2, images2, prefix)
self.assertEqual(stats2['errors'], 1)
self.assertEqual(self.image.read_bytes(), second_before)
self.assertEqual(self.conn.files[0]['filesize'], 12345)
def test_symlink_is_not_modified(self):
link = self.folder / 'scans' / 'alias.jpg'
link.symlink_to(self.image)
self.conn.files[0]['filepath'] = 'sites/default/files/scans/alias.jpg'
args = self.args('--optimize', '--dry-run', '--max-width', '500')
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
self.assertEqual(images[0]['path'], str(link))
with redirect_stdout(io.StringIO()):
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
self.assertEqual(images[0]['optimize_status'], 'skip')
self.assertEqual(stats['planned'], 0)
self.assertTrue(link.is_symlink())
def test_exif_orientation_after_optimization(self):
original = self.folder / 'scan-orientation.jpg'
img = Image.new('RGB', (240, 120), 'orange')
exif = Image.Exif()
exif[274] = 6
img.save(original, 'JPEG', quality=100, exif=exif)
optimized = create_optimized_candidate(str(original), max_width=150, max_height=200,
max_bytes=100, min_gain_percent=0)
self.assertEqual(optimized['status'], 'ready', optimized['reason'])
try:
with Image.open(optimized['temp_path']) as out:
self.assertEqual(out.size, (100, 200))
self.assertNotEqual(out.getexif().get(274), 6)
finally:
os.unlink(optimized['temp_path'])
def test_png_with_alpha_preserved(self):
filename = self.folder / 'scans' / 'alpha.png'
img = Image.new('RGBA', (300, 900), (10, 100, 50, 130))
img.save(filename)
result = create_optimized_candidate(str(filename), max_width=200, max_height=500,
max_bytes=200, min_gain_percent=0)
self.assertEqual(result['status'], 'ready', result['reason'])
try:
with Image.open(result['temp_path']) as output:
self.assertEqual(output.mode, 'RGBA')
self.assertEqual(output.size, (167, 500))
self.assertEqual(output.getpixel((1, 1))[3], 130)
finally:
os.unlink(result['temp_path'])
def test_no_optimization_for_small_or_gif(self):
tiny = self.folder / 'tiny.png'
Image.new('RGB', (30, 30), 'red').save(tiny)
self.assertEqual(create_optimized_candidate(str(tiny))['status'], 'skip')
gif = self.folder / 'animated.gif'
Image.new('RGB', (300, 500), 'red').save(gif)
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip')
def test_cli_end_to_end_read_only_generates_reports(self):
csv_path = self.root / 'end.csv'
json_path = self.root / 'end.json'
with patch.object(main, 'load_config', return_value=self.cfg), \
patch.object(main, 'get_connection', return_value=self.conn), \
redirect_stdout(io.StringIO()):
status = main.main(['--find-usage', '--no-text-scan', '--top', '10',
'--report-csv', str(csv_path),
'--report-json', str(json_path)])
self.assertEqual(status, 0)
self.assertIn('https://forum.example.org/node/11', csv_path.read_text(encoding='utf-8-sig'))
self.assertIn('"images"', json_path.read_text(encoding='utf-8'))
self.assertEqual(self.conn.files[0]['filesize'], 12345)
def test_csv_has_direct_link(self):
args = self.args('--find-usage', '--site-url', 'https://forum.example.org', '--report-csv', str(self.root / 'out.csv'))
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
images[0]['references'] = [
{'nid': 11, 'cid': None, 'title': 'Przypiety temat', 'node_type': 'forum',
'published': True, 'comment_published': None, 'sources': ['upload.fid'],
'confidence': 'pewne', 'url': 'https://forum.example.org/node/11',
'edit_url': 'https://forum.example.org/node/11/edit'}
]
main.write_csv(args.report_csv, images, self.cfg, args)
text = Path(args.report_csv).read_text(encoding='utf-8-sig')
self.assertIn('https://forum.example.org/node/11', text)
self.assertIn('Przypiety temat', text)
if __name__ == '__main__':
unittest.main()