| 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: |
| 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" |
| } |
|
|
| |
| |
| |
| 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") |
|
|
| |
| |
| |
| 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_.-]+)") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| 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}'} |
|
|
| |
| 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' |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| 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) |
| return out_path |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| |
| 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 |
|
|
| |
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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)) |
| |
| |
| 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) |
|
|