Files
compare_filesize_drupal6/drupal_lookup.py
T
2026-10-23 15:26:08 +02:00

326 lines
15 KiB
Python

"""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()
}