44from __future__ import annotations
55
66import argparse
7+ import json
78import math
89import os
910import re
1011import shlex
1112import shutil
1213import subprocess
1314import tempfile
14- from dataclasses import dataclass
15+ from dataclasses import dataclass , replace
1516from pathlib import Path
1617from typing import Any , Dict , Iterable , List , Mapping , Optional , Sequence , Tuple
17- from urllib .parse import urlparse
18+ from urllib .error import HTTPError , URLError
19+ from urllib .parse import quote , urlparse
20+ from urllib .request import Request , urlopen
1821
1922import yaml
2023
169172 "machine-learning" : "ai" ,
170173}
171174TEMPLATE_README_BASE = "https://raw.githubusercontent.com/labring-actions/templates/kb-0.9/template"
175+ SVGL_API_BASE = "https://api.svgl.app"
176+ SVGL_REQUEST_TIMEOUT = 10
177+ SVGL_LOGO_EXT = "svg"
172178HTTP_INGRESS_ANNOTATIONS = {
173179 "kubernetes.io/ingress.class" : "nginx" ,
174180 "nginx.ingress.kubernetes.io/proxy-body-size" : "32m" ,
@@ -233,6 +239,7 @@ class MetadataOptions:
233239 author : str
234240 categories : Sequence [str ]
235241 repo_raw_base : str
242+ logo_ext : str = "png"
236243
237244
238245@dataclass (frozen = True )
@@ -255,6 +262,146 @@ def normalize_k8s_name(raw: str) -> str:
255262 return value
256263
257264
265+ def _normalize_search_text (raw : str ) -> str :
266+ return re .sub (r"[^a-z0-9]+" , "" , raw .lower ())
267+
268+
269+ def _logo_search_terms (meta : MetadataOptions ) -> List [str ]:
270+ terms = [meta .title , meta .app_name .replace ("-" , " " )]
271+ for url in (meta .url , meta .git_repo ):
272+ parsed = urlparse (url )
273+ host = parsed .netloc .lower ().removeprefix ("www." )
274+ if host :
275+ terms .append (host .split ("." )[0 ])
276+ path_name = Path (parsed .path ).stem
277+ if path_name :
278+ terms .append (path_name .replace ("-" , " " ))
279+
280+ unique : List [str ] = []
281+ seen = set ()
282+ for term in terms :
283+ normalized = re .sub (r"\s+" , " " , term .strip ())
284+ if not normalized :
285+ continue
286+ key = normalized .lower ()
287+ if key in seen :
288+ continue
289+ seen .add (key )
290+ unique .append (normalized )
291+ return unique
292+
293+
294+ def _read_json_url (url : str , timeout : int = SVGL_REQUEST_TIMEOUT ) -> Any :
295+ request = Request (url , headers = {"User-Agent" : "docker-to-sealos/1.0" })
296+ with urlopen (request , timeout = timeout ) as response :
297+ return json .loads (response .read ().decode ("utf-8" ))
298+
299+
300+ def _read_text_url (url : str , timeout : int = SVGL_REQUEST_TIMEOUT ) -> str :
301+ request = Request (url , headers = {"User-Agent" : "docker-to-sealos/1.0" })
302+ with urlopen (request , timeout = timeout ) as response :
303+ return response .read ().decode ("utf-8" )
304+
305+
306+ def _select_svg_route (route : Any ) -> str :
307+ if isinstance (route , str ) and route .lower ().endswith (".svg" ):
308+ return route
309+ if isinstance (route , dict ):
310+ for key in ("light" , "dark" ):
311+ value = route .get (key )
312+ if isinstance (value , str ) and value .lower ().endswith (".svg" ):
313+ return value
314+ for value in route .values ():
315+ if isinstance (value , str ) and value .lower ().endswith (".svg" ):
316+ return value
317+ return ""
318+
319+
320+ def _score_svgl_result (
321+ result : Mapping [str , Any ],
322+ meta : MetadataOptions ,
323+ term : str ,
324+ ) -> Tuple [int , int , str ]:
325+ title = str (result .get ("title" ) or "" )
326+ url = str (result .get ("url" ) or result .get ("brandUrl" ) or "" )
327+ title_key = _normalize_search_text (title )
328+ term_key = _normalize_search_text (term )
329+ app_key = _normalize_search_text (meta .app_name )
330+ meta_title_key = _normalize_search_text (meta .title )
331+
332+ score = 0
333+ if title_key and title_key == term_key :
334+ score += 120
335+ elif title_key and term_key and (title_key in term_key or term_key in title_key ):
336+ score += 70
337+ if title_key and title_key in {app_key , meta_title_key }:
338+ score += 90
339+
340+ parsed_meta = urlparse (meta .url )
341+ parsed_result = urlparse (url )
342+ meta_host = parsed_meta .netloc .lower ().removeprefix ("www." )
343+ result_host = parsed_result .netloc .lower ().removeprefix ("www." )
344+ if meta_host and result_host :
345+ hosts_match = (
346+ meta_host == result_host
347+ or meta_host .endswith (f".{ result_host } " )
348+ or result_host .endswith (f".{ meta_host } " )
349+ )
350+ if hosts_match :
351+ score += 100
352+
353+ route = _select_svg_route (result .get ("route" ))
354+ if route :
355+ score += 20
356+ return score , - len (title_key ), route
357+
358+
359+ def find_svgl_logo_url (meta : MetadataOptions ) -> str :
360+ best : Tuple [int , int , str ] = (0 , 0 , "" )
361+ for term in _logo_search_terms (meta ):
362+ search_url = f"{ SVGL_API_BASE } ?search={ quote (term )} "
363+ try :
364+ payload = _read_json_url (search_url )
365+ except (HTTPError , URLError , TimeoutError , json .JSONDecodeError , OSError ):
366+ continue
367+ if not isinstance (payload , list ):
368+ continue
369+ for item in payload :
370+ if not isinstance (item , dict ):
371+ continue
372+ score = _score_svgl_result (item , meta , term )
373+ if score [2 ] and score > best :
374+ best = score
375+ return best [2 ]
376+
377+
378+ def fetch_svgl_logo (meta : MetadataOptions , output_path : Path ) -> bool :
379+ logo_url = find_svgl_logo_url (meta )
380+ if not logo_url :
381+ return False
382+ try :
383+ svg_text = _read_text_url (logo_url )
384+ except (HTTPError , URLError , TimeoutError , UnicodeDecodeError , OSError ):
385+ return False
386+ if "<svg" not in svg_text [:500 ].lower ():
387+ return False
388+ output_path .parent .mkdir (parents = True , exist_ok = True )
389+ output_path .write_text (svg_text , encoding = "utf-8" )
390+ return True
391+
392+
393+ def prepare_logo_asset (meta : MetadataOptions , app_dir : Path , enabled : bool ) -> MetadataOptions :
394+ if not enabled :
395+ return meta
396+ logo_path = app_dir / f"logo.{ SVGL_LOGO_EXT } "
397+ if fetch_svgl_logo (meta , logo_path ):
398+ return replace (meta , logo_ext = SVGL_LOGO_EXT )
399+ existing_logo = next (iter (sorted (app_dir .glob ("logo.*" ))), None )
400+ if existing_logo is not None and existing_logo .suffix :
401+ return replace (meta , logo_ext = existing_logo .suffix .lstrip ("." ))
402+ return meta
403+
404+
258405def has_pinned_image (image : str ) -> bool :
259406 text = image .strip ()
260407 if not text :
@@ -1103,7 +1250,7 @@ def build_template_resource(meta: MetadataOptions) -> Dict[str, Any]:
11031250 "author" : meta .author ,
11041251 "description" : meta .description ,
11051252 "readme" : f"{ readme_base } /README.md" ,
1106- "icon" : f"{ meta .repo_raw_base } /template/{ meta .app_name } /logo.png " ,
1253+ "icon" : f"{ meta .repo_raw_base } /template/{ meta .app_name } /logo.{ meta . logo_ext } " ,
11071254 "templateType" : "inline" ,
11081255 "locale" : "en" ,
11091256 "i18n" : {
@@ -2088,7 +2235,7 @@ def build_app_resource(meta: MetadataOptions) -> Dict[str, Any]:
20882235 "url" : "https://${{ defaults.app_host }}.${{ SEALOS_CLOUD_DOMAIN }}" ,
20892236 },
20902237 "displayType" : "normal" ,
2091- "icon" : f"{ meta .repo_raw_base } /template/{ meta .app_name } /logo.png " ,
2238+ "icon" : f"{ meta .repo_raw_base } /template/{ meta .app_name } /logo.{ meta . logo_ext } " ,
20922239 "name" : meta .title ,
20932240 "type" : "link" ,
20942241 },
@@ -2252,10 +2399,13 @@ def convert_compose_to_template(
22522399 meta : MetadataOptions ,
22532400 kompose_shapes : Optional [Mapping [str , ServiceShape ]] = None ,
22542401 write_files : bool = True ,
2402+ fetch_logo : bool = True ,
22552403) -> Tuple [Path , str ]:
22562404 compose_data = parse_compose (compose_path )
2257- documents = build_documents (compose_data , meta , kompose_shapes = kompose_shapes )
22582405 app_dir = output_root / meta .app_name
2406+ if write_files :
2407+ meta = prepare_logo_asset (meta , app_dir , fetch_logo )
2408+ documents = build_documents (compose_data , meta , kompose_shapes = kompose_shapes )
22592409 index_path = app_dir / "index.yaml"
22602410 rendered = render_index_yaml (documents )
22612411 if write_files :
@@ -2286,6 +2436,11 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
22862436 default = "always" ,
22872437 help = "Use kompose-generated workload shapes: always (required, default), auto (best effort), never (disable)" ,
22882438 )
2439+ parser .add_argument (
2440+ "--no-fetch-logo" ,
2441+ action = "store_true" ,
2442+ help = "Disable default svgl.app SVG logo search and keep the fallback logo path" ,
2443+ )
22892444 parser .add_argument ("--dry-run" , action = "store_true" , help = "Print index.yaml content without writing files" )
22902445 return parser .parse_args (argv )
22912446
@@ -2308,6 +2463,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
23082463 meta = meta ,
23092464 kompose_shapes = kompose_shapes ,
23102465 write_files = not args .dry_run ,
2466+ fetch_logo = not args .no_fetch_logo ,
23112467 )
23122468 except ValueError as exc :
23132469 raise SystemExit (f"ERROR: { exc } " ) from exc
0 commit comments