Skip to content

Commit c2c459e

Browse files
feat(docker-to-sealos): prefer SVGL logos by default (#29)
1 parent a2874b2 commit c2c459e

2 files changed

Lines changed: 365 additions & 5 deletions

File tree

skills/docker-to-sealos/scripts/compose_to_template.py

Lines changed: 161 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
from __future__ import annotations
55

66
import argparse
7+
import json
78
import math
89
import os
910
import re
1011
import shlex
1112
import shutil
1213
import subprocess
1314
import tempfile
14-
from dataclasses import dataclass
15+
from dataclasses import dataclass, replace
1516
from pathlib import Path
1617
from 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

1922
import yaml
2023

@@ -169,6 +172,9 @@
169172
"machine-learning": "ai",
170173
}
171174
TEMPLATE_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"
172178
HTTP_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+
258405
def 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

Comments
 (0)