// 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 . var ( linkTag = regexp.MustCompile(`(?is)]*>`) 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) → → /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 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(" 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 }