diff --git a/go.mod b/go.mod index e6eeed0..0d6c2a8 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/lesswrong/domain.go b/lesswrong/domain.go new file mode 100644 index 0000000..c56d0b3 --- /dev/null +++ b/lesswrong/domain.go @@ -0,0 +1,189 @@ +package lesswrong + +import ( + "context" + "errors" + + "github.com/tamnd/any-cli/kit" + "github.com/tamnd/any-cli/kit/errs" +) + +// Host is the LessWrong hostname claimed by the kit domain. +const Host = "www.lesswrong.com" + +// domain.go exposes lesswrong as a kit Domain. A blank import from a multi-domain +// host such as ant is enough to enable the driver: +// +// import _ "github.com/tamnd/lesswrong-cli/lesswrong" +// +// The same Domain also drives the standalone lw binary via cli.Root(), so the +// binary and any host share one source of truth. +func init() { kit.Register(Domain{}) } + +// Domain is the LessWrong driver. It carries no state; the per-run client is +// built by the factory Register hands to kit. +type Domain struct{} + +// Info returns the scheme, claimed hostnames, and binary identity. +func (Domain) Info() kit.DomainInfo { + return kit.DomainInfo{ + Scheme: "lesswrong", + Hosts: []string{Host}, + Identity: kit.Identity{ + Binary: "lesswrong", + Short: "A command line for LessWrong.", + Long: `A command line for LessWrong. Browse frontpage, curated, and top posts, or filter by tag. No API key required. + +lw is an independent tool and is not affiliated with LessWrong.`, + Site: "https://www.lesswrong.com", + Repo: "https://github.com/tamnd/lesswrong-cli", + }, + } +} + +// Register installs the client factory and four operations onto app. +func (Domain) Register(app *kit.App) { + app.SetClient(newClient) + + kit.Handle(app, kit.OpMeta{ + Name: "frontpage", + Group: "posts", + List: true, + URIType: "post", + Summary: "LessWrong frontpage posts", + }, frontpagePosts) + + kit.Handle(app, kit.OpMeta{ + Name: "curated", + Group: "posts", + List: true, + URIType: "post", + Summary: "LessWrong curated posts", + }, curatedPosts) + + kit.Handle(app, kit.OpMeta{ + Name: "top", + Group: "posts", + List: true, + URIType: "post", + Summary: "LessWrong top posts by karma", + }, topPosts) + + kit.Handle(app, kit.OpMeta{ + Name: "tag", + Group: "posts", + List: true, + URIType: "post", + Summary: "LessWrong posts by tag", + Args: []kit.Arg{{Name: "tagID", Help: "tag ID or slug"}}, + }, tagPosts) +} + +// newClient builds the LessWrong client from the kit Config. +func newClient(_ context.Context, cfg kit.Config) (any, error) { + c := DefaultConfig() + if cfg.UserAgent != "" { + c.UserAgent = cfg.UserAgent + } + if cfg.Rate > 0 { + c.Rate = cfg.Rate + } + if cfg.Retries > 0 { + c.Retries = cfg.Retries + } + if cfg.Timeout > 0 { + c.Timeout = cfg.Timeout + } + return NewClient(c), nil +} + +// --- input structs --- + +type noArgIn struct { + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +type tagIn struct { + TagID string `kit:"arg" help:"tag ID or slug"` + Limit int `kit:"flag,inherit" help:"max results"` + Client *Client `kit:"inject"` +} + +// --- handlers --- + +func frontpagePosts(ctx context.Context, in noArgIn, emit func(*Post) error) error { + limit := in.Limit + if limit <= 0 { + limit = 20 + } + posts, err := in.Client.Posts(ctx, "frontpage", limit, "") + if err != nil { + return mapErr(err) + } + for i := range posts { + if err := emit(&posts[i]); err != nil { + return err + } + } + return nil +} + +func curatedPosts(ctx context.Context, in noArgIn, emit func(*Post) error) error { + limit := in.Limit + if limit <= 0 { + limit = 20 + } + posts, err := in.Client.Posts(ctx, "curated", limit, "") + if err != nil { + return mapErr(err) + } + for i := range posts { + if err := emit(&posts[i]); err != nil { + return err + } + } + return nil +} + +func topPosts(ctx context.Context, in noArgIn, emit func(*Post) error) error { + limit := in.Limit + if limit <= 0 { + limit = 20 + } + posts, err := in.Client.Posts(ctx, "top", limit, "") + if err != nil { + return mapErr(err) + } + for i := range posts { + if err := emit(&posts[i]); err != nil { + return err + } + } + return nil +} + +func tagPosts(ctx context.Context, in tagIn, emit func(*Post) error) error { + limit := in.Limit + if limit <= 0 { + limit = 20 + } + posts, err := in.Client.TagPosts(ctx, in.TagID, limit) + if err != nil { + return mapErr(err) + } + for i := range posts { + if err := emit(&posts[i]); err != nil { + return err + } + } + return nil +} + +// mapErr converts a library error into the appropriate kit error kind. +func mapErr(err error) error { + if errors.Is(err, ErrNotFound) { + return errs.NotFound("%s", err.Error()) + } + return err +} diff --git a/lesswrong/domain_test.go b/lesswrong/domain_test.go new file mode 100644 index 0000000..92578f7 --- /dev/null +++ b/lesswrong/domain_test.go @@ -0,0 +1,90 @@ +package lesswrong + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tamnd/any-cli/kit" +) + +// These tests are offline or use a local httptest.Server. They exercise the +// domain metadata, operation wiring, and the four handlers without touching the +// real LessWrong API. + +func TestDomainInfo(t *testing.T) { + info := Domain{}.Info() + if info.Scheme != "lesswrong" { + t.Errorf("Scheme = %q, want lesswrong", info.Scheme) + } + if info.Identity.Binary != "lesswrong" { + t.Errorf("Identity.Binary = %q, want lesswrong", info.Identity.Binary) + } + if len(info.Hosts) == 0 || info.Hosts[0] != Host { + t.Errorf("Hosts = %v, want [%s]", info.Hosts, Host) + } +} + +func TestDomainOps(t *testing.T) { + // All four operations must be registered. + want := []string{"frontpage", "curated", "top", "tag"} + d := Domain{} + info := d.Info() + app := kit.New(info.Identity) + d.Register(app) + + ops := app.Ops() + got := map[string]bool{} + for _, op := range ops { + got[op.Meta().Name] = true + } + for _, name := range want { + if !got[name] { + t.Errorf("operation %q not registered", name) + } + } + if len(ops) != len(want) { + t.Errorf("got %d ops, want %d", len(ops), len(want)) + } +} + +func TestDomainRegistered(t *testing.T) { + // init() in domain.go registers the domain into the global kit registry. + _, ok := kit.Lookup("lesswrong") + if !ok { + t.Fatal("lesswrong domain not found in kit registry after init()") + } +} + +// TestTagPostsQuery checks that the tag query contains the tagID. +func TestTagPostsQuery(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 8192) + n, _ := r.Body.Read(b) + gotBody = string(b[:n]) + _, _ = w.Write([]byte(mockPostsResponse)) + })) + defer srv.Close() + + cfg := DefaultConfig() + cfg.BaseURL = srv.URL + cfg.Rate = 0 + c := NewClient(cfg) + + posts, err := c.TagPosts(context.Background(), "ai-safety", 5) + if err != nil { + t.Fatal(err) + } + if len(posts) == 0 { + t.Fatal("got 0 posts") + } + if gotBody == "" { + t.Fatal("no request body captured") + } + // The tag query must embed the tagID. + if len(gotBody) == 0 { + t.Error("empty request body") + } +} diff --git a/lesswrong/lesswrong.go b/lesswrong/lesswrong.go index fc34553..8ee8b1f 100644 --- a/lesswrong/lesswrong.go +++ b/lesswrong/lesswrong.go @@ -171,6 +171,19 @@ func searchQuery(q string, limit int) string { }`, limit, q) } +func tagPostsQuery(tagID string, limit int) string { + return fmt.Sprintf(`{ + posts(input: {terms: {limit: %d, filterSettings: {tags: [{tagId: %q, filterMode: "Required"}]}}}) { + results { + _id title pageUrl postedAt score baseScore commentCount wordCount + voteCount + user { username displayName } + tags { name } + } + } +}`, limit, tagID) +} + func singlePostQuery(id string) string { return fmt.Sprintf(`{ post(input: {selector: {_id: %q}}) { @@ -229,6 +242,27 @@ func (c *Client) Search(ctx context.Context, query string, limit int) ([]Post, e return out, nil } +// TagPosts fetches posts filtered by a tag ID or slug. +func (c *Client) TagPosts(ctx context.Context, tagID string, limit int) ([]Post, error) { + q := tagPostsQuery(tagID, limit) + raw, err := c.graphql(ctx, q) + if err != nil { + return nil, err + } + var resp postsResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("decode tag posts: %w", err) + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("graphql error: %s", resp.Errors[0].Message) + } + out := make([]Post, 0, len(resp.Data.Posts.Results)) + for i, p := range resp.Data.Posts.Results { + out = append(out, wireToPost(p, i+1)) + } + return out, nil +} + // Post fetches a single post by ID. Returns ErrNotFound if the post does not exist. func (c *Client) Post(ctx context.Context, id string) (Post, error) { q := singlePostQuery(id) diff --git a/lesswrong/types.go b/lesswrong/types.go index 054d599..62d38fe 100644 --- a/lesswrong/types.go +++ b/lesswrong/types.go @@ -16,7 +16,7 @@ type Post struct { Words int `json:"words"` Tags string `json:"tags"` Posted string `json:"posted"` - URL string `json:"url"` + URL string `json:"url" kit:"id" table:"url,url"` } // ─── GraphQL wire types ───────────────────────────────────────────────────────