Add auto favicon fetch via URL with manual upload fallback
This commit is contained in:
parent
c64ddd41a2
commit
68a9e44363
4 changed files with 247 additions and 7 deletions
113
backend/main.py
113
backend/main.py
|
|
@ -8,6 +8,9 @@ from typing import Optional, List
|
|||
import aiosqlite
|
||||
import shutil
|
||||
import os
|
||||
import re as re_mod
|
||||
import ipaddress
|
||||
import httpx
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
|
@ -403,3 +406,113 @@ async def import_html(file: UploadFile = File(...)):
|
|||
"links": imported_links
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class IconLinkParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.icons = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag != 'link':
|
||||
return
|
||||
attrs = dict(attrs)
|
||||
rel = attrs.get('rel', '').lower()
|
||||
if 'icon' in rel:
|
||||
href = attrs.get('href', '').strip()
|
||||
sizes = attrs.get('sizes', '')
|
||||
if href:
|
||||
self.icons.append({'href': href, 'sizes': sizes, 'rel': rel})
|
||||
|
||||
|
||||
def _is_private_url(url: str) -> bool:
|
||||
"""Return True for Tailscale, .local, or private IP URLs — skip fetch for these."""
|
||||
if '.ts.net' in url or '.local' in url:
|
||||
return True
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).hostname or ''
|
||||
addr = ipaddress.ip_address(host)
|
||||
return addr.is_private or addr.is_loopback
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pick_best_icon(icons: list, base_url: str) -> str | None:
|
||||
"""Pick highest-res icon, resolve relative URLs, return absolute URL or None."""
|
||||
from urllib.parse import urljoin, urlparse
|
||||
if not icons:
|
||||
return None
|
||||
# Prefer explicit sizes, larger first
|
||||
def size_score(icon):
|
||||
s = icon.get('sizes', '')
|
||||
m = re_mod.search(r'(\d+)', s)
|
||||
return int(m.group(1)) if m else 0
|
||||
icons_sorted = sorted(icons, key=size_score, reverse=True)
|
||||
href = icons_sorted[0]['href']
|
||||
if href.startswith('data:'):
|
||||
return None
|
||||
return urljoin(base_url, href)
|
||||
|
||||
|
||||
def _domain_filename(url: str) -> str:
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).hostname or 'unknown'
|
||||
# strip www.
|
||||
host = re_mod.sub(r'^www\.', '', host)
|
||||
return host + '.png'
|
||||
|
||||
|
||||
@app.post("/favicons/fetch")
|
||||
async def fetch_favicon(payload: dict):
|
||||
from urllib.parse import urlparse, urljoin
|
||||
url = payload.get('url', '').strip()
|
||||
if not url:
|
||||
return {"favicon": None}
|
||||
if _is_private_url(url):
|
||||
return {"favicon": None}
|
||||
|
||||
filename = _domain_filename(url)
|
||||
dest = os.path.join(FAVICON_DIR, filename)
|
||||
|
||||
# If already cached, return immediately
|
||||
if os.path.exists(dest):
|
||||
return {"favicon": filename}
|
||||
|
||||
parsed = urlparse(url)
|
||||
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
icon_url = None
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0 (compatible; favicon-fetcher/1.0)"}
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client:
|
||||
# Try to parse the page for <link rel="icon"> — short timeout
|
||||
try:
|
||||
resp = await client.get(url, timeout=5)
|
||||
if resp.status_code == 200:
|
||||
content_type = resp.headers.get('content-type', '')
|
||||
if 'html' in content_type:
|
||||
parser = IconLinkParser()
|
||||
parser.feed(resp.text[:20000])
|
||||
icon_url = _pick_best_icon(parser.icons, base_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to /favicon.ico
|
||||
if not icon_url:
|
||||
icon_url = base_url + '/favicon.ico'
|
||||
|
||||
# Download the icon — separate timeout
|
||||
try:
|
||||
icon_resp = await client.get(icon_url, timeout=8)
|
||||
if icon_resp.status_code == 200 and len(icon_resp.content) > 0:
|
||||
with open(dest, 'wb') as f:
|
||||
f.write(icon_resp.content)
|
||||
return {"favicon": filename}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"favicon": None}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
fastapi==0.111.0
|
||||
uvicorn==0.29.0
|
||||
aiosqlite==0.20.0
|
||||
python-multipart==0.0.9
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
|
|
|
|||
107
frontend/app.js
107
frontend/app.js
|
|
@ -330,9 +330,17 @@ function openAddLinkModal() {
|
|||
<label>URL</label>
|
||||
<input type="text" id="f-url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Favicon filename (optional)</label>
|
||||
<input type="text" id="f-favicon" placeholder="forgejo.png" />
|
||||
<div class="field favicon-field">
|
||||
<label>Favicon</label>
|
||||
<div class="favicon-preview-row">
|
||||
<div id="f-favicon-preview" class="favicon-preview-box"></div>
|
||||
<span id="f-favicon-status" class="favicon-status"></span>
|
||||
<button type="button" class="btn-ghost favicon-upload-btn" id="f-favicon-upload-btn" style="display:none">
|
||||
<i class="ti ti-upload"></i> Upload manually
|
||||
</button>
|
||||
<input type="file" id="f-favicon-file" accept="image/*" style="display:none" />
|
||||
<input type="hidden" id="f-favicon" value="" />
|
||||
</div>
|
||||
</div>
|
||||
`, async () => {
|
||||
const label = document.getElementById('f-label').value.trim();
|
||||
|
|
@ -349,6 +357,7 @@ function openAddLinkModal() {
|
|||
closeModal();
|
||||
await selectSubcategory(state.activeSubcategoryId);
|
||||
});
|
||||
setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file');
|
||||
}
|
||||
|
||||
function openEditLinkModal(link, e) {
|
||||
|
|
@ -363,9 +372,17 @@ function openEditLinkModal(link, e) {
|
|||
<label>URL</label>
|
||||
<input type="text" id="f-url" value="${link.url}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Favicon filename</label>
|
||||
<input type="text" id="f-favicon" value="${link.favicon || ''}" />
|
||||
<div class="field favicon-field">
|
||||
<label>Favicon</label>
|
||||
<div class="favicon-preview-row">
|
||||
<div id="f-favicon-preview" class="favicon-preview-box"></div>
|
||||
<span id="f-favicon-status" class="favicon-status"></span>
|
||||
<button type="button" class="btn-ghost favicon-upload-btn" id="f-favicon-upload-btn" style="display:none">
|
||||
<i class="ti ti-upload"></i> Upload manually
|
||||
</button>
|
||||
<input type="file" id="f-favicon-file" accept="image/*" style="display:none" />
|
||||
<input type="hidden" id="f-favicon" value="${link.favicon || ''}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions" style="justify-content:flex-start; margin-top:0">
|
||||
<button class="btn-secondary" style="color:#e8722a;border-color:#e8722a"
|
||||
|
|
@ -386,6 +403,7 @@ function openEditLinkModal(link, e) {
|
|||
closeModal();
|
||||
await selectSubcategory(state.activeSubcategoryId);
|
||||
});
|
||||
setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file', link.favicon);
|
||||
}
|
||||
|
||||
async function deleteCategory(id) {
|
||||
|
|
@ -466,3 +484,80 @@ document.getElementById('import-file-input').addEventListener('change', async (e
|
|||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// Favicon auto-fetch + manual upload helper
|
||||
function setupFaviconFetch(urlId, faviconId, previewId, statusId, uploadBtnId, fileInputId, existingFavicon) {
|
||||
const urlEl = document.getElementById(urlId);
|
||||
const faviconEl = document.getElementById(faviconId);
|
||||
const previewEl = document.getElementById(previewId);
|
||||
const statusEl = document.getElementById(statusId);
|
||||
const uploadBtn = document.getElementById(uploadBtnId);
|
||||
const fileInput = document.getElementById(fileInputId);
|
||||
|
||||
function showPreview(filename) {
|
||||
if (filename) {
|
||||
previewEl.innerHTML = '<img src="/favicons/' + filename + '" width="20" height="20" onerror="this.parentNode.innerHTML=\'<span style=opacity:.4>?</span>\'" />';
|
||||
} else {
|
||||
previewEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Show existing favicon immediately if editing
|
||||
if (existingFavicon) {
|
||||
showPreview(existingFavicon);
|
||||
statusEl.textContent = existingFavicon;
|
||||
uploadBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
async function doFetch(url) {
|
||||
if (!url || !url.startsWith('http')) return;
|
||||
statusEl.textContent = 'Fetching…';
|
||||
uploadBtn.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch(API + '/favicons/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.favicon) {
|
||||
faviconEl.value = data.favicon;
|
||||
showPreview(data.favicon);
|
||||
statusEl.textContent = data.favicon;
|
||||
uploadBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
faviconEl.value = '';
|
||||
previewEl.innerHTML = '';
|
||||
statusEl.textContent = 'Not found';
|
||||
uploadBtn.style.display = 'inline-flex';
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Failed';
|
||||
uploadBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
urlEl.addEventListener('blur', () => doFetch(urlEl.value.trim()));
|
||||
|
||||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
statusEl.textContent = 'Uploading…';
|
||||
try {
|
||||
const res = await fetch(API + '/favicons/upload', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (data.favicon) {
|
||||
faviconEl.value = data.favicon;
|
||||
showPreview(data.favicon);
|
||||
statusEl.textContent = data.favicon;
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'Upload failed';
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -457,3 +457,34 @@ body {
|
|||
background: var(--surface2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Favicon fetch UI */
|
||||
.favicon-preview-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.favicon-preview-box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.favicon-status {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.favicon-upload-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue