rfeghr / app.py
kacapower's picture
Upload app.py
0b49cbb verified
Raw
History Blame Contribute Delete
16.9 kB
import os
import re
import json
import shutil
import requests
import tempfile
import mimetypes
from urllib.parse import urlparse, parse_qs, quote
from bs4 import BeautifulSoup
import gradio as gr
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError: # Pillow not installed — degrade gracefully
PIL_AVAILABLE = False
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
}
# Optional: set a GITHUB_TOKEN env var to raise the GitHub API rate limit from
# 60/hr (unauthenticated, shared across everyone on a host like HF Spaces) to
# 5000/hr. A "public_repo" read-only token is enough.
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN")
GITHUB_API_HEADERS = {
**HEADERS,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
if GITHUB_TOKEN:
GITHUB_API_HEADERS["Authorization"] = f"Bearer {GITHUB_TOKEN}"
KOMI_DOMAINS = ("komi.store", "komistore.app", "komistore.com", "github-store.org")
# Common conventional locations for an app icon inside a repo, used both to
# rank real tree results and as a blind fallback if the Trees API is
# unavailable (rate-limited / private repo).
ICON_FILENAME_RE = re.compile(
r"(fastlane/.+/icon|ic_launcher(_round)?|playstore.?icon|app.?icon|logo|icon)[^/]*\.(png|webp|jpg|jpeg)$",
re.I,
)
DENSITY_RANK = {"xxxhdpi": 6, "xxhdpi": 5, "xhdpi": 4, "hdpi": 3, "mdpi": 2, "ldpi": 1}
COMMON_ICON_GUESS_PATHS = [
"fastlane/metadata/android/en-US/images/icon.png",
"app/src/main/res/mipmap-xxxhdpi/ic_launcher.png",
"app/src/main/res/mipmap-xxhdpi/ic_launcher.png",
"src/main/res/mipmap-xxxhdpi/ic_launcher.png",
".github/logo.png",
".github/assets/logo.png",
"assets/icon.png",
"assets/logo.png",
"docs/logo.png",
"logo.png",
"icon.png",
]
GITHUB_REPO_IN_TEXT_RE = re.compile(r"github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)")
# ---------------------------------------------------------------------------
# Step 1: analyze the link and classify its source
# ---------------------------------------------------------------------------
def identify_source(input_str: str):
input_str = input_str.strip()
if not input_str:
raise ValueError('Enter a Play Store, GitHub, F-Droid, or Komi Store URL or package id.')
try:
u = urlparse(input_str)
except Exception:
u = None
if u and u.scheme in ('http', 'https'):
netloc = u.netloc.lower()
if 'play.google.com' in netloc:
qs = parse_qs(u.query)
if 'id' in qs:
return 'play', qs['id'][0]
m = re.search(r'id=([a-zA-Z0-9_\.]+)', input_str)
if m:
return 'play', m.group(1)
raise ValueError('Invalid Google Play URL.')
if 'f-droid.org' in netloc:
m = re.search(r'/packages/([a-zA-Z0-9_.]+)', u.path)
if m:
return 'fdroid', m.group(1)
qs = parse_qs(u.query)
if 'fdid' in qs:
return 'fdroid', qs['fdid'][0]
raise ValueError('Invalid F-Droid URL: could not find a package id in the path.')
if 'github.com' in netloc:
path = u.path.strip('/')
parts = [part for part in path.split('/') if part]
if len(parts) >= 2:
owner, repo = parts[0], re.sub(r'\.git$', '', parts[1])
return 'github', {'owner': owner, 'repo': repo, 'url': f'https://github.com/{owner}/{repo}'}
raise ValueError('GitHub URL must include owner and repository.')
if any(d in netloc for d in KOMI_DOMAINS):
return 'komi', input_str
# bare "owner/repo" shorthand -> GitHub
m = re.match(r'^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$', input_str)
if m and '.' not in m.group(1):
owner, repo = m.group(1), re.sub(r'\.git$', '', m.group(2))
return 'github', {'owner': owner, 'repo': repo, 'url': f'https://github.com/{owner}/{repo}'}
# bare package name -> Play Store
if re.match(r'^[a-zA-Z0-9_\.]+\.[a-zA-Z0-9_\.]+$', input_str):
return 'play', input_str
raise ValueError('Unsupported source or invalid URL.')
def describe_source(source: str, data) -> str:
if source == 'play':
return f'Detected: Google Play Store — package "{data}"'
if source == 'fdroid':
return f'Detected: F-Droid — package "{data}"'
if source == 'github':
return f'Detected: GitHub repository — {data["owner"]}/{data["repo"]}'
if source == 'komi':
return 'Detected: Komi Store (client-rendered — will resolve via underlying GitHub/Codeberg repo)'
return 'Detected: generic page'
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def fetch_page(url: str) -> str:
r = requests.get(url, headers=HEADERS, timeout=20)
r.raise_for_status()
return r.text
def fetch_json(url: str, headers=None):
r = requests.get(url, headers=headers or HEADERS, timeout=20)
r.raise_for_status()
return r.json()
def find_icon_url_from_html(html: str, source: str = 'generic', repo_data=None):
soup = BeautifulSoup(html, 'lxml')
meta = soup.find('meta', property='og:image')
if meta and meta.get('content'):
return meta['content']
meta = soup.find('meta', attrs={'name': 'twitter:image'})
if meta and meta.get('content'):
return meta['content']
link = soup.find('link', rel='image_src')
if link and link.get('href'):
return link['href']
script = soup.find('script', type='application/ld+json')
if script and script.string:
try:
data = json.loads(script.string)
if isinstance(data, dict) and 'image' in data:
return data['image']
except Exception:
pass
candidates = []
for img in soup.find_all('img'):
src = img.get('src') or img.get('data-src')
alt = (img.get('alt') or '').lower()
cls = ' '.join(img.get('class') or []).lower()
if not src:
continue
if 'icon' in alt or 'logo' in alt or 'avatar' in alt or 'app' in alt:
candidates.append(src)
elif 'avatar' in cls or 'logo' in cls or 'icon' in cls:
candidates.append(src)
if candidates:
return candidates[0]
if source == 'github' and repo_data:
owner = repo_data.get('owner')
if owner:
return f'https://github.com/{owner}.png?size=1024'
raise RuntimeError('Could not find icon URL on the page.')
def upscale_google_image_url(url: str, target_size: int = 1024) -> str:
new = re.sub(r'=s\d+(-[a-zA-Z0-9_-]+)?', f'=s{target_size}', url)
new = re.sub(r'=w\d+-h\d+(-[a-zA-Z0-9_-]+)?', f'=w{target_size}-h{target_size}-rw', new)
return new
def download_image_to_temp(url: str) -> str:
r = requests.get(url, headers=HEADERS, timeout=30, stream=True)
r.raise_for_status()
content_type = r.headers.get('Content-Type', '')
if 'image' not in content_type and not re.search(r'\.(png|jpe?g|webp|gif)$', urlparse(url).path, re.I):
raise RuntimeError(f'Not an image response ({content_type or "unknown type"}).')
ext = mimetypes.guess_extension(content_type.split(';')[0]) or os.path.splitext(urlparse(url).path)[1] or '.png'
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext, prefix='icon_fetch_')
size = 0
with open(tmp.name, 'wb') as f:
for chunk in r.iter_content(4096):
if chunk:
size += len(chunk)
f.write(chunk)
if size < 256: # guard against 1x1 tracking pixels / empty placeholder responses
raise RuntimeError('Downloaded file is too small to be a real icon.')
return tmp.name
def image_dimensions(path: str):
if not PIL_AVAILABLE:
return (0, 0)
try:
with Image.open(path) as im:
return im.size
except Exception:
return (0, 0)
def save_as_logo_png(path: str) -> str:
out_dir = tempfile.mkdtemp(prefix='icon_fetch_')
out_path = os.path.join(out_dir, 'logo.png')
if PIL_AVAILABLE:
try:
with Image.open(path) as im:
if im.mode not in ('RGB', 'RGBA', 'LA', 'L'):
im = im.convert('RGBA')
im.save(out_path, format='PNG')
return out_path
except Exception:
pass
shutil.copyfile(path, out_path) # fallback: raw copy, keep original bytes
return out_path
# ---------------------------------------------------------------------------
# Step 2: per-source, quality-ranked candidate resolution
# ---------------------------------------------------------------------------
def candidates_play(package_id: str):
url = f'https://play.google.com/store/apps/details?id={package_id}&hl=en&gl=US'
html = fetch_page(url)
icon_url = find_icon_url_from_html(html, source='play')
return [upscale_google_image_url(icon_url, target_size=1024)]
def candidates_fdroid(package_id: str):
urls = []
try:
data = fetch_json(f'https://f-droid.org/api/v1/packages/{package_id}')
vcode = data.get('suggestedVersionCode')
if not vcode and data.get('packages'):
vcode = data['packages'][0].get('versionCode')
if vcode:
for density in ('640', '480', '320', '240', '120'):
urls.append(f'https://f-droid.org/repo/icons-{density}/{package_id}.{vcode}.png')
urls.append(f'https://f-droid.org/repo/icons/{package_id}.{vcode}.png')
except Exception:
pass
for page in (f'https://f-droid.org/en/packages/{package_id}/', f'https://f-droid.org/packages/{package_id}/'):
try:
html = fetch_page(page)
urls.append(find_icon_url_from_html(html, source='fdroid'))
except Exception:
continue
if not urls:
raise RuntimeError(f'Could not resolve an icon for F-Droid package "{package_id}".')
return urls
def score_github_path(path: str) -> int:
lower = path.lower()
score = 0
if 'fastlane' in lower and lower.endswith('icon.png'):
score += 1000
if 'playstore' in lower:
score += 200
for density, rank in DENSITY_RANK.items():
if density in lower:
score += rank * 10
m = re.search(r'(\d{2,4})', lower)
if m:
score += min(int(m.group(1)), 2000) // 10
if lower.rsplit('/', 1)[-1] in ('icon.png', 'logo.png', 'app_icon.png'):
score += 50
return score
def candidates_github(owner: str, repo: str, page_url: str = None):
urls = []
branch = None
avatar = None
try:
meta = fetch_json(f'https://api.github.com/repos/{owner}/{repo}', headers=GITHUB_API_HEADERS)
branch = meta.get('default_branch')
avatar = (meta.get('owner') or {}).get('avatar_url')
except Exception:
pass
branches_to_try = [branch] if branch else ['main', 'master']
tree_paths = []
for b in branches_to_try:
if not b:
continue
try:
tree = fetch_json(
f'https://api.github.com/repos/{owner}/{repo}/git/trees/{b}?recursive=1',
headers=GITHUB_API_HEADERS,
).get('tree', [])
tree_paths = [item['path'] for item in tree if item.get('type') == 'blob' and ICON_FILENAME_RE.search(item.get('path', ''))]
branch = b
break
except Exception:
continue
if tree_paths:
tree_paths.sort(key=score_github_path, reverse=True)
for path in tree_paths[:5]:
encoded = '/'.join(quote(part) for part in path.split('/'))
urls.append(f'https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{encoded}')
else:
# Trees API unavailable (rate-limited / private repo) — blind-guess the
# conventional icon locations against each candidate branch.
for b in branches_to_try:
if not b:
continue
for guess in COMMON_ICON_GUESS_PATHS:
urls.append(f'https://raw.githubusercontent.com/{owner}/{repo}/{b}/{guess}')
if avatar:
sep = '&' if '?' in avatar else '?'
urls.append(f'{avatar}{sep}s=1024')
else:
urls.append(f'https://github.com/{owner}.png?size=1024')
if page_url:
try:
html = fetch_page(page_url)
og = find_icon_url_from_html(html, source='github', repo_data={'owner': owner})
if og and og not in urls:
urls.append(og)
except Exception:
pass
return urls
def candidates_komi(page_url: str):
html = fetch_page(page_url)
generic_urls = []
try:
generic_urls.append(find_icon_url_from_html(html, source='komi'))
except Exception:
pass
# Komi Store is a discovery layer over GitHub/Codeberg/Forgejo releases and
# its app pages are rendered client-side, so the raw HTML rarely has a
# usable icon. Look for the underlying GitHub repo reference and delegate
# to the (much more reliable) GitHub pipeline.
github_urls = []
match = GITHUB_REPO_IN_TEXT_RE.search(html)
if match:
owner, repo = match.group(1), re.sub(r'\.git$', '', match.group(2))
try:
github_urls = candidates_github(owner, repo)
except Exception:
github_urls = []
urls = github_urls + generic_urls
if not urls:
raise RuntimeError(
'This Komi Store page is rendered client-side, so no icon is exposed to a '
'plain HTTP fetch and no GitHub/Codeberg repo link was found on the page. '
'Paste the underlying GitHub repo link instead for a high-quality logo.'
)
return urls
# ---------------------------------------------------------------------------
# Step 3: download candidates and keep the highest-quality result
# ---------------------------------------------------------------------------
def fetch_icon_from_source(input_str: str):
source, data = identify_source(input_str)
detected = describe_source(source, data)
if source == 'play':
urls = candidates_play(data)
elif source == 'fdroid':
urls = candidates_fdroid(data)
elif source == 'github':
urls = candidates_github(data['owner'], data['repo'], page_url=data['url'])
elif source == 'komi':
urls = candidates_komi(data)
else:
page_html = fetch_page(input_str)
urls = [find_icon_url_from_html(page_html)]
downloaded = []
for url_try in urls:
try:
path = download_image_to_temp(url_try)
except Exception:
continue
downloaded.append((path, image_dimensions(path), url_try))
# Once we have a few real candidates, compare and stop — no need to
# hammer every fallback once quality is established.
if len(downloaded) >= 3:
break
if not downloaded:
return None, None, f'{detected}\nFailed to download a logo from any candidate source.'
downloaded.sort(key=lambda d: (d[1][0] * d[1][1]) if d[1] else 0, reverse=True)
best_path, best_dims, best_url = downloaded[0]
final_path = save_as_logo_png(best_path)
dims_str = f'{best_dims[0]}x{best_dims[1]}' if best_dims != (0, 0) else 'unknown size'
status = f'{detected}\nOK — {dims_str} from {urlparse(best_url).netloc}'
return final_path, final_path, status
def gr_fetch(input_text: str):
try:
path_img, path_file, status = fetch_icon_from_source(input_text)
except Exception as exc:
return None, None, f'Error: {exc}'
if path_img is None:
return None, None, status
return path_img, path_file, status
if __name__ == '__main__':
with gr.Blocks(title='Icon Fetcher') as demo:
gr.Markdown(
'''# Icon Fetcher\n'''
'''Paste a Google Play Store, GitHub, F-Droid, or Komi Store app URL (or a Play Store package id / bare `owner/repo`). '''
'''Click Fetch — the source is analyzed first, then the highest-quality `logo.png` available is downloaded.'''
)
with gr.Row():
inp = gr.Textbox(label='Play Store / GitHub / F-Droid / Komi Store URL or id', placeholder='https://play.google.com/store/apps/details?id=com.example.app')
btn = gr.Button('Fetch icon')
with gr.Row():
img = gr.Image(type='filepath', label='Icon (preview)')
dl = gr.File(label='Download logo.png')
status = gr.Textbox(label='Status', interactive=False, lines=2)
btn.click(fn=gr_fetch, inputs=[inp], outputs=[img, dl, status])
demo.launch(server_name='0.0.0.0', share=False)