"""Controlled image refresh for current Radar signals.
Order: existing explicit image_url, YouTube thumbnail, article OG/Twitter image,
then NULL. Only rows in the requested observed_at window are touched. A URL is
marked image_found=1 only after an HTTP image response is verified.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import re
import sqlite3
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path('/opt/struktur/social-media-radar')
sys.path.insert(0, str(ROOT))
from intake.og_fetch import fetch_og_image # noqa: E402
DB = '/var/lib/sma-data/signals.db'
HEADERS = {'User-Agent': 'Mozilla/5.0 (compatible; MallorcaRadar/1.0)'}
YT_RE = re.compile(r'[?&]v=([A-Za-z0-9_-]{11})')
def validate_image(url: str) -> tuple[bool, int | None, str, int]:
if not url or not url.startswith(('http://', 'https://')):
return False, None, '', 0
try:
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=10) as response:
content_type = (response.headers.get('Content-Type') or '').split(';')[0].lower()
head = response.read(64)
ok = response.status == 200 and content_type.startswith('image/') and bool(head)
return ok, response.status, content_type, len(head)
except Exception:
return False, None, '', 0
def youtube_thumbnail(source_url: str) -> str:
match = YT_RE.search(source_url or '')
return f'https://i.ytimg.com/vi/{match.group(1)}/hqdefault.jpg' if match else ''
def resolve_candidate(row: sqlite3.Row) -> tuple[str, str, str | None]:
"""Return image_url, source label, final article URL."""
existing = (row['image_url'] or '').strip()
if existing:
return existing, (row['image_source'] or 'explicit_signal_image'), row['final_article_url']
yt = youtube_thumbnail(row['source_url']) if row['source_type'] in ('youtube', 'watch_entity') else ''
if yt:
return yt, 'youtube_thumbnail', row['final_article_url']
# Facebook search-index pages are not a reliable image source and are not fetched.
if row['source_type'] == 'facebook_public':
return '', '', row['final_article_url']
source_url = row['final_article_url'] or row['source_url']
if not source_url:
return '', '', row['final_article_url']
try:
result = fetch_og_image(source_url)
if result.get('image_url'):
return result['image_url'], result.get('image_source') or 'og:image', result.get('final_article_url')
except Exception:
pass
return '', '', row['final_article_url']
def refresh(days: int = 30, limit: int = 500) -> dict:
now = datetime.now(timezone.utc).isoformat()
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
cutoff = f"-{int(days)} days"
rows = con.execute(
"""SELECT signal_id, topic, source_type, source_name, source_url,
image_url, image_source, image_found, cached_image_path,
final_article_url
FROM signals
WHERE observed_at >= datetime('now', ?) ORDER BY observed_at DESC LIMIT ?""",
(cutoff, limit),
).fetchall()
candidates = []
for row in rows:
url, source, final_url = resolve_candidate(row)
candidates.append((row, url, source, final_url))
# Network validation is parallel; DB writes remain serialized below.
def checked(item):
row, url, source, final_url = item
ok, status, ctype, byte_count = validate_image(url)
return row, url, source, final_url, ok, status, ctype, byte_count
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool:
results = list(pool.map(checked, candidates))
stats = {'checked': len(rows), 'with_image_before': 0, 'valid_images': 0,
'new_image_urls': 0, 'marked_found': 0, 'no_image': 0,
'http_200_images': 0, 'errors': 0}
stats['with_image_before'] = sum(bool((r['image_url'] or '').strip()) and bool(r['image_found']) for r in rows)
evidence = []
for row, url, source, final_url, ok, status, ctype, byte_count in results:
if ok:
sets = ['image_url=?', 'image_source=?', 'image_found=1', 'image_checked_at=?']
values = [url, source or 'validated_image', now]
if final_url:
sets.append('final_article_url=COALESCE(NULLIF(final_article_url,\'\'),?)')
values.append(final_url)
values.append(row['signal_id'])
con.execute(f"UPDATE signals SET {', '.join(sets)} WHERE signal_id=?", values)
stats['valid_images'] += 1
stats['http_200_images'] += 1
if not row['image_url']:
stats['new_image_urls'] += 1
if not row['image_found']:
stats['marked_found'] += 1
evidence.append({'signal_id': row['signal_id'], 'title': row['topic'],
'source_name': row['source_name'], 'image_url': url,
'image_source': source, 'http_status': status,
'content_type': ctype, 'bytes_head': byte_count,
'used_field': 'image_url'})
else:
con.execute('UPDATE signals SET image_checked_at=?, image_found=0 WHERE signal_id=?',
(now, row['signal_id']))
stats['no_image'] += 1
if url and status is not None and not ctype.startswith('image/'):
stats['errors'] += 1
con.commit()
con.close()
stats['evidence'] = evidence
return stats
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--days', type=int, default=30)
parser.add_argument('--limit', type=int, default=500)
args = parser.parse_args()
import json
print(json.dumps(refresh(args.days, args.limit), ensure_ascii=False, indent=2))