| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | type SearchHit struct { |
| 14 | GoogleID string `json:"google_id"` |
| 15 | Title string `json:"title"` |
| 16 | Authors string `json:"authors"` |
| 17 | ISBN *string `json:"isbn,omitempty"` |
| 18 | CoverURL *string `json:"cover_url,omitempty"` |
| 19 | } |
| 20 | |
| 21 | type volumesResponse struct { |
| 22 | Items []volume `json:"items"` |
| 23 | } |
| 24 | |
| 25 | type volume struct { |
| 26 | ID string `json:"id"` |
| 27 | VolumeInfo volumeInfo `json:"volumeInfo"` |
| 28 | } |
| 29 | |
| 30 | type volumeInfo struct { |
| 31 | Title string `json:"title"` |
| 32 | Authors []string `json:"authors"` |
| 33 | Identifiers []identifier `json:"industryIdentifiers"` |
| 34 | ImageLinks *imageLinks `json:"imageLinks"` |
| 35 | } |
| 36 | |
| 37 | type identifier struct { |
| 38 | Kind string `json:"type"` |
| 39 | Identifier string `json:"identifier"` |
| 40 | } |
| 41 | |
| 42 | type imageLinks struct { |
| 43 | Thumbnail string `json:"thumbnail"` |
| 44 | SmallThumbnail string `json:"smallThumbnail"` |
| 45 | } |
| 46 | |
| 47 | func googleBooksSearch(ctx context.Context, query, apiKey string) ([]SearchHit, error) { |
| 48 | trimmed := strings.TrimSpace(query) |
| 49 | if trimmed == "" { |
| 50 | return nil, nil |
| 51 | } |
| 52 | normalized := strings.Map(func(r rune) rune { |
| 53 | if r == ' ' || r == '\t' || r == '\n' || r == '-' { |
| 54 | return -1 |
| 55 | } |
| 56 | return r |
| 57 | }, trimmed) |
| 58 | isISBN := (len(normalized) == 10 || len(normalized) == 13) && isISBNChars(normalized) |
| 59 | q := trimmed |
| 60 | if isISBN { |
| 61 | q = "isbn:" + strings.ToUpper(normalized) |
| 62 | } |
| 63 | u := "https://www.googleapis.com/books/v1/volumes?q=" + url.QueryEscape(q) + "&maxResults=10&printType=books" |
| 64 | if apiKey != "" { |
| 65 | u += "&key=" + url.QueryEscape(apiKey) |
| 66 | } |
| 67 | |
| 68 | client := &http.Client{Timeout: 8 * time.Second} |
| 69 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) |
| 70 | if err != nil { |
| 71 | return nil, err |
| 72 | } |
| 73 | req.Header.Set("User-Agent", "andromeda-library/0.1") |
| 74 | resp, err := client.Do(req) |
| 75 | if err != nil { |
| 76 | return nil, fmt.Errorf("request: %w", err) |
| 77 | } |
| 78 | defer resp.Body.Close() |
| 79 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| 80 | return nil, fmt.Errorf("google books status %s", resp.Status) |
| 81 | } |
| 82 | var data volumesResponse |
| 83 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { |
| 84 | return nil, fmt.Errorf("parse: %w", err) |
| 85 | } |
| 86 | hits := make([]SearchHit, 0, len(data.Items)) |
| 87 | for _, v := range data.Items { |
| 88 | title := v.VolumeInfo.Title |
| 89 | if title == "" { |
| 90 | title = "Untitled" |
| 91 | } |
| 92 | hit := SearchHit{ |
| 93 | GoogleID: v.ID, |
| 94 | Title: title, |
| 95 | Authors: strings.Join(v.VolumeInfo.Authors, ", "), |
| 96 | } |
| 97 | if isbn := pickISBN(v.VolumeInfo.Identifiers); isbn != "" { |
| 98 | hit.ISBN = &isbn |
| 99 | } |
| 100 | if v.VolumeInfo.ImageLinks != nil { |
| 101 | cover := v.VolumeInfo.ImageLinks.Thumbnail |
| 102 | if cover == "" { |
| 103 | cover = v.VolumeInfo.ImageLinks.SmallThumbnail |
| 104 | } |
| 105 | if cover != "" { |
| 106 | cover = strings.Replace(cover, "http://", "https://", 1) |
| 107 | hit.CoverURL = &cover |
| 108 | } |
| 109 | } |
| 110 | hits = append(hits, hit) |
| 111 | } |
| 112 | return hits, nil |
| 113 | } |
| 114 | |
| 115 | func pickISBN(ids []identifier) string { |
| 116 | for _, i := range ids { |
| 117 | if i.Kind == "ISBN_13" { |
| 118 | return i.Identifier |
| 119 | } |
| 120 | } |
| 121 | for _, i := range ids { |
| 122 | if i.Kind == "ISBN_10" { |
| 123 | return i.Identifier |
| 124 | } |
| 125 | } |
| 126 | return "" |
| 127 | } |
| 128 | |
| 129 | func isISBNChars(s string) bool { |
| 130 | for _, c := range s { |
| 131 | if (c < '0' || c > '9') && c != 'X' && c != 'x' { |
| 132 | return false |
| 133 | } |
| 134 | } |
| 135 | return true |
| 136 | } |