feat: Replace single logo comparison with multi-candidate gallery
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Instead of auto-selecting one logo candidate, the service now downloads up to 6 candidates and displays them in a gallery. User sees all options (including their current logo if exists) and picks the best one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b07fc98de
commit
4119e44a58
@ -600,14 +600,12 @@ def api_enrich_company_registry(company_id):
|
||||
@login_required
|
||||
def api_fetch_company_logo(company_id):
|
||||
"""
|
||||
API: Fetch company logo from their website automatically.
|
||||
API: Fetch company logo candidates from their website.
|
||||
|
||||
Actions:
|
||||
- fetch (default): Download logo, save as preview if logo exists
|
||||
- confirm: Rename preview to final (after user comparison)
|
||||
- cancel: Delete preview file
|
||||
|
||||
Accessible by users who can edit the company profile (MANAGER+) or admins.
|
||||
- fetch (default): Download candidates, save as temp files
|
||||
- confirm: Save chosen candidate as final logo (by index)
|
||||
- cancel: Delete all candidate temp files
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@ -628,13 +626,13 @@ def api_fetch_company_logo(company_id):
|
||||
action = data.get('action', 'fetch')
|
||||
|
||||
if action == 'confirm':
|
||||
file_ext = data.get('file_ext', 'webp')
|
||||
ok = service.confirm_logo(company.slug, file_ext)
|
||||
logger.info(f"Logo confirmed for {company.name} by {current_user.email}")
|
||||
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Brak pliku preview'})
|
||||
index = data.get('index', 0)
|
||||
ok = service.confirm_candidate(company.slug, index)
|
||||
logger.info(f"Logo candidate #{index} confirmed for {company.name} by {current_user.email}")
|
||||
return jsonify({'success': ok, 'message': 'Logo zapisane' if ok else 'Nie znaleziono kandydata'})
|
||||
|
||||
if action == 'cancel':
|
||||
service.cancel_logo(company.slug)
|
||||
service.cleanup_candidates(company.slug)
|
||||
return jsonify({'success': True, 'message': 'Anulowano'})
|
||||
|
||||
# action == 'fetch'
|
||||
@ -645,13 +643,13 @@ def api_fetch_company_logo(company_id):
|
||||
}), 400
|
||||
|
||||
has_logo = service.has_existing_logo(company.slug)
|
||||
result = service.fetch_logo(company.website, company.slug, preview=bool(has_logo))
|
||||
result = service.fetch_candidates(company.website, company.slug)
|
||||
result['has_existing_logo'] = has_logo is not None
|
||||
result['existing_logo_ext'] = has_logo
|
||||
|
||||
logger.info(
|
||||
f"Logo fetch for company {company.id} ({company.name}): "
|
||||
f"success={result['success']}, source={result.get('source')}, "
|
||||
f"success={result['success']}, candidates={len(result.get('candidates', []))}, "
|
||||
f"has_existing={has_logo is not None}, by={current_user.email}"
|
||||
)
|
||||
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
"""
|
||||
Logo Fetch Service - Automatically downloads company logos from their websites.
|
||||
Logo Fetch Service - Downloads multiple logo candidates from company websites.
|
||||
|
||||
Strategies (in priority order):
|
||||
1. og:image / twitter:image meta tags
|
||||
1. <img> elements with "logo" in class/id/alt/src
|
||||
2. apple-touch-icon / link rel="icon" (largest size)
|
||||
3. <img> elements with "logo" in class/id/alt/src
|
||||
3. og:image / twitter:image meta tags
|
||||
4. Google Favicon API fallback
|
||||
|
||||
Steps reported to frontend:
|
||||
- fetch_website: GET company website
|
||||
- meta_tags: Parse og:image, twitter:image, favicon
|
||||
- scan_images: Scan img elements for logo candidates
|
||||
- download: Download best candidate image
|
||||
- convert: Convert to WebP format
|
||||
- save: Save to static/img/companies/{slug}.webp
|
||||
Flow:
|
||||
1. Fetch website, find all candidates
|
||||
2. Download up to MAX_CANDIDATES, save as {slug}_cand_{i}.webp
|
||||
3. Frontend shows gallery — user picks one
|
||||
4. confirm_candidate() renames chosen file to {slug}.webp
|
||||
5. cleanup_candidates() removes temp files
|
||||
"""
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@ -30,35 +30,48 @@ logger = logging.getLogger(__name__)
|
||||
USER_AGENT = 'Mozilla/5.0 (compatible; NordaBizBot/1.0)'
|
||||
TIMEOUT = 10
|
||||
MAX_DOWNLOAD_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MIN_LOGO_SIZE = 64 # px
|
||||
MIN_LOGO_SIZE = 32 # px (lowered to catch more candidates)
|
||||
MAX_LOGO_SIZE = 800 # px
|
||||
WEBP_QUALITY = 85
|
||||
MAX_CANDIDATES = 6
|
||||
|
||||
LOGO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'img', 'companies')
|
||||
|
||||
|
||||
SOURCE_LABELS = {
|
||||
'img_scan': 'Element HTML',
|
||||
'css_bg': 'Tło CSS',
|
||||
'apple-touch-icon': 'Apple Touch Icon',
|
||||
'og:image': 'Open Graph',
|
||||
'twitter:image': 'Twitter Card',
|
||||
'favicon': 'Favicon',
|
||||
'google_favicon': 'Google Favicon',
|
||||
}
|
||||
|
||||
|
||||
class LogoFetchService:
|
||||
|
||||
def fetch_logo(self, website_url: str, slug: str, preview: bool = False) -> dict:
|
||||
def fetch_candidates(self, website_url: str, slug: str) -> dict:
|
||||
"""
|
||||
Fetch logo from company website and save as WebP.
|
||||
Fetch multiple logo candidates from company website.
|
||||
|
||||
Args:
|
||||
preview: If True, save as {slug}_preview.{ext} instead of {slug}.{ext}
|
||||
|
||||
Returns: {'success': bool, 'message': str, 'source': str, 'file_ext': str, 'steps': [...]}
|
||||
Returns: {
|
||||
'success': bool,
|
||||
'message': str,
|
||||
'candidates': [{'index': 0, 'source': str, 'label': str, 'ext': str, 'width': int, 'height': int}, ...],
|
||||
'steps': [...]
|
||||
}
|
||||
"""
|
||||
steps = []
|
||||
candidates = []
|
||||
|
||||
# Ensure URL has protocol
|
||||
if not website_url.startswith('http'):
|
||||
website_url = 'https://' + website_url
|
||||
|
||||
# Step 1: Fetch website
|
||||
html, base_url = self._step_fetch_website(website_url, steps)
|
||||
if html is None:
|
||||
return {'success': False, 'message': steps[-1]['message'], 'source': None, 'steps': steps}
|
||||
return {'success': False, 'message': steps[-1]['message'], 'candidates': [], 'steps': steps}
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
@ -79,64 +92,196 @@ class LogoFetchService:
|
||||
|
||||
if not candidates:
|
||||
steps.append({'step': 'download', 'status': 'error', 'message': 'Nie znaleziono kandydatów na logo'})
|
||||
steps.append({'step': 'convert', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
|
||||
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
|
||||
return {'success': False, 'message': 'Nie znaleziono logo na stronie firmy', 'source': None, 'steps': steps}
|
||||
return {'success': False, 'message': 'Nie znaleziono logo na stronie firmy', 'candidates': [], 'steps': steps}
|
||||
|
||||
# Sort by priority (lower = better)
|
||||
# Deduplicate by URL
|
||||
seen_urls = set()
|
||||
unique = []
|
||||
for c in candidates:
|
||||
if c['url'] not in seen_urls:
|
||||
seen_urls.add(c['url'])
|
||||
unique.append(c)
|
||||
candidates = unique
|
||||
|
||||
# Sort by priority
|
||||
candidates.sort(key=lambda c: c['priority'])
|
||||
|
||||
# Step 4: Download best candidate
|
||||
image_data, image_source, content_type = self._step_download(candidates, steps)
|
||||
if image_data is None:
|
||||
steps.append({'step': 'convert', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
|
||||
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — brak obrazu'})
|
||||
return {'success': False, 'message': 'Nie udało się pobrać żadnego kandydata', 'source': None, 'steps': steps}
|
||||
# Step 4+5+6: Download, convert, save each candidate
|
||||
saved = self._download_and_save_candidates(candidates, slug, steps)
|
||||
|
||||
# Step 5: Convert
|
||||
is_svg = content_type and 'svg' in content_type
|
||||
output_data, file_ext = self._step_convert(image_data, is_svg, steps)
|
||||
if output_data is None:
|
||||
steps.append({'step': 'save', 'status': 'skipped', 'message': 'Pominięto — błąd konwersji'})
|
||||
return {'success': False, 'message': 'Błąd konwersji obrazu', 'source': None, 'steps': steps}
|
||||
if not saved:
|
||||
return {'success': False, 'message': 'Nie udało się pobrać żadnego kandydata', 'candidates': [], 'steps': steps}
|
||||
|
||||
# Step 6: Save
|
||||
save_slug = f'{slug}_preview' if preview else slug
|
||||
saved_path = self._step_save(output_data, save_slug, file_ext, steps)
|
||||
if saved_path is None:
|
||||
return {'success': False, 'message': 'Błąd zapisu pliku', 'source': None, 'steps': steps}
|
||||
steps.append({
|
||||
'step': 'save',
|
||||
'status': 'complete',
|
||||
'message': f'Zapisano {len(saved)} kandydatów do wyboru'
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Logo pobrane z {image_source} i zapisane jako {save_slug}.{file_ext}',
|
||||
'source': image_source,
|
||||
'file_ext': file_ext,
|
||||
'message': f'Znaleziono {len(saved)} kandydatów na logo',
|
||||
'candidates': saved,
|
||||
'steps': steps
|
||||
}
|
||||
|
||||
def _download_and_save_candidates(self, candidates, slug, steps):
|
||||
"""Download up to MAX_CANDIDATES, convert and save each."""
|
||||
saved = []
|
||||
idx = 0
|
||||
|
||||
# Clean up old candidates first
|
||||
self.cleanup_candidates(slug)
|
||||
|
||||
for candidate in candidates:
|
||||
if idx >= MAX_CANDIDATES:
|
||||
break
|
||||
|
||||
url = candidate['url']
|
||||
try:
|
||||
response = requests.get(url, timeout=TIMEOUT, headers={
|
||||
'User-Agent': USER_AGENT
|
||||
})
|
||||
|
||||
content_length = int(response.headers.get('content-length', 0))
|
||||
if content_length > MAX_DOWNLOAD_SIZE:
|
||||
continue
|
||||
|
||||
content_type = response.headers.get('content-type', '')
|
||||
|
||||
if not any(t in content_type for t in ['image', 'svg', 'octet-stream']):
|
||||
if 'html' in content_type:
|
||||
continue
|
||||
|
||||
data = response.content
|
||||
if len(data) > MAX_DOWNLOAD_SIZE or len(data) < 100:
|
||||
continue
|
||||
|
||||
is_svg = 'svg' in content_type
|
||||
width, height = 0, 0
|
||||
|
||||
if not is_svg:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(BytesIO(data))
|
||||
width, height = img.size
|
||||
if width < MIN_LOGO_SIZE or height < MIN_LOGO_SIZE:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Convert
|
||||
output_data, file_ext = self._convert(data, is_svg)
|
||||
if output_data is None:
|
||||
continue
|
||||
|
||||
# Get dimensions after conversion
|
||||
if not is_svg and width == 0:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(BytesIO(output_data))
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save as candidate file
|
||||
os.makedirs(LOGO_DIR, exist_ok=True)
|
||||
filename = f'{slug}_cand_{idx}.{file_ext}'
|
||||
filepath = os.path.join(LOGO_DIR, filename)
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(output_data)
|
||||
|
||||
saved.append({
|
||||
'index': idx,
|
||||
'source': candidate['source'],
|
||||
'label': SOURCE_LABELS.get(candidate['source'], candidate['source']),
|
||||
'ext': file_ext,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'size': len(output_data),
|
||||
'filename': filename
|
||||
})
|
||||
idx += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download candidate {url}: {e}")
|
||||
continue
|
||||
|
||||
count = len(saved)
|
||||
if count > 0:
|
||||
steps.append({
|
||||
'step': 'download',
|
||||
'status': 'complete',
|
||||
'message': f'Pobrano {count} obrazów'
|
||||
})
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'download',
|
||||
'status': 'error',
|
||||
'message': 'Żaden kandydat nie spełnił wymagań'
|
||||
})
|
||||
|
||||
return saved
|
||||
|
||||
def _convert(self, image_data, is_svg):
|
||||
"""Convert image to WebP (or keep SVG). Returns (data, ext) or (None, None)."""
|
||||
if is_svg:
|
||||
return image_data, 'svg'
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(image_data))
|
||||
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
|
||||
img = background.convert('RGB')
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
w, h = img.size
|
||||
if w > MAX_LOGO_SIZE or h > MAX_LOGO_SIZE:
|
||||
img.thumbnail((MAX_LOGO_SIZE, MAX_LOGO_SIZE), Image.LANCZOS)
|
||||
|
||||
output = BytesIO()
|
||||
img.save(output, format='WEBP', quality=WEBP_QUALITY)
|
||||
return output.getvalue(), 'webp'
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Conversion error: {e}")
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def confirm_logo(slug: str, file_ext: str) -> bool:
|
||||
"""Rename preview file to final."""
|
||||
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{file_ext}')
|
||||
final = os.path.join(LOGO_DIR, f'{slug}.{file_ext}')
|
||||
if os.path.exists(preview):
|
||||
# Remove old logo in other format if exists
|
||||
for ext in ('webp', 'svg'):
|
||||
old = os.path.join(LOGO_DIR, f'{slug}.{ext}')
|
||||
if old != final and os.path.exists(old):
|
||||
os.remove(old)
|
||||
os.rename(preview, final)
|
||||
return True
|
||||
def confirm_candidate(slug: str, index: int) -> bool:
|
||||
"""Rename chosen candidate to final logo file."""
|
||||
# Find candidate file
|
||||
for ext in ('webp', 'svg'):
|
||||
cand = os.path.join(LOGO_DIR, f'{slug}_cand_{index}.{ext}')
|
||||
if os.path.exists(cand):
|
||||
final = os.path.join(LOGO_DIR, f'{slug}.{ext}')
|
||||
# Remove old logo in other format
|
||||
for old_ext in ('webp', 'svg'):
|
||||
old = os.path.join(LOGO_DIR, f'{slug}.{old_ext}')
|
||||
if old != final and os.path.exists(old):
|
||||
os.remove(old)
|
||||
os.rename(cand, final)
|
||||
# Cleanup remaining candidates
|
||||
LogoFetchService.cleanup_candidates(slug)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def cancel_logo(slug: str) -> bool:
|
||||
"""Delete preview file."""
|
||||
for ext in ('webp', 'svg'):
|
||||
preview = os.path.join(LOGO_DIR, f'{slug}_preview.{ext}')
|
||||
if os.path.exists(preview):
|
||||
os.remove(preview)
|
||||
return True
|
||||
def cleanup_candidates(slug: str):
|
||||
"""Delete all candidate files for a slug."""
|
||||
pattern = os.path.join(LOGO_DIR, f'{slug}_cand_*')
|
||||
for f in glob.glob(pattern):
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def has_existing_logo(slug: str) -> str | None:
|
||||
@ -161,7 +306,6 @@ class LogoFetchService:
|
||||
})
|
||||
return response.text, response.url
|
||||
except requests.exceptions.SSLError:
|
||||
# Retry without SSL verification
|
||||
try:
|
||||
http_url = url.replace('https://', 'http://')
|
||||
response = requests.get(http_url, timeout=TIMEOUT, headers={
|
||||
@ -193,21 +337,18 @@ class LogoFetchService:
|
||||
"""Step 2: Search meta tags for logo candidates."""
|
||||
found = []
|
||||
|
||||
# og:image
|
||||
og_img = soup.find('meta', property='og:image')
|
||||
if og_img and og_img.get('content'):
|
||||
url = urljoin(base_url, og_img['content'])
|
||||
candidates.append({'url': url, 'source': 'og:image', 'priority': 10})
|
||||
found.append('og:image')
|
||||
|
||||
# twitter:image
|
||||
tw_img = soup.find('meta', attrs={'name': 'twitter:image'})
|
||||
if tw_img and tw_img.get('content'):
|
||||
url = urljoin(base_url, tw_img['content'])
|
||||
candidates.append({'url': url, 'source': 'twitter:image', 'priority': 11})
|
||||
found.append('twitter:image')
|
||||
|
||||
# apple-touch-icon (prefer largest)
|
||||
touch_icons = soup.find_all('link', rel=lambda r: r and 'apple-touch-icon' in r)
|
||||
if touch_icons:
|
||||
best = max(touch_icons, key=lambda t: self._parse_size(t.get('sizes', '0x0')))
|
||||
@ -216,28 +357,19 @@ class LogoFetchService:
|
||||
candidates.append({'url': url, 'source': 'apple-touch-icon', 'priority': 5})
|
||||
found.append('apple-touch-icon')
|
||||
|
||||
# link rel="icon" (prefer largest, skip tiny favicons)
|
||||
icons = soup.find_all('link', rel=lambda r: r and 'icon' in r and 'apple' not in str(r))
|
||||
for icon in icons:
|
||||
size = self._parse_size(icon.get('sizes', '0x0'))
|
||||
href = icon.get('href', '')
|
||||
if href and size >= 64:
|
||||
if href and size >= 32:
|
||||
url = urljoin(base_url, href)
|
||||
candidates.append({'url': url, 'source': 'favicon', 'priority': 15})
|
||||
found.append(f'favicon ({icon.get("sizes", "?")})')
|
||||
|
||||
if found:
|
||||
steps.append({
|
||||
'step': 'meta_tags',
|
||||
'status': 'complete',
|
||||
'message': f'Znaleziono: {", ".join(found)}'
|
||||
})
|
||||
steps.append({'step': 'meta_tags', 'status': 'complete', 'message': f'Znaleziono: {", ".join(found)}'})
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'meta_tags',
|
||||
'status': 'missing',
|
||||
'message': 'Brak meta tagów z logo'
|
||||
})
|
||||
steps.append({'step': 'meta_tags', 'status': 'missing', 'message': 'Brak meta tagów z logo'})
|
||||
|
||||
def _step_scan_images(self, soup, base_url, candidates, steps):
|
||||
"""Step 3: Scan img elements for logo candidates."""
|
||||
@ -255,16 +387,14 @@ class LogoFetchService:
|
||||
src = img.get('src') or img.get('data-src') or img.get('data-lazy-src')
|
||||
if src:
|
||||
url = urljoin(base_url, src)
|
||||
# Prioritize based on attribute match
|
||||
priority = 20
|
||||
if 'logo' in (img.get('id', '') + ' '.join(img.get('class', []))).lower():
|
||||
priority = 3 # Class/ID match is very strong signal
|
||||
priority = 3
|
||||
elif 'logo' in img.get('alt', '').lower():
|
||||
priority = 8
|
||||
candidates.append({'url': url, 'source': 'img_scan', 'priority': priority})
|
||||
found_count += 1
|
||||
|
||||
# Also check CSS background images in header/nav
|
||||
for el in soup.select('header a[class*="logo"], nav a[class*="logo"], .logo, #logo, [class*="brand"]'):
|
||||
style = el.get('style', '')
|
||||
bg_match = re.search(r'url\(["\']?([^"\')\s]+)["\']?\)', style)
|
||||
@ -274,150 +404,9 @@ class LogoFetchService:
|
||||
found_count += 1
|
||||
|
||||
if found_count > 0:
|
||||
steps.append({
|
||||
'step': 'scan_images',
|
||||
'status': 'complete',
|
||||
'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'
|
||||
})
|
||||
steps.append({'step': 'scan_images', 'status': 'complete', 'message': f'Znaleziono {found_count} kandydatów z elementów img/CSS'})
|
||||
else:
|
||||
steps.append({
|
||||
'step': 'scan_images',
|
||||
'status': 'missing',
|
||||
'message': 'Brak elementów img z "logo" w atrybutach'
|
||||
})
|
||||
|
||||
def _step_download(self, candidates, steps):
|
||||
"""Step 4: Download the best candidate image."""
|
||||
for candidate in candidates:
|
||||
url = candidate['url']
|
||||
try:
|
||||
response = requests.get(url, timeout=TIMEOUT, headers={
|
||||
'User-Agent': USER_AGENT
|
||||
}, stream=True)
|
||||
|
||||
content_length = int(response.headers.get('content-length', 0))
|
||||
if content_length > MAX_DOWNLOAD_SIZE:
|
||||
logger.debug(f"Skipping {url}: too large ({content_length} bytes)")
|
||||
continue
|
||||
|
||||
content_type = response.headers.get('content-type', '')
|
||||
|
||||
# Verify it's an image
|
||||
if not any(t in content_type for t in ['image', 'svg', 'octet-stream']):
|
||||
# Could be a redirect to HTML page (common for og:image on some sites)
|
||||
if 'html' in content_type:
|
||||
continue
|
||||
|
||||
data = response.content
|
||||
|
||||
if len(data) > MAX_DOWNLOAD_SIZE:
|
||||
continue
|
||||
|
||||
# For raster images, verify dimensions
|
||||
if 'svg' not in content_type:
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(BytesIO(data))
|
||||
w, h = img.size
|
||||
if w < MIN_LOGO_SIZE or h < MIN_LOGO_SIZE:
|
||||
logger.debug(f"Skipping {url}: too small ({w}x{h})")
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
steps.append({
|
||||
'step': 'download',
|
||||
'status': 'complete',
|
||||
'message': f'Pobrano obraz z {candidate["source"]} ({len(data)} bajtów)'
|
||||
})
|
||||
return data, candidate['source'], content_type
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to download {url}: {e}")
|
||||
continue
|
||||
|
||||
steps.append({
|
||||
'step': 'download',
|
||||
'status': 'error',
|
||||
'message': 'Żaden kandydat nie spełnił wymagań (rozmiar, format)'
|
||||
})
|
||||
return None, None, None
|
||||
|
||||
def _step_convert(self, image_data, is_svg, steps):
|
||||
"""Step 5: Convert image to WebP (or keep SVG)."""
|
||||
if is_svg:
|
||||
steps.append({
|
||||
'step': 'convert',
|
||||
'status': 'complete',
|
||||
'message': 'Format SVG — zapisuję bez konwersji'
|
||||
})
|
||||
return image_data, 'svg'
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(image_data))
|
||||
|
||||
# Convert RGBA/P to RGB for WebP
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1] if 'A' in img.mode else None)
|
||||
img = background.convert('RGB')
|
||||
elif img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Resize if too large
|
||||
w, h = img.size
|
||||
if w > MAX_LOGO_SIZE or h > MAX_LOGO_SIZE:
|
||||
img.thumbnail((MAX_LOGO_SIZE, MAX_LOGO_SIZE), Image.LANCZOS)
|
||||
w, h = img.size
|
||||
|
||||
# Save to WebP
|
||||
output = BytesIO()
|
||||
img.save(output, format='WEBP', quality=WEBP_QUALITY)
|
||||
output_data = output.getvalue()
|
||||
|
||||
steps.append({
|
||||
'step': 'convert',
|
||||
'status': 'complete',
|
||||
'message': f'Konwersja do WebP ({w}x{h}, {len(output_data)} bajtów)'
|
||||
})
|
||||
return output_data, 'webp'
|
||||
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'convert',
|
||||
'status': 'error',
|
||||
'message': f'Błąd konwersji: {str(e)[:100]}'
|
||||
})
|
||||
return None, None
|
||||
|
||||
def _step_save(self, data, slug, ext, steps):
|
||||
"""Step 6: Save the file to disk."""
|
||||
try:
|
||||
os.makedirs(LOGO_DIR, exist_ok=True)
|
||||
filename = f'{slug}.{ext}'
|
||||
filepath = os.path.join(LOGO_DIR, filename)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
steps.append({
|
||||
'step': 'save',
|
||||
'status': 'complete',
|
||||
'message': f'Zapisano jako {filename}'
|
||||
})
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
steps.append({
|
||||
'step': 'save',
|
||||
'status': 'error',
|
||||
'message': f'Błąd zapisu: {str(e)[:100]}'
|
||||
})
|
||||
return None
|
||||
steps.append({'step': 'scan_images', 'status': 'missing', 'message': 'Brak elementów img z "logo" w atrybutach'})
|
||||
|
||||
@staticmethod
|
||||
def _parse_size(sizes_str):
|
||||
|
||||
@ -363,8 +363,8 @@
|
||||
.logo-step-icon.missing svg { color: #6b7280; }
|
||||
.logo-step-icon.skipped svg { color: #9ca3af; }
|
||||
|
||||
/* Logo Comparison Modal */
|
||||
.logo-compare-overlay {
|
||||
/* Logo Gallery Modal */
|
||||
.logo-gallery-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
@ -373,61 +373,89 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo-compare-overlay.active { display: flex; }
|
||||
.logo-compare-content {
|
||||
.logo-gallery-overlay.active { display: flex; }
|
||||
.logo-gallery-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
max-width: 600px;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
}
|
||||
.logo-compare-content h3 {
|
||||
.logo-gallery-content h3 {
|
||||
text-align: center;
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
margin: 0 0 4px 0;
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.logo-compare-grid {
|
||||
.logo-gallery-content .gallery-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.logo-gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
.logo-compare-item {
|
||||
.logo-gallery-item {
|
||||
text-align: center;
|
||||
}
|
||||
.logo-compare-item .logo-compare-label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.logo-compare-item .logo-compare-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
background: var(--background);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 3px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.logo-gallery-item:hover { border-color: #e5e7eb; background: #fafafa; }
|
||||
.logo-gallery-item.selected { border-color: #f59e0b; background: #fffbeb; }
|
||||
.logo-gallery-item.current { border-color: #10b981; background: #ecfdf5; }
|
||||
.logo-gallery-item .gallery-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto var(--spacing-xs);
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--border);
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.logo-compare-item .logo-compare-img img {
|
||||
.logo-gallery-item .gallery-img img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.logo-compare-actions {
|
||||
.logo-gallery-item .gallery-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.logo-gallery-item .gallery-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.logo-gallery-item.current .gallery-badge {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.logo-gallery-item .gallery-size {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.logo-gallery-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
.logo-compare-actions button {
|
||||
.logo-gallery-actions button {
|
||||
padding: var(--spacing-sm) var(--spacing-xl);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
@ -436,19 +464,24 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.logo-compare-actions .btn-keep {
|
||||
.logo-gallery-actions .btn-cancel-gallery {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.logo-compare-actions .btn-keep:hover { background: var(--border); }
|
||||
.logo-compare-actions .btn-replace {
|
||||
.logo-gallery-actions .btn-cancel-gallery:hover { background: var(--border); }
|
||||
.logo-gallery-actions .btn-confirm-gallery {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
}
|
||||
.logo-compare-actions .btn-replace:hover {
|
||||
.logo-gallery-actions .btn-confirm-gallery:hover {
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
.logo-gallery-actions .btn-confirm-gallery:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.logo-step-text {
|
||||
font-size: var(--font-size-sm);
|
||||
@ -4112,27 +4145,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Comparison Modal -->
|
||||
<div class="logo-compare-overlay" id="logoCompareOverlay">
|
||||
<div class="logo-compare-content">
|
||||
<h3>Porównanie logo</h3>
|
||||
<div class="logo-compare-grid">
|
||||
<div class="logo-compare-item">
|
||||
<div class="logo-compare-label">Obecne logo</div>
|
||||
<div class="logo-compare-img">
|
||||
<img id="logoCompareOld" src="" alt="Obecne logo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="logo-compare-item">
|
||||
<div class="logo-compare-label">Nowe logo (ze strony WWW)</div>
|
||||
<div class="logo-compare-img">
|
||||
<img id="logoCompareNew" src="" alt="Nowe logo">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Logo Gallery Modal -->
|
||||
<div class="logo-gallery-overlay" id="logoGalleryOverlay">
|
||||
<div class="logo-gallery-content">
|
||||
<h3>Wybierz logo</h3>
|
||||
<p class="gallery-subtitle">Kliknij na logo, które chcesz użyć dla firmy</p>
|
||||
<div class="logo-gallery-grid" id="logoGalleryGrid">
|
||||
<!-- Dynamically populated by JS -->
|
||||
</div>
|
||||
<div class="logo-compare-actions">
|
||||
<button class="btn-keep" id="logoKeepOld">Zachowaj obecne</button>
|
||||
<button class="btn-replace" id="logoUseNew">Użyj nowego</button>
|
||||
<div class="logo-gallery-actions">
|
||||
<button class="btn-cancel-gallery" id="logoGalleryCancel">Anuluj</button>
|
||||
<button class="btn-confirm-gallery" id="logoGalleryConfirm" disabled>Użyj wybranego</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -4167,13 +4190,7 @@
|
||||
<div class="logo-step-icon pending" id="logoStepIcon_download">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>
|
||||
</div>
|
||||
<span class="logo-step-text" id="logoStepText_download">Pobieram najlepszy kandydat...</span>
|
||||
</div>
|
||||
<div class="logo-step" id="logoStep_convert">
|
||||
<div class="logo-step-icon pending" id="logoStepIcon_convert">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>
|
||||
</div>
|
||||
<span class="logo-step-text" id="logoStepText_convert">Konwertuję do formatu WebP...</span>
|
||||
<span class="logo-step-text" id="logoStepText_download">Pobieram i konwertuję obrazy...</span>
|
||||
</div>
|
||||
<div class="logo-step" id="logoStep_save">
|
||||
<div class="logo-step-icon pending" id="logoStepIcon_save">
|
||||
@ -4659,9 +4676,10 @@ function updateLogoStep(stepId, status, message) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const companyId = logoBtn.dataset.companyId;
|
||||
const slug = '{{ company.slug }}';
|
||||
let selectedIndex = null;
|
||||
|
||||
async function animateSteps(data) {
|
||||
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'];
|
||||
const stepOrder = ['fetch_website', 'meta_tags', 'scan_images', 'download', 'save'];
|
||||
const stepsMap = {};
|
||||
if (data.steps) data.steps.forEach(s => { stepsMap[s.step] = s; });
|
||||
|
||||
@ -4689,11 +4707,62 @@ function updateLogoStep(stepId, status, message) {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function buildGallery(candidates, existingLogoExt) {
|
||||
const grid = document.getElementById('logoGalleryGrid');
|
||||
grid.innerHTML = '';
|
||||
selectedIndex = null;
|
||||
const cacheBust = '?t=' + Date.now();
|
||||
|
||||
// Show existing logo first if exists
|
||||
if (existingLogoExt) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'logo-gallery-item current';
|
||||
item.dataset.index = 'current';
|
||||
item.innerHTML = `
|
||||
<div class="gallery-img">
|
||||
<img src="/static/img/companies/${slug}.${existingLogoExt}${cacheBust}" alt="Obecne logo">
|
||||
</div>
|
||||
<div class="gallery-label">Obecne logo</div>
|
||||
<span class="gallery-badge">obecne</span>
|
||||
`;
|
||||
grid.appendChild(item);
|
||||
}
|
||||
|
||||
// Show candidates
|
||||
candidates.forEach(c => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'logo-gallery-item';
|
||||
item.dataset.index = c.index;
|
||||
const dims = c.width ? `${c.width}x${c.height}` : 'SVG';
|
||||
item.innerHTML = `
|
||||
<div class="gallery-img">
|
||||
<img src="/static/img/companies/${c.filename}${cacheBust}" alt="${c.label}">
|
||||
</div>
|
||||
<div class="gallery-label">${c.label}</div>
|
||||
<div class="gallery-size">${dims}</div>
|
||||
`;
|
||||
item.addEventListener('click', () => selectCandidate(item, c.index));
|
||||
grid.appendChild(item);
|
||||
});
|
||||
|
||||
document.getElementById('logoGalleryConfirm').disabled = true;
|
||||
}
|
||||
|
||||
function selectCandidate(item, index) {
|
||||
// Deselect all
|
||||
document.querySelectorAll('.logo-gallery-item').forEach(el => el.classList.remove('selected'));
|
||||
// Select clicked (unless it's the current logo)
|
||||
if (item.dataset.index === 'current') return;
|
||||
item.classList.add('selected');
|
||||
selectedIndex = index;
|
||||
document.getElementById('logoGalleryConfirm').disabled = false;
|
||||
}
|
||||
|
||||
logoBtn.addEventListener('click', async function() {
|
||||
const overlay = document.getElementById('logoLoadingOverlay');
|
||||
|
||||
// Reset steps
|
||||
['fetch_website', 'meta_tags', 'scan_images', 'download', 'convert', 'save'].forEach(s => {
|
||||
['fetch_website', 'meta_tags', 'scan_images', 'download', 'save'].forEach(s => {
|
||||
updateLogoStep(s, 'pending', null);
|
||||
const el = document.getElementById('logoStep_' + s);
|
||||
if (el) el.classList.remove('active', 'done');
|
||||
@ -4707,7 +4776,7 @@ function updateLogoStep(stepId, status, message) {
|
||||
try {
|
||||
const data = await sendLogoAction('fetch');
|
||||
await animateSteps(data);
|
||||
await sleep(2000);
|
||||
await sleep(1500);
|
||||
overlay.classList.remove('active');
|
||||
|
||||
if (!data.success) {
|
||||
@ -4717,24 +4786,10 @@ function updateLogoStep(stepId, status, message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.has_existing_logo) {
|
||||
// Show comparison modal
|
||||
const compareOverlay = document.getElementById('logoCompareOverlay');
|
||||
const oldImg = document.getElementById('logoCompareOld');
|
||||
const newImg = document.getElementById('logoCompareNew');
|
||||
const cacheBust = '?t=' + Date.now();
|
||||
oldImg.src = `/static/img/companies/${slug}.${data.existing_logo_ext}${cacheBust}`;
|
||||
newImg.src = `/static/img/companies/${slug}_preview.${data.file_ext}${cacheBust}`;
|
||||
compareOverlay.classList.add('active');
|
||||
// Show gallery with all candidates
|
||||
buildGallery(data.candidates, data.existing_logo_ext);
|
||||
document.getElementById('logoGalleryOverlay').classList.add('active');
|
||||
|
||||
// Store file_ext for confirm action
|
||||
compareOverlay.dataset.fileExt = data.file_ext;
|
||||
} else {
|
||||
// No existing logo — already saved directly
|
||||
showToast('Logo pobrane i zapisane!', 'success', 3000);
|
||||
await sleep(2000);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logo fetch error:', error);
|
||||
overlay.classList.remove('active');
|
||||
@ -4744,25 +4799,36 @@ function updateLogoStep(stepId, status, message) {
|
||||
}
|
||||
});
|
||||
|
||||
// Comparison modal: Keep old
|
||||
document.getElementById('logoKeepOld').addEventListener('click', async function() {
|
||||
// Gallery: Cancel
|
||||
document.getElementById('logoGalleryCancel').addEventListener('click', async function() {
|
||||
await sendLogoAction('cancel');
|
||||
document.getElementById('logoCompareOverlay').classList.remove('active');
|
||||
showToast('Zachowano obecne logo', 'info', 3000);
|
||||
document.getElementById('logoGalleryOverlay').classList.remove('active');
|
||||
showToast('Anulowano', 'info', 2000);
|
||||
logoBtn.classList.remove('loading');
|
||||
logoBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Comparison modal: Use new
|
||||
document.getElementById('logoUseNew').addEventListener('click', async function() {
|
||||
const compareOverlay = document.getElementById('logoCompareOverlay');
|
||||
const fileExt = compareOverlay.dataset.fileExt || 'webp';
|
||||
await sendLogoAction('confirm', { file_ext: fileExt });
|
||||
compareOverlay.classList.remove('active');
|
||||
showToast('Nowe logo zapisane!', 'success', 3000);
|
||||
// Gallery: Confirm
|
||||
document.getElementById('logoGalleryConfirm').addEventListener('click', async function() {
|
||||
if (selectedIndex === null) return;
|
||||
this.disabled = true;
|
||||
this.textContent = 'Zapisuję...';
|
||||
await sendLogoAction('confirm', { index: selectedIndex });
|
||||
document.getElementById('logoGalleryOverlay').classList.remove('active');
|
||||
showToast('Logo zapisane!', 'success', 3000);
|
||||
await sleep(2000);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
// Close gallery on backdrop click
|
||||
document.getElementById('logoGalleryOverlay').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
sendLogoAction('cancel');
|
||||
this.classList.remove('active');
|
||||
logoBtn.classList.remove('loading');
|
||||
logoBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user