diff --git a/go.mod b/go.mod index 8357075..1cce75b 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect @@ -28,10 +30,17 @@ require ( github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tamnd/any-cli v0.4.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.24.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect ) diff --git a/go.sum b/go.sum index e7d4564..c5f139c 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,10 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -47,8 +51,12 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -58,6 +66,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tamnd/any-cli v0.4.0 h1:ngyRJBvjZ2X1iBlwlmDLvY2S9aQWlDjVE7CiOwxtt5Y= +github.com/tamnd/any-cli v0.4.0/go.mod h1:lns3VfQVrC9hMy7YKBzIQoYpobnfSDIzJ8c27H2ILmk= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -65,10 +75,22 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/substack/domain.go b/substack/domain.go new file mode 100644 index 0000000..6c37fb5 --- /dev/null +++ b/substack/domain.go @@ -0,0 +1,112 @@ +package substack + +import ( + "context" + + "github.com/tamnd/any-cli/kit" +) + +func init() { kit.Register(Domain{}) } + +// Domain is the substack kit driver. It carries no state; the per-run Client +// is built by the factory Register hands kit. +type Domain struct{} + +// Info describes the scheme, the hostnames a pasted link is matched against, +// and the identity reused for the binary's help and version. +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: "substack", + Hosts: []string{Host}, + Identity: kit.Identity{ + Binary: "substack", + Short: "A command line for Substack newsletters.", + Long: `A command line for Substack newsletters. + +Fetch recent posts from any Substack publication or search for publications +by topic. No API key required.`, + Site: "https://" + Host, + Repo: "https://github.com/tamnd/substack-cli", + }, + } +} + +// Register installs the client factory and every operation onto app. +func (Domain) Register(app *kit.App) { + app.SetClient(newClient) + + // feed: list recent posts from a Substack publication. + kit.Handle(app, kit.OpMeta{ + Name: "feed", + Group: "read", + List: true, + URIType: "post", + Summary: "List recent posts from a Substack publication", + Args: []kit.Arg{{Name: "publication", Help: "publication slug (e.g. astralcodexten, stratechery)"}}, + }, feedCmd) + + // search: search for Substack publications by query. + kit.Handle(app, kit.OpMeta{ + Name: "search", + Group: "read", + List: true, + URIType: "publication", + Summary: "Search for Substack publications", + Args: []kit.Arg{{Name: "query", Help: "search query (e.g. technology, finance)"}}, + }, searchCmd) +} + +func newClient(_ context.Context, cfg kit.Config) (any, error) { + dcfg := DefaultConfig() + if cfg.UserAgent != "" { + dcfg.UserAgent = cfg.UserAgent + } + if cfg.Rate > 0 { + dcfg.Rate = cfg.Rate + } + if cfg.Retries > 0 { + dcfg.Retries = cfg.Retries + } + if cfg.Timeout > 0 { + dcfg.Timeout = cfg.Timeout + } + return NewClient(dcfg), nil +} + +type feedIn struct { + Publication string `kit:"arg" help:"publication slug (e.g. astralcodexten, stratechery)"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +func feedCmd(ctx context.Context, in feedIn, emit func(*Post) error) error { + posts, err := in.Client.Feed(ctx, in.Publication, in.Limit) + if err != nil { + return err + } + for i := range posts { + if err := emit(&posts[i]); err != nil { + return err + } + } + return nil +} + +type searchIn struct { + Query string `kit:"arg" help:"search query (e.g. technology, finance)"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +func searchCmd(ctx context.Context, in searchIn, emit func(*Publication) error) error { + pubs, err := in.Client.Search(ctx, in.Query, in.Limit) + if err != nil { + return err + } + for i := range pubs { + if err := emit(&pubs[i]); err != nil { + return err + } + } + return nil +} diff --git a/substack/domain_test.go b/substack/domain_test.go new file mode 100644 index 0000000..1017d2e --- /dev/null +++ b/substack/domain_test.go @@ -0,0 +1,241 @@ +package substack + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tamnd/any-cli/kit" +) + +// These tests are offline: they exercise the kit domain wiring and the +// feed/search handlers via an httptest.Server. + +func TestDomainInfo(t *testing.T) { + info := Domain{}.Info() + if info.Scheme != "substack" { + t.Errorf("Scheme = %q, want substack", info.Scheme) + } + if len(info.Hosts) == 0 || info.Hosts[0] != Host { + t.Errorf("Hosts = %v, want [%s]", info.Hosts, Host) + } + if info.Identity.Binary != "substack" { + t.Errorf("Identity.Binary = %q, want substack", info.Identity.Binary) + } +} + +// TestHostWiring verifies that kit.Open succeeds after the init registration. +func TestHostWiring(t *testing.T) { + _, err := kit.Open() + if err != nil { + t.Fatalf("kit.Open: %v", err) + } +} + +// samplePosts is a minimal JSON array returned by the Substack posts API. +var samplePosts = []wirePost{ + { + Title: "First Post", + Subtitle: "A subtitle", + PostDate: "2026-06-15T10:00:00.000Z", + Slug: "first-post", + CanonicalURL: "https://example.substack.com/p/first-post", + Type: "newsletter", + }, + { + Title: "Second Post", + Subtitle: "", + PostDate: "2026-06-14T08:00:00.000Z", + Slug: "second-post", + CanonicalURL: "https://example.substack.com/p/second-post", + Type: "newsletter", + }, +} + +func sampleSearchResp() searchResp { + return searchResp{ + Results: []searchResult{ + {Handle: "astralcodexten", Name: "Astral Codex Ten", Description: "A blog about everything", BaseURL: "https://astralcodexten.substack.com"}, + {Handle: "stratechery", Name: "Stratechery", Description: "Technology strategy", BaseURL: "https://stratechery.com"}, + }, + } +} + +func newDomainTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + cfg.Rate = 0 + cfg.Retries = 1 + return NewClient(cfg) +} + +// TestFeedReturnsPosts verifies that Feed parses post JSON from the API. +func TestFeedReturnsPosts(t *testing.T) { + body, _ := json.Marshal(samplePosts) + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/v1/posts") { + t.Errorf("unexpected path %q, want /api/v1/posts", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + + posts, err := c.Feed(context.Background(), "example", 0) + if err != nil { + t.Fatal(err) + } + if len(posts) != 2 { + t.Fatalf("got %d posts, want 2", len(posts)) + } + if posts[0].Title != "First Post" { + t.Errorf("title = %q, want First Post", posts[0].Title) + } + if posts[0].URL != "https://example.substack.com/p/first-post" { + t.Errorf("url = %q", posts[0].URL) + } + // PostDate should be truncated to YYYY-MM-DD. + if posts[0].PostDate != "2026-06-15" { + t.Errorf("post_date = %q, want 2026-06-15", posts[0].PostDate) + } + if posts[0].Rank != 1 { + t.Errorf("rank = %d, want 1", posts[0].Rank) + } +} + +// TestFeedLimitQueryParam verifies that limit is sent as a query param to the API. +func TestFeedLimitQueryParam(t *testing.T) { + body, _ := json.Marshal(samplePosts) + var gotLimit string + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotLimit = r.URL.Query().Get("limit") + _, _ = w.Write(body) + }) + + _, err := c.Feed(context.Background(), "example", 5) + if err != nil { + t.Fatal(err) + } + if gotLimit != "5" { + t.Errorf("limit query param = %q, want 5", gotLimit) + } +} + +// TestSearchReturnsPublications verifies that Search parses the search API response. +func TestSearchReturnsPublications(t *testing.T) { + resp := sampleSearchResp() + body, _ := json.Marshal(resp) + + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/search" { + t.Errorf("unexpected path %q, want /api/v1/search", r.URL.Path) + } + if r.URL.Query().Get("type") != "publication" { + t.Errorf("type param = %q, want publication", r.URL.Query().Get("type")) + } + q := r.URL.Query().Get("q") + if q == "" { + t.Error("q param is empty") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + + pubs, err := c.Search(context.Background(), "technology", 0) + if err != nil { + t.Fatal(err) + } + if len(pubs) != 2 { + t.Fatalf("got %d publications, want 2", len(pubs)) + } + if pubs[0].Name != "astralcodexten" { + t.Errorf("name = %q, want astralcodexten", pubs[0].Name) + } + if pubs[0].Title != "Astral Codex Ten" { + t.Errorf("title = %q, want Astral Codex Ten", pubs[0].Title) + } + if pubs[0].URL != "https://astralcodexten.substack.com" { + t.Errorf("url = %q", pubs[0].URL) + } + if pubs[0].Rank != 1 { + t.Errorf("rank = %d, want 1", pubs[0].Rank) + } +} + +// TestSearchLimit verifies that limit is applied to search results. +func TestSearchLimit(t *testing.T) { + resp := sampleSearchResp() + body, _ := json.Marshal(resp) + + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + }) + + pubs, err := c.Search(context.Background(), "technology", 1) + if err != nil { + t.Fatal(err) + } + if len(pubs) != 1 { + t.Fatalf("got %d publications with limit=1, want 1", len(pubs)) + } +} + +// TestSearchQueryEncoding verifies that the query is URL-encoded in the request. +func TestSearchQueryEncoding(t *testing.T) { + resp := sampleSearchResp() + body, _ := json.Marshal(resp) + var gotQ string + + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotQ = r.URL.Query().Get("q") + _, _ = w.Write(body) + }) + + _, err := c.Search(context.Background(), "machine learning", 0) + if err != nil { + t.Fatal(err) + } + if gotQ != "machine learning" { + t.Errorf("q = %q, want machine learning", gotQ) + } +} + +// TestDomainFeedSendsUserAgent verifies that the client sets a User-Agent header. +func TestDomainFeedSendsUserAgent(t *testing.T) { + body, _ := json.Marshal(samplePosts) + var gotUA string + + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + _, _ = w.Write(body) + }) + + _, err := c.Feed(context.Background(), "example", 0) + if err != nil { + t.Fatal(err) + } + if gotUA == "" { + t.Error("request carried no User-Agent") + } +} + +// TestSearchEmptyResults verifies that an empty results array is handled. +func TestSearchEmptyResults(t *testing.T) { + body := []byte(`{"results":[]}`) + c := newDomainTestClient(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(body) + }) + + pubs, err := c.Search(context.Background(), "nothing", 0) + if err != nil { + t.Fatal(err) + } + if len(pubs) != 0 { + t.Errorf("got %d publications, want 0", len(pubs)) + } +} diff --git a/substack/substack.go b/substack/substack.go index 7b33e83..1b49342 100644 --- a/substack/substack.go +++ b/substack/substack.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "net/http" + "net/url" "sync" "time" ) @@ -21,6 +22,10 @@ import ( // DefaultUserAgent identifies the client to Substack. const DefaultUserAgent = "substack/dev (+https://github.com/tamnd/substack-cli)" +// Host is the site this client talks to, and the host the URI driver in +// domain.go claims. +const Host = "substack.com" + // Config holds constructor parameters. type Config struct { // BaseURL overrides the host when non-empty. Used in tests to point at a @@ -94,6 +99,85 @@ func (c *Client) Feed(ctx context.Context, newsletter string, limit int) ([]Post return nil, fmt.Errorf("feed %s: %w", newsletter, lastErr) } +// Search finds Substack publications matching query. +// limit is the maximum number of results to return (0 means use API default). +func (c *Client) Search(ctx context.Context, query string, limit int) ([]Publication, error) { + baseURL := c.cfg.BaseURL + if baseURL == "" { + baseURL = "https://substack.com" + } + url := fmt.Sprintf("%s/api/v1/search?q=%s&type=publication", baseURL, urlEscape(query)) + + var lastErr error + for attempt := 0; attempt <= c.cfg.Retries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt)): + } + } + pubs, retry, err := c.fetchSearch(ctx, url, limit) + if err == nil { + return pubs, nil + } + lastErr = err + if !retry { + return nil, err + } + } + return nil, fmt.Errorf("search %q: %w", query, lastErr) +} + +func (c *Client) fetchSearch(ctx context.Context, url string, limit int) ([]Publication, bool, error) { + c.pace() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, err + } + req.Header.Set("User-Agent", c.cfg.UserAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, true, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + return nil, true, fmt.Errorf("http %d", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("http %d", resp.StatusCode) + } + + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, true, err + } + + var wire searchResp + if err := json.Unmarshal(b, &wire); err != nil { + return nil, false, fmt.Errorf("decode search: %w", err) + } + + results := wire.Results + if limit > 0 && limit < len(results) { + results = results[:limit] + } + out := make([]Publication, len(results)) + for i, r := range results { + out[i] = Publication{ + Rank: i + 1, + Name: r.Handle, + Title: r.Name, + Description: r.Description, + URL: r.BaseURL, + } + } + return out, false, nil +} + func (c *Client) feedURL(newsletter string, limit int) string { if c.cfg.BaseURL != "" { return fmt.Sprintf("%s/api/v1/posts?limit=%d", c.cfg.BaseURL, limit) @@ -147,6 +231,9 @@ func (c *Client) pace() { c.last = time.Now() } +// urlEscape percent-encodes a query string value. +func urlEscape(s string) string { return url.QueryEscape(s) } + func backoff(attempt int) time.Duration { d := time.Duration(attempt) * 500 * time.Millisecond if d > 5*time.Second { diff --git a/substack/types.go b/substack/types.go index 7e8f300..5751691 100644 --- a/substack/types.go +++ b/substack/types.go @@ -1,5 +1,26 @@ package substack +// Publication is the record emitted for the search command. +type Publication struct { + Rank int `json:"rank"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` +} + +// searchResp is the JSON shape returned by the Substack search API. +type searchResp struct { + Results []searchResult `json:"results"` +} + +type searchResult struct { + Handle string `json:"handle"` + Name string `json:"name"` + Description string `json:"description"` + BaseURL string `json:"base_url"` +} + // Post is the record emitted for the feed command. type Post struct { Rank int `json:"rank"`