favicons
This commit is contained in:
@@ -277,8 +277,17 @@ def _is_icon(data: bytes, content_type: str, url: str) -> bool:
|
||||
|
||||
|
||||
|
||||
def _attr_value(tag: str, name: str) -> str:
|
||||
# Note: Accept quoted and unquoted HTML attributes so favicon discovery works with compact/minified tracker pages.
|
||||
match = re.search(rf"\b{name}\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
|
||||
if match:
|
||||
return match.group(2).strip()
|
||||
match = re.search(rf"\b{name}\s*=\s*([^\s>]+)", tag, re.I | re.S)
|
||||
return match.group(1).strip().strip("'\"") if match else ""
|
||||
|
||||
|
||||
def _extract_icon_hrefs(html: str) -> list[str]:
|
||||
# Note: Regex fallback catches real-world tags like <link rel='shortcut icon' href='...'> even when HTMLParser skips malformed markup.
|
||||
# Note: Read any <link rel=...icon... href=...> order, including shortcut icon and relative CDN paths.
|
||||
hrefs: list[str] = []
|
||||
parser = _IconParser()
|
||||
try:
|
||||
@@ -286,45 +295,51 @@ def _extract_icon_hrefs(html: str) -> list[str]:
|
||||
hrefs.extend(parser.icons)
|
||||
except Exception:
|
||||
pass
|
||||
for match in re.finditer(r"<link\b[^>]*>", html, re.I):
|
||||
for match in re.finditer(r"<link\b[^>]*>", html, re.I | re.S):
|
||||
tag = match.group(0)
|
||||
rel = re.search(r"\brel\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
|
||||
href = re.search(r"\bhref\s*=\s*(['\"])(.*?)\1", tag, re.I | re.S)
|
||||
if rel and href and "icon" in rel.group(2).lower():
|
||||
hrefs.append(href.group(2).strip())
|
||||
rel = _attr_value(tag, "rel").lower()
|
||||
href = _attr_value(tag, "href")
|
||||
if href and "icon" in rel:
|
||||
hrefs.append(href)
|
||||
clean = []
|
||||
seen = set()
|
||||
for href in hrefs:
|
||||
href = str(href or "").strip()
|
||||
if href and href not in seen:
|
||||
seen.add(href)
|
||||
clean.append(href)
|
||||
return clean
|
||||
|
||||
def _favicon_candidates(domain: str) -> list[str]:
|
||||
|
||||
def _tracker_icon_hosts(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
# Note: Only probe the exact tracker host and the registrable root domain; CDN/static hosts are used only when HTML explicitly points to them.
|
||||
return [h for h in dict.fromkeys([host, root]) if h]
|
||||
|
||||
|
||||
def _favicon_candidates(domain: str) -> list[str]:
|
||||
candidates = []
|
||||
# Note: Besides host/root favicon.ico, try common CDN/static hostnames after HTML lookup for trackers that publish icons there.
|
||||
for h in [host, root, f"cdn.{root}" if root else "", f"static.{root}" if root else "", f"www.{root}" if root else ""]:
|
||||
if h:
|
||||
for h in _tracker_icon_hosts(domain):
|
||||
candidates.extend([f"https://{h}/favicon.ico", f"http://{h}/favicon.ico"])
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
|
||||
def _html_icon_candidates(domain: str) -> list[str]:
|
||||
host = tracker_domain(domain)
|
||||
root = _root_domain(host)
|
||||
def _html_icon_candidates(domain: str, errors: list[str] | None = None) -> list[str]:
|
||||
urls = []
|
||||
for h in [host, root]:
|
||||
if not h:
|
||||
continue
|
||||
for h in _tracker_icon_hosts(domain):
|
||||
for scheme in ("https", "http"):
|
||||
base = f"{scheme}://{h}/"
|
||||
try:
|
||||
data, ctype, final_url = _fetch(base, limit=524288)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: {exc}")
|
||||
continue
|
||||
if "html" not in ctype and b"<html" not in data[:2048].lower() and b"<link" not in data.lower():
|
||||
lower = data[:4096].lower()
|
||||
if "html" not in ctype and b"<html" not in lower and b"<link" not in data.lower():
|
||||
if errors is not None:
|
||||
errors.append(f"{base}: response is not html ({ctype or 'unknown content-type'})")
|
||||
continue
|
||||
html = data.decode("utf-8", errors="ignore")
|
||||
for href in _extract_icon_hrefs(html):
|
||||
@@ -365,9 +380,8 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
# Note: Favicon lookup prefers HTML <link rel="icon"> over generic /favicon.ico, because some trackers serve a broken default icon there.
|
||||
FAVICON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
errors = []
|
||||
candidates = _html_icon_candidates(clean) + _favicon_candidates(clean)
|
||||
candidates = _html_icon_candidates(clean, errors) + _favicon_candidates(clean)
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
checked_html = False
|
||||
idx = 0
|
||||
while idx < len(candidates):
|
||||
url = candidates[idx]
|
||||
@@ -375,6 +389,7 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
try:
|
||||
data, ctype, final_url = _fetch(url, limit=524288)
|
||||
if not _is_icon(data, ctype, final_url):
|
||||
errors.append(f"{url}: invalid icon ({ctype or 'unknown content-type'}, {len(data)} bytes)")
|
||||
continue
|
||||
ext = Path(urllib.parse.urlparse(final_url).path).suffix.lower() or mimetypes.guess_extension(ctype) or ".ico"
|
||||
if ext not in {".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp"}:
|
||||
@@ -400,9 +415,7 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
return path, mime
|
||||
except Exception as exc:
|
||||
errors.append(f"{url}: {exc}")
|
||||
if idx >= len(candidates) and not checked_html:
|
||||
checked_html = True
|
||||
candidates.extend([u for u in _html_icon_candidates(clean) if u not in candidates])
|
||||
# HTML is checked once before direct /favicon.ico probes; do not guess cdn/static/www hosts unless HTML points there.
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -413,6 +426,6 @@ def favicon_path(domain: str, enabled: bool = True, force: bool = False) -> tupl
|
||||
updated_epoch=excluded.updated_epoch,
|
||||
error=excluded.error
|
||||
""",
|
||||
(clean, utcnow(), now, "; ".join(errors[-3:]) or "favicon not found"),
|
||||
(clean, utcnow(), now, "; ".join(errors[-8:]) or "favicon not found"),
|
||||
)
|
||||
return None, None
|
||||
|
||||
Reference in New Issue
Block a user