- Dialog ikon: nama aplikasi -> Iconify; domain (mikrotik.com) -> ikon resmi situs (manifest 512, apple-touch-icon, favicon) + layanan favicon; URL gambar -> langsung - Contoh kasus: 'winbox' cuma punya 1 ikon tidak nyambung di Iconify, tapi 'mikrotik.com' memberi ikon resminya (5 pilihan, semuanya PNG) - Decoder ICO: entri BMP 24/32-bit (favicon.ico lama) ikut didukung, bukan cuma PNG - FromURL/Fetch selalu mengembalikan PNG; hasil raster/konversi seragam 512px - Tes: deteksi URL gambar/domain, daftar ikon situs, decode ICO BMP, konversi format
304 lines
7.9 KiB
Go
304 lines
7.9 KiB
Go
// Package icon: favicon.go mengambil ikon dari halaman web untuk shortcut PWA.
|
|
package icon
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var client = &http.Client{Timeout: 8 * time.Second}
|
|
|
|
// shortcut: parsing HTML pakai regex, bukan parser penuh; cukup untuk tag <link rel=icon>.
|
|
var (
|
|
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
|
|
relAttr = regexp.MustCompile(`(?is)\brel\s*=\s*["']?([^"'>]+)`)
|
|
hrefAttr = regexp.MustCompile(`(?is)\bhref\s*=\s*["']?([^"'\s>]+)`)
|
|
typeAttr = regexp.MustCompile(`(?is)\btype\s*=\s*["']?([^"'\s>]+)`)
|
|
sizeAttr = regexp.MustCompile(`(?is)\bsizes\s*=\s*["']?(\d+)`)
|
|
)
|
|
|
|
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
|
|
// Urutan: ikon dari web app manifest (paling besar) → <link rel=icon> → /favicon.ico.
|
|
func Fetch(pageURL string) ([]byte, string, error) {
|
|
base, err := url.Parse(pageURL)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
var lastErr error
|
|
for _, candidate := range iconCandidates(base) {
|
|
data, err := get(candidate.url)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
converted, err := ToPNG(data)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("%s: %w", candidate.url, err)
|
|
continue
|
|
}
|
|
return converted, ".png", nil
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = errors.New("no favicon found")
|
|
}
|
|
return nil, "", lastErr
|
|
}
|
|
|
|
// manifestIcons membaca <link rel=manifest> lalu daftar ikonnya,
|
|
// diurutkan dari ukuran terbesar. Gagal parse bukan error: cukup dilewati.
|
|
func manifestIcons(page *url.URL) []iconCandidate {
|
|
html, err := get(page.String())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var manifestURL string
|
|
for _, tag := range linkTag.FindAllString(string(html), -1) {
|
|
if !strings.Contains(strings.ToLower(attr(relAttr, tag)), "manifest") {
|
|
continue
|
|
}
|
|
if href := attr(hrefAttr, tag); href != "" {
|
|
if ref, err := url.Parse(href); err == nil {
|
|
manifestURL = page.ResolveReference(ref).String()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if manifestURL == "" {
|
|
return nil
|
|
}
|
|
|
|
data, err := get(manifestURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var manifest struct {
|
|
Icons []struct {
|
|
Src string `json:"src"`
|
|
Sizes string `json:"sizes"`
|
|
} `json:"icons"`
|
|
}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
return nil
|
|
}
|
|
|
|
base, err := url.Parse(manifestURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var candidates []iconCandidate
|
|
for _, entry := range manifest.Icons {
|
|
if entry.Src == "" {
|
|
continue
|
|
}
|
|
ref, err := url.Parse(entry.Src)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
score, label := 0, "manifest"
|
|
if entry.Sizes != "" {
|
|
label = "manifest " + entry.Sizes
|
|
}
|
|
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
|
|
fmt.Sscanf(size, "%d", &score)
|
|
}
|
|
candidates = append(candidates, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
|
|
}
|
|
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
|
return candidates
|
|
}
|
|
|
|
// SiteIcons mengembalikan semua ikon yang ditawarkan sebuah situs (manifest,
|
|
// apple-touch-icon, favicon) plus versi dari layanan favicon.
|
|
// Dipakai untuk memasang ikon aplikasi dari domain resminya.
|
|
func SiteIcons(pageURL string) ([]Result, error) {
|
|
base, err := normalizePageURL(pageURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out []Result
|
|
seen := map[string]bool{}
|
|
add := func(c iconCandidate) {
|
|
if c.url == "" || seen[c.url] {
|
|
return
|
|
}
|
|
seen[c.url] = true
|
|
out = append(out, Result{Name: c.label, URL: c.url})
|
|
}
|
|
for _, c := range iconCandidates(base) {
|
|
add(c)
|
|
}
|
|
if base.Hostname() != "" {
|
|
add(faviconService(base.Hostname()))
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("situs ini tidak menyediakan ikon")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// faviconService memakai layanan favicon Google sebagai pilihan tambahan.
|
|
func faviconService(domain string) iconCandidate {
|
|
return iconCandidate{
|
|
url: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", url.QueryEscape(domain)),
|
|
label: "google " + domain,
|
|
score: 5,
|
|
}
|
|
}
|
|
|
|
// normalizePageURL menambah skema https kalau user menulis domain saja.
|
|
func normalizePageURL(raw string) (*url.URL, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, errors.New("alamat kosong")
|
|
}
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
base, err := url.Parse(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if base.Host == "" {
|
|
return nil, errors.New("domain tidak valid: " + raw)
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
|
type iconCandidate struct {
|
|
url string
|
|
label string // keterangan untuk ditampilkan di UI (mis. "manifest 512x512")
|
|
score int
|
|
}
|
|
|
|
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
|
|
func iconCandidates(base *url.URL) []iconCandidate {
|
|
var found []iconCandidate
|
|
|
|
if page, err := get(base.String()); err == nil {
|
|
for _, tag := range linkTag.FindAllString(string(page), -1) {
|
|
rel := strings.ToLower(attr(relAttr, tag))
|
|
if !strings.Contains(rel, "icon") || strings.Contains(rel, "mask-icon") {
|
|
continue
|
|
}
|
|
href := attr(hrefAttr, tag)
|
|
if href == "" {
|
|
continue
|
|
}
|
|
ref, err := url.Parse(href)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
score := 1
|
|
if strings.Contains(strings.ToLower(attr(typeAttr, tag)), "png") {
|
|
score += 10
|
|
}
|
|
if size := attr(sizeAttr, tag); size != "" {
|
|
fmt.Sscanf(size, "%d", &score)
|
|
score += 10
|
|
}
|
|
label := rel
|
|
if size := attr(sizeAttr, tag); size != "" {
|
|
label += " " + size
|
|
}
|
|
found = append(found, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score})
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
|
found = append(manifestIcons(base), found...)
|
|
|
|
out := make([]iconCandidate, 0, len(found)+1)
|
|
seen := map[string]bool{}
|
|
for _, c := range found {
|
|
if !seen[c.url] {
|
|
seen[c.url] = true
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
|
|
if !seen[fallback] {
|
|
out = append(out, iconCandidate{url: fallback, label: "favicon.ico"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func attr(re *regexp.Regexp, tag string) string {
|
|
if m := re.FindStringSubmatch(tag); m != nil {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func get(rawURL string) ([]byte, error) {
|
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) LazyRun")
|
|
req.Header.Set("Accept", "text/html,image/*;q=0.9,*/*;q=0.8")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%s: %s", rawURL, resp.Status)
|
|
}
|
|
return io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
|
}
|
|
|
|
func sniffExt(data []byte) string {
|
|
head := data
|
|
if len(head) > 512 {
|
|
head = head[:512]
|
|
}
|
|
switch {
|
|
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
|
return ".png"
|
|
case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}):
|
|
return ".jpg"
|
|
case bytes.Contains(bytes.ToLower(head), []byte("<svg")):
|
|
return ".svg"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// largestPNGFromICO mengambil PNG terbesar di dalam file .ico.
|
|
// ICO dari BMP lama tidak didukung (langka untuk favicon).
|
|
func largestPNGFromICO(data []byte) []byte {
|
|
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
|
return nil
|
|
}
|
|
count := int(data[4]) | int(data[5])<<8
|
|
var best []byte
|
|
for i := 0; i < count; i++ {
|
|
off := 6 + i*16
|
|
if off+16 > len(data) {
|
|
break
|
|
}
|
|
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
|
|
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
|
|
if size <= 0 || start < 0 || start+size > len(data) {
|
|
continue
|
|
}
|
|
blob := data[start : start+size]
|
|
if bytes.HasPrefix(blob, []byte("\x89PNG\r\n\x1a\n")) && len(blob) > len(best) {
|
|
best = blob
|
|
}
|
|
}
|
|
return best
|
|
}
|