Repository navigation
Add birdy scrape for batch tweet collection - #46
Conversation
Introduce a unified scrape command that auto-routes mixed tweet, profile, search, and list inputs, compiles structured advanced-search filters, runs Latest+Top search, and exports JSON, flat, or CSV after dedup. Co-authored-by: guzus <guzus@users.noreply.github.com>
guzus
left a comment
There was a problem hiding this comment.
Tested this locally: unit tests, extra planner/collector cases, and live birdy scrape against X with a real account.
What works
Existing tests pass (go test ./internal/scrape ./cmd ./internal/xapi ./internal/birdtool). Live happy paths also work:
--handle nasa -n 3IDs matchbirdy user-tweets nasa -n 3 --jsonexactly--search from:nasaIDs matchbirdy search --json- Extra metrics (
quoteCount/viewCount/bookmarkCount/lang) show up on scrape rows and stay off native--json(read/user-tweets/search) --output flat,--mode thread|replies|quotes|retweeters, positional list URLs,@nasavs bareAI, structured--from/--since/--min-likes/--filter, and--max-items-per-targetall behaved- Validation (no targets, unknown mode/output/filter, bad date,
-n 10001) fails before hitting X
Must-fix (reproduced live)
--list <URL>is broken. Flag help says "id or URL".--list https://x.com/i/lists/1748648376080666720sends the raw URL aslistIdand X returnsunrecognized timeline response shape. The same URL as a positional works.--sort bothdrops data.--sort latestand--sort topboth succeed forAI lang:en.--sort bothhitsTimelinePrompton the second search and prints nothing, becausecollectScrapeRowscontinues on error even whensearchJobreturned the Latest tweets. A collector unit test that fails Top on the second call getsrows=[].--handle nasa --from elonmuskscrapes Elon, not NASA.compiledalready containsfrom:elonmusk, so the handle is discarded.
Should-fix
--mode likesaliases to favoriters.--mode likes --handle nasaerrors withmode favoriters requires tweet ids.- Scrape JSON
omitemptys zero counts. Same NASA tweets: user-tweets haslikeCount: [2190, 0, 0], scrape omits the zeros. CSV still prints0. - Search URL
f=liveoverrides--sort top. - README example tweet
1846987139428634858is dead (TweetDetail response contained no usable tweets). Use a live id. --mode profile-likes --handle nasa(hidden likes) returns an opaque GraphQL shape error instead of a clear "likes unavailable" message.
CI
The red test job is not from scrape. make verify dies in web typecheck on App.test.tsx (toHaveLength / .not), which landed on main in "Make X URLs clickable in birdy web". Other tests in that file use .toBe(false). This branch cannot go green until that is fixed on main or here.
The three live functional bugs and the zero-count JSON contract should be fixed before merge. Happy to re-test once those are addressed.
| for _, id := range r.ListIDs { | ||
| id = strings.TrimSpace(strings.TrimPrefix(id, "list:")) | ||
| if id == "" { | ||
| return nil, fmt.Errorf("empty list id") | ||
| } | ||
| targets = append(targets, Target{Kind: KindList, Value: id, Source: id}) | ||
| } |
There was a problem hiding this comment.
--list is documented as accepting a URL, but this path only strips a list: prefix. Live:
birdy scrape --list https://x.com/i/lists/1748648376080666720 -n 2
# Error: x api: unrecognized timeline response shape: no known instructions root
The same URL as a positional works, because Classify extracts the numeric id. Run --list values through Classify (or at least the list-URL regex) so the advertised flag works.
| case scrape.KindSearch: | ||
| tweets, err := searchJob(ctx, client, job) | ||
| if err != nil { | ||
| runErr = err | ||
| continue | ||
| } | ||
| for _, t := range tweets { | ||
| addTweet(t, job) | ||
| } |
There was a problem hiding this comment.
searchJob(..., SortBoth) already returns Latest tweets plus the Top error, but this continue drops those tweets. Live: --sort latest and --sort top both succeed for AI lang:en, while --sort both exits 1 with empty stdout (TimelinePrompt on the second search).
A unit test that fails Top on the second SearchWith call gets rows=[]. Add the Latest tweets before recording runErr, otherwise the README's --sort both example loses data on a flaky Top page.
| case KindProfile: | ||
| if filters.HasConstraints() || compiled != "" { | ||
| query := compiled | ||
| if !strings.Contains(query, "from:") { | ||
| query = strings.TrimSpace("from:" + target.Value + " " + query) | ||
| } | ||
| job.Kind = KindSearch | ||
| job.Query = strings.TrimSpace(query) | ||
| } |
There was a problem hiding this comment.
If compiled already contains from:, the handle is dropped. Live:
birdy scrape --handle nasa --from elonmusk -n 3
# authors: elonmusk, elonmusk, elonmusk
HasConstraints also ignores From, so this only becomes a search because Compile() emitted from:elonmusk. Either reject --handle + --from as conflicting, or keep from:<handle> AND from:<from> (or document that --from replaces the handle).
| case ModeFavoriters, "likes": | ||
| return ModeFavoriters, nil |
There was a problem hiding this comment.
likes is a surprising alias for favoriters. Live:
birdy scrape --mode likes --handle nasa
# Error: mode favoriters requires tweet ids or URLs, got nasa
Users will read this as profile likes (--mode profile-likes / https://x.com/nasa/likes). Drop the likes alias or map it to profile-likes when the target is a handle.
| LikeCount int `json:"likeCount,omitempty"` | ||
| RetweetCount int `json:"retweetCount,omitempty"` | ||
| ReplyCount int `json:"replyCount,omitempty"` | ||
| QuoteCount int `json:"quoteCount,omitempty"` | ||
| ViewCount int `json:"viewCount,omitempty"` | ||
| BookmarkCount int `json:"bookmarkCount,omitempty"` |
There was a problem hiding this comment.
omitempty on counts makes zero likes disappear from JSON. Live, the same 3 NASA tweets:
birdy user-tweets nasa -n 3 --json→likeCount: [2190, 0, 0]birdy scrape --handle nasa -n 3→[2190, <omitted>, <omitted>]
CSV still prints 0. Native/bird JSON keeps zeros on purpose (TestNativeTweetJSONMatchesBird). Scrape consumers cannot tell "0 likes" from "metric missing". Drop omitempty on the numeric engagement fields.
| Value: target.Value, | ||
| Source: target.Source, | ||
| Limit: limit, | ||
| Sort: firstNonEmpty(target.Sort, sort), |
There was a problem hiding this comment.
firstNonEmpty(target.Sort, sort) lets a search URL's f=live override --sort. Plan({Positionals: ["https://x.com/search?q=moon&f=live"], Sort: "top"}) yields Sort: latest. If the user passed --sort explicitly it should win; URL sort should only fill the default.
Parse --list URLs to numeric ids, keep Latest rows when Top search fails, reject a conflicting --handle/--from pair, stop aliasing --mode likes to favoriters, keep zero engagement counts in JSON, and let an explicit --sort override a search URL ranking flag. Co-authored-by: guzus <guzus@users.noreply.github.com>
Use the project's expect helpers so bun typecheck accepts the target=_blank and no-link assertions. Co-authored-by: guzus <guzus@users.noreply.github.com>
birdy scrapecollects tweets from profiles, lists, searches, and tweet URLs in one command.@handles, tweet IDs, list IDs, and search terms--from,--since/--until,--lang,--min-likes,--min-reposts, and--filterinto X advanced-search syntax--sort bothruns Latest and Top, then deduplicates--output json|flat|csvwith quote, view, and bookmark counts that bird's JSON contract never emittedExisting
search,user-tweets, and--jsonoutput stay unchanged. Extra metrics stay unexported on the parser type so bird-compatible JSON does not grow new keys.Review follow-ups (live-tested)
--listnow parses list URLs (not just raw ids)--sort bothkeeps Latest rows when Top fails (TimelinePrompt)--handle+ a different--fromis rejected instead of silently scraping the--fromaccount--mode likesis no longer an alias for favoriters; useprofile-likesorfavoriters0engagement counts--sortoverrides a search URLf=ranking flagexpecthelpers so CI can pass