apps/library/google_books.go 3.1 K raw
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
}