diff options
| author | Miquel Sabaté Solà <mssola@mssola.com> | 2026-10-26 22:31:34 +0100 |
|---|---|---|
| committer | Miquel Sabaté Solà <mssola@mssola.com> | 2026-10-26 22:31:34 +0100 |
| commit | 55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b (patch) | |
| tree | 1fcc48a634bc6ba0a0eb0deb11b78b7020f0df2c /main.go | |
| download | piupiu-55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b.tar.gz piupiu-55ca9eb060fb9e55c33f4ec35f4c6d1e27873d8b.zip | |
Initial commit
Signed-off-by: Miquel Sabaté Solà <mssola@mssola.com>
Diffstat (limited to 'main.go')
| -rw-r--r-- | main.go | 214 |
1 files changed, 214 insertions, 0 deletions
@@ -0,0 +1,214 @@ +// piupiu: grab tweets and put them into RSS feeds. +// Copyright (C) 2026-Ω Miquel Sabaté Solà <mssola@mssola.com> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +package main + +import ( + "encoding/json" + "encoding/xml" + "flag" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type Tweet struct { + ID string `json:"id"` + Text string `json:"text"` + CreatedAt string `json:"created_at"` +} + +type TweetResponse struct { + Data []Tweet `json:"data"` +} + +type RSS struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel RSSChannel `xml:"channel"` +} + +type RSSChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Items []RSSItem `xml:"item"` +} + +type RSSItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + GUID string `xml:"guid"` +} + +// Given the directory 'path' and the 'username', create a +// '<path>/<username>.xml' file with the 'tweets' in RSS format. +func tweetsToRss(username, path string, tweets []Tweet) error { + // XML definition of this whole thing. + rss := RSS{ + Version: "2.0", + Channel: RSSChannel{ + Title: fmt.Sprintf("piu piu: @%s", username), + Link: fmt.Sprintf("https://x.com/%s", username), + Description: fmt.Sprintf("@%s", username), + Items: make([]RSSItem, 0), + }, + } + + // Now iterate on the given tweets and transform them into RSSItem's. + for _, tweet := range tweets { + tweetURL := fmt.Sprintf("https://x.com/%s/status/%s", username, tweet.ID) + + // Remember how the X API required a specific timestamp format? Well, + // RSS wants another one. Because timestamp ISO's are how programmers + // become (even) more cynical. + t, err := time.Parse(time.RFC3339, tweet.CreatedAt) + pubDate := tweet.CreatedAt + if err == nil { + pubDate = t.Format(time.RFC1123Z) + } + + // Use the first few words or characters as the RSS item title. + title := tweet.Text + if len(title) > 50 { + title = title[:47] + "..." + } + + // Voila! + rss.Channel.Items = append(rss.Channel.Items, RSSItem{ + Title: title, + Link: tweetURL, + Description: tweet.Text, + PubDate: pubDate, + GUID: tweetURL, + }) + } + + // Marshal into XML with a standard header. + output, err := xml.MarshalIndent(rss, "", " ") + if err != nil { + return err + } + fullXML := []byte(xml.Header + string(output)) + + // And write the damn thing. + filename := fmt.Sprintf("%s.xml", username) + filePath := filepath.Join(path, filename) + return os.WriteFile(filePath, fullXML, 0644) +} + +// Given a user 'id' and the API 'token', fetch all the tweets from the last 24 +// hours. +func fetch(id, token string) ([]Tweet, error) { + apiURL := fmt.Sprintf("https://api.x.com/2/users/%v/tweets", id) + u, err := url.Parse(apiURL) + if err != nil { + return nil, err + } + + q := u.Query() + startTime := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) + q.Set("start_time", startTime) + q.Set("tweet.fields", "created_at,text") + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error: %v (%v)", resp.Status, resp.StatusCode) + } + + var object TweetResponse + if err := json.NewDecoder(resp.Body).Decode(&object); err != nil { + return nil, err + } + return object.Data, nil +} + +func main() { + // First of all, enforce the stupid ass API token. + token := os.Getenv("PIUPIU_TOKEN") + if token == "" { + fmt.Println("error: you need to provide a token via the 'PIUPIU_TOKEN' environment variable.") + os.Exit(1) + } + + // Behold, argument parsing! + query := flag.String("q", "", "Comma-separated list of '<@user_name>:<user_id>' values.") + output := flag.String("o", ".", "Directory where RSS files will be dropped") + flag.Parse() + + // This, of course, cursed in unholy ways, as path sanitization shenanigans + // have proved since the existence of the concept of a path in operating + // systems. That being said, I'm the sole user of this, so whatever... + if *output != "." { + if err := os.MkdirAll(*output, 0755); err != nil { + fmt.Printf("error: cannot create directory: %w", err) + os.Exit(1) + } + } + + ids := strings.Split(*query, ",") + exit := 0 + for _, id := range ids { + // I could've gone with something friendlier, but this was faster, and I + // just didn't care. If you don't recall the format, just RTFM (in this + // case just -h, as manuals are for people who actually want to make a + // living out of ones and zeroes :P). + vals := strings.Split(id, ":") + if len(vals) != 2 { + fmt.Printf("error: bad argument format\n\n") + flag.PrintDefaults() + os.Exit(1) + } + + // Fetch the tweet data from the API. + name := strings.TrimSpace(vals[0]) + id = strings.TrimSpace(vals[1]) + tweets, err := fetch(id, token) + if err != nil { + fmt.Printf("error: %v\n", err) + exit = 2 + continue + } + + // Generate the RSS file for this user. + err = tweetsToRss(name, *output, tweets) + if err != nil { + fmt.Printf("error: %v\n", err) + exit = 2 + } + } + + os.Exit(exit) +} |
