diff --git a/.syndicate b/.syndicate new file mode 100644 index 00000000..40a920c5 --- /dev/null +++ b/.syndicate @@ -0,0 +1,34 @@ +publish_root = "public/" +publish_url = "https://www.byjp.me/" +feeds = ["index.xml"] +content_glob = "content/**/*.md" +interactions_dir = "data/interactions/" + +[sites] + [sites.instagram] + type = "instagram" + username = "jphastings" + password_envvar = "INSTAGRAM_PASSWORD" + totp_envvar = "INSTAGRAM_TOTP" + + # [sites.bluesky] + # type = "bluesky" + # username = "byjp.me" + # password_envvar = "BLUESKY_PASSWORD" + + [sites.pixelfed] + type = "pixelfed" + server = "https://pixelfed.social" + username = "jphastings" + access_token_envvar = "PIXELFED_ACCESS_TOKEN" + + # [sites.medium] + # type = "medium" + # username = "jphastings" + # password_envvar = "MEDIUM_PASSWORD" + + # [sites.mastodon] + # type = "mastodon" + # server = "https://hachyderm.io" + # username = "byjp" + # password_envvar = "MASTODON_PASSWORD" diff --git a/content/photos/sunny-brunch/index.md b/content/photos/sunny-brunch/index.md new file mode 100644 index 00000000..3cea7597 --- /dev/null +++ b/content/photos/sunny-brunch/index.md @@ -0,0 +1,12 @@ +--- +title: "Sunny Brunch" +media: +- "sunny-brunch.webp" +date: 2023-11-11T11:48:26Z +tags: +syndications: +- https://www.instagram.com/p/Czibda5IQDNvyatQaYIVoAHsALmacvYx_N0Qn40/ +- https://pixelfed.social/p/jphastings/629220605979255535 +--- + +Yvette and I had a delicious brunch at the ever-excellent [Friends of Ours](https://www.friendsofourscafe.com/) earlier, the _Autumnal Toast_ (topped with every roasted autumn veg you can think of) was superb! diff --git a/content/photos/sunny-brunch/sunny-brunch.webp b/content/photos/sunny-brunch/sunny-brunch.webp new file mode 100644 index 00000000..33252d86 Binary files /dev/null and b/content/photos/sunny-brunch/sunny-brunch.webp differ diff --git a/tools/syndicate/.gitignore b/tools/syndicate/.gitignore new file mode 100644 index 00000000..4c49bd78 --- /dev/null +++ b/tools/syndicate/.gitignore @@ -0,0 +1 @@ +.env diff --git a/tools/syndicate/backfeeder/backfeeder.go b/tools/syndicate/backfeeder/backfeeder.go new file mode 100644 index 00000000..0ae36c1e --- /dev/null +++ b/tools/syndicate/backfeeder/backfeeder.go @@ -0,0 +1,15 @@ +package backfeeder + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +type backfeeder struct { + services map[string]shared.Service + done map[string]struct{} +} + +func New(services map[string]shared.Service) *backfeeder { + return &backfeeder{ + services: services, + done: make(map[string]struct{}), + } +} diff --git a/tools/syndicate/config.go b/tools/syndicate/config.go new file mode 100644 index 00000000..d99ffa1b --- /dev/null +++ b/tools/syndicate/config.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "path" + "regexp" + "strings" + + "github.com/BurntSushi/toml" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + + "github.com/bmatcuk/doublestar/v4" + + _ "github.com/by-jp/www.byjp.me/tools/syndicate/services/bluesky" + _ "github.com/by-jp/www.byjp.me/tools/syndicate/services/instagram" + _ "github.com/by-jp/www.byjp.me/tools/syndicate/services/localonly" + _ "github.com/by-jp/www.byjp.me/tools/syndicate/services/mastodon" + _ "github.com/by-jp/www.byjp.me/tools/syndicate/services/medium" +) + +type fileConfig struct { + Feeds []string `toml:"feeds"` + ContentGlob string `toml:"content_glob"` + InteractionsDir string `toml:"interactions_dir"` + Sites map[string]any `toml:"sites"` + PublishRoot string `toml:"publish_root"` + PublishURL string `toml:"publish_url"` +} + +type config struct { + feeds []string + services map[string]shared.Service + interactionsDir string + content []string + tagMatcher *regexp.Regexp + urlToPath func(string) string +} + +func parseConfig(cfgPath string) (*config, error) { + var cfgData fileConfig + _, err := toml.DecodeFile(cfgPath, &cfgData) + if err != nil { + return nil, err + } + + cfg := &config{ + feeds: []string{}, + services: make(map[string]shared.Service), + interactionsDir: cfgData.InteractionsDir, + urlToPath: func(url string) string { + return path.Join(cfgData.PublishRoot, strings.TrimPrefix(url, cfgData.PublishURL)) + }, + } + + cfg.content, err = doublestar.Glob(os.DirFS("."), cfgData.ContentGlob) + if err != nil { + return nil, err + } + + for _, feed := range cfgData.Feeds { + newFeeds, err := doublestar.Glob(os.DirFS(cfgData.PublishRoot), feed) + if err != nil { + return nil, err + } + for _, nf := range newFeeds { + cfg.feeds = append(cfg.feeds, path.Join(cfgData.PublishRoot, nf)) + } + } + + var serviceTags []string + for name, siteConfig := range cfgData.Sites { + svc, err := shared.Load(name, siteConfig) + if err != nil { + return nil, err + } + cfg.services[name] = svc + serviceTags = append(serviceTags, name) + } + cfg.tagMatcher, err = shared.TagMatcher(serviceTags) + if err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/tools/syndicate/feeds.go b/tools/syndicate/feeds.go new file mode 100644 index 00000000..6c9283d2 --- /dev/null +++ b/tools/syndicate/feeds.go @@ -0,0 +1,81 @@ +package main + +import ( + "io" + "os" + "regexp" + "strings" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/mmcdole/gofeed" +) + +type toPostMap map[shared.SyndicationID]shared.Post +type toBackfeedMap map[string]string + +func parseFeed(urlToPath func(string) string, feedReader io.Reader, tagMatcher *regexp.Regexp) ([]string, toPostMap, toBackfeedMap, error) { + fp := gofeed.NewParser() + feed, err := fp.Parse(feedReader) + if err != nil { + return nil, nil, nil, err + } + + toPost := make(toPostMap) + toBackfeed := make(toBackfeedMap) + var services []string + + for _, item := range feed.Items { + if item.Extensions == nil || item.Extensions["dc"] == nil || item.Extensions["dc"]["relation"] == nil { + continue + } + + for _, ext := range item.Extensions["dc"]["relation"] { + syndicateTag := tagMatcher.FindStringSubmatch(ext.Value) + if syndicateTag != nil { + sID := shared.SyndicationID{Source: syndicateTag[1], ID: syndicateTag[2]} + if _, ok := toPost[sID]; ok { + continue + } + toPost[sID], err = itemToPost(item, urlToPath) + services = append(services, sID.Source) + if err != nil { + return nil, nil, nil, err + } + continue + } + + for _, bf := range []regexp.Regexp{} { + if bf.MatchString(ext.Value) { + toBackfeed[ext.Value] = item.Link + // TODO: Login for backfed posts + break + } + } + } + } + + return services, toPost, toBackfeed, nil +} + +func itemToPost(item *gofeed.Item, urlToPath func(string) string) (shared.Post, error) { + p := shared.Post{ + Title: item.Title, + URL: item.Link, + Summary: item.Description, + Content: item.Content, + } + + for _, enc := range item.Enclosures { + if !strings.HasPrefix(enc.Type, "image/") { + continue + } + img, err := os.ReadFile(urlToPath(enc.URL)) + if err != nil { + return p, err + } + + p.Images = append(p.Images, img) + } + + return p, nil +} diff --git a/tools/syndicate/go.mod b/tools/syndicate/go.mod new file mode 100644 index 00000000..acc623c9 --- /dev/null +++ b/tools/syndicate/go.mod @@ -0,0 +1,37 @@ +module github.com/by-jp/www.byjp.me/tools/syndicate + +go 1.21.1 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/Davincible/goinsta/v3 v3.2.6 + github.com/bmatcuk/doublestar/v4 v4.6.1 + github.com/grokify/html-strip-tags-go v0.0.1 + github.com/h2non/filetype v1.1.3 + github.com/mattn/go-mastodon v0.0.6 + github.com/mmcdole/gofeed v1.2.1 + golang.org/x/image v0.13.0 +) + +require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/chromedp/cdproto v0.0.0-20220901095120-1a01299a2163 // indirect + github.com/chromedp/chromedp v0.8.5 // indirect + github.com/chromedp/sysutil v1.0.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.1.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mmcdole/goxpp v1.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.13.0 // indirect +) diff --git a/tools/syndicate/go.sum b/tools/syndicate/go.sum new file mode 100644 index 00000000..a64bf5ca --- /dev/null +++ b/tools/syndicate/go.sum @@ -0,0 +1,86 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8= +github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw= +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/chromedp/cdproto v0.0.0-20220901095120-1a01299a2163 h1:d3i/+z+spo9ieg6L5FWdGmcgvAzsyFNl1vsr68RjzBc= +github.com/chromedp/cdproto v0.0.0-20220901095120-1a01299a2163/go.mod h1:5Y4sD/eXpwrChIuxhSr/G20n9CdbCmoerOHnuAf0Zr0= +github.com/chromedp/chromedp v0.8.5 h1:HAVg54yQFcn7sg5reVjXtoI1eQaFxhjAjflHACicUFw= +github.com/chromedp/chromedp v0.8.5/go.mod h1:xal2XY5Di7m/bzlGwtoYpmgIOfDqCakOIVg5OfdkPZ4= +github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= +github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA= +github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.0.1 h1:0fThFwLbW7P/kOiTBs03FsJSV9RM2M/Q/MOnCQxKMo0= +github.com/grokify/html-strip-tags-go v0.0.1/go.mod h1:2Su6romC5/1VXOQMaWL2yb618ARB8iVo6/DR99A6d78= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-mastodon v0.0.6 h1:lqU1sOeeIapaDsDUL6udDZIzMb2Wqapo347VZlaOzf0= +github.com/mattn/go-mastodon v0.0.6/go.mod h1:cg7RFk2pcUfHZw/IvKe1FUzmlq5KnLFqs7eV2PHplV8= +github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s= +github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4= +github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI= +github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= +golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg= +golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/syndicate/main.go b/tools/syndicate/main.go new file mode 100644 index 00000000..250b25b0 --- /dev/null +++ b/tools/syndicate/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "fmt" + "log" + "os" + "strings" + + "github.com/by-jp/www.byjp.me/tools/syndicate/poster" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func check(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "🛑 %+v\n", err) + // TODO: Remove panic + panic(err) + os.Exit(1) + } +} + +func main() { + check(os.Chdir("../../")) + cfg, err := parseConfig(".syndicate") + check(err) + + pstr := poster.New(cfg.services) + // bkfd := backfeeder.New(cfg.services) + + for _, feed := range cfg.feeds { + f, err := os.Open(feed) + check(err) + services, toPost, _, err := parseFeed(cfg.urlToPath, f, cfg.tagMatcher) + check(err) + + fmt.Fprintf(os.Stderr, "Found %d syndications to complete in %s\n", len(toPost), feed) + fmt.Fprintf(os.Stderr, "Connecting to %s to syndicate…\n", strings.Join(services, ", ")) + for _, sname := range services { + if err := pstr.Connect(sname); err != nil { + check(fmt.Errorf("couldn't connect to %s: %w", sname, err)) + } + } + + for k, p := range toPost { + if err := pstr.Post(k, p); err == nil { + fmt.Printf("Posted '%s' to %s: %s\n", p.Title, k.Source, pstr.PostedURL(k)) + } else { + fmt.Fprintf(os.Stderr, "Couldn't post %s to %s: %v\n", p.URL, k.Source, err) + } + } + + for _, fname := range cfg.content { + f, err := os.ReadFile(fname) + if err != nil { + fmt.Fprintf(os.Stderr, "Couldn't read %s: %v\n", fname, err) + continue + } + + tags := cfg.tagMatcher.FindAllSubmatch(f, -1) + if tags == nil { + continue + } + + var urls []string + for _, tag := range tags { + sid := shared.SyndicationID{Source: string(tag[1]), ID: string(tag[2])} + if url := pstr.PostedURL(sid); url != "" { + urls = append(urls, url) + f = bytes.ReplaceAll(f, sid.Bytes(), []byte(url)) + log.Printf("Replacing syndication tag '%s' with post URL: %s", sid, url) + } + } + + if len(urls) == 0 { + continue + } + + if err := os.WriteFile(fname, f, 0644); err != nil { + fmt.Fprintf( + os.Stderr, + "Couldn't insert posted URLs (%s) into %s: %v\n", + strings.Join(urls, ", "), + fname, + err, + ) + } + } + } +} diff --git a/tools/syndicate/poster/poster.go b/tools/syndicate/poster/poster.go new file mode 100644 index 00000000..6ff58871 --- /dev/null +++ b/tools/syndicate/poster/poster.go @@ -0,0 +1,44 @@ +package poster + +import ( + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +type poster struct { + services map[string]shared.Service + done map[shared.SyndicationID]string +} + +func New(services map[string]shared.Service) *poster { + return &poster{ + services: services, + done: make(map[shared.SyndicationID]string), + } +} + +func (p *poster) Connect(sname string) error { + return p.services[sname].Connect(false) +} + +func (p *poster) Post(sid shared.SyndicationID, post shared.Post) error { + if _, ok := p.done[sid]; ok { + return nil + } + + url, err := p.services[sid.Source].Post(post) + if err != nil { + return err + } + + p.done[sid] = url + + return nil +} + +func (p *poster) PostedURL(sid shared.SyndicationID) string { + if url, ok := p.done[sid]; ok { + return url + } else { + return "" + } +} diff --git a/tools/syndicate/services/bluesky/init.go b/tools/syndicate/services/bluesky/init.go new file mode 100644 index 00000000..fcc50063 --- /dev/null +++ b/tools/syndicate/services/bluesky/init.go @@ -0,0 +1,28 @@ +package bluesky + +import ( + "fmt" + "regexp" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func init() { + shared.Register("bluesky", New) +} + +func New(config map[string]any) (shared.Service, error) { + return &service{}, nil +} + +func (s *service) BackfeedMatcher() (*regexp.Regexp, error) { + return regexp.Compile(`https://bsky.app/profile/(?P[a-zA-Z0-9_-]+)/post/(?P[a-zA-Z0-9_-]+)/`) +} + +func (s *service) Connect(force bool) error { + return fmt.Errorf("not implemented") +} + +func (s *service) Close() error { + return fmt.Errorf("not implemented") +} diff --git a/tools/syndicate/services/bluesky/interactions.go b/tools/syndicate/services/bluesky/interactions.go new file mode 100644 index 00000000..413da91a --- /dev/null +++ b/tools/syndicate/services/bluesky/interactions.go @@ -0,0 +1,7 @@ +package bluesky + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +func (s *service) Interactions(url string) ([]shared.Interaction, error) { + return nil, nil +} diff --git a/tools/syndicate/services/bluesky/post.go b/tools/syndicate/services/bluesky/post.go new file mode 100644 index 00000000..69ed52e0 --- /dev/null +++ b/tools/syndicate/services/bluesky/post.go @@ -0,0 +1,11 @@ +package bluesky + +import ( + "fmt" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func (s *service) Post(p shared.Post) (string, error) { + return "", fmt.Errorf("not implemented") +} diff --git a/tools/syndicate/services/bluesky/types.go b/tools/syndicate/services/bluesky/types.go new file mode 100644 index 00000000..37833f46 --- /dev/null +++ b/tools/syndicate/services/bluesky/types.go @@ -0,0 +1,14 @@ +package bluesky + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +type Config struct { + Username string + Password string +} + +type service struct { + configPath string +} + +var _ shared.Service = (*service)(nil) diff --git a/tools/syndicate/services/instagram/init.go b/tools/syndicate/services/instagram/init.go new file mode 100644 index 00000000..77a62671 --- /dev/null +++ b/tools/syndicate/services/instagram/init.go @@ -0,0 +1,89 @@ +package instagram + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/Davincible/goinsta/v3" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/config" +) + +func init() { + shared.Register("instagram", New) +} + +func New(cfg map[string]any) (shared.Service, error) { + cfgMap, err := config.GetStrings(cfg, "username", "password_envvar", "totp_envvar") + if err != nil { + return nil, err + } + + svc := &service{ + // TODO: Real dir for .goinsta + configPath: filepath.Join("/tmp", ".goinsta"), + username: cfgMap["username"], + password: cfgMap["password"], + totp: cfgMap["totp"], + } + + if svc.username == "" { + return nil, fmt.Errorf("missing Instagram username") + } + + return svc, nil +} + +func (s *service) BackfeedMatcher() (*regexp.Regexp, error) { + return regexp.Compile(`https://www.instagram.com/p/(?P[a-zA-Z0-9_-]+)/`) +} + +func (s *service) Connect(force bool) error { + if s.insta != nil && !force { + return nil + } + + var err error + s.insta, err = authenticate( + s.configPath, + s.username, + s.password, + s.totp, + ) + + return err +} + +func (s *service) Close() error { + return s.insta.Export(s.configPath) +} + +func authenticate(config, username, password, totp string) (*goinsta.Instagram, error) { + _, err := os.Stat(config) + if err == nil { + insta, err := goinsta.Import(config) + if err != nil { + return nil, err + } + + return insta, insta.OpenApp() + } + + if !os.IsNotExist(err) { + return nil, err + } + + insta := goinsta.New(username, password, totp) + err = insta.Login() + if err == nil { + return insta, nil + } + + if err != goinsta.Err2FARequired { + return nil, err + } + + return insta, insta.TwoFactorInfo.Login2FA() +} diff --git a/tools/syndicate/services/instagram/interactions.go b/tools/syndicate/services/instagram/interactions.go new file mode 100644 index 00000000..62e4cadd --- /dev/null +++ b/tools/syndicate/services/instagram/interactions.go @@ -0,0 +1,7 @@ +package instagram + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +func (s *service) Interactions(url string) ([]shared.Interaction, error) { + return nil, nil +} diff --git a/tools/syndicate/services/instagram/post.go b/tools/syndicate/services/instagram/post.go new file mode 100644 index 00000000..5677df84 --- /dev/null +++ b/tools/syndicate/services/instagram/post.go @@ -0,0 +1,41 @@ +package instagram + +import ( + "fmt" + + "github.com/Davincible/goinsta/v3" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/images" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/text" +) + +func (s *service) Post(p shared.Post) (string, error) { + if len(p.Images) == 0 { + return "", fmt.Errorf("no images to post to instagram for %s", p.URL) + } + + jpgIOs, err := images.ToJPEGs(p.Images) + if err != nil { + return "", fmt.Errorf("failed to convert images to JPEGs: %w", err) + } + + upload := &goinsta.UploadOptions{ + Caption: text.Caption(p), + } + if len(jpgIOs) == 1 { + upload.File = jpgIOs[0] + } else { + upload.Album = jpgIOs + } + + item, err := s.insta.Upload(upload) + if err != nil { + return "", err + } + + return postURL(item), nil +} + +func postURL(item *goinsta.Item) string { + return fmt.Sprintf("https://www.instagram.com/p/%s/", item.Code) +} diff --git a/tools/syndicate/services/instagram/types.go b/tools/syndicate/services/instagram/types.go new file mode 100644 index 00000000..d7ee2961 --- /dev/null +++ b/tools/syndicate/services/instagram/types.go @@ -0,0 +1,16 @@ +package instagram + +import ( + "github.com/Davincible/goinsta/v3" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +type service struct { + insta *goinsta.Instagram + configPath string + username string + password string + totp string +} + +var _ shared.Service = (*service)(nil) diff --git a/tools/syndicate/services/localonly/init.go b/tools/syndicate/services/localonly/init.go new file mode 100644 index 00000000..6c3b435f --- /dev/null +++ b/tools/syndicate/services/localonly/init.go @@ -0,0 +1,58 @@ +package localonly + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func init() { + shared.Register("localonly", New) +} + +func New(cfg map[string]any) (shared.Service, error) { + dir, ok := cfg["dir"].(string) + if !ok { + return nil, fmt.Errorf("missing 'dir' (the directory to store local only posts)") + } + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("unable to get absolute path for 'dir' %s: %w", dir, err) + } + svc := &service{dir: absDir} + + s, err := os.Stat(svc.dir) + if os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("unable to create 'dir' %s: %w", svc.dir, err) + } + + return svc, nil + } else if err != nil { + return nil, fmt.Errorf("invalid 'dir': %w", err) + } else if !s.IsDir() { + return nil, fmt.Errorf("invalid 'dir' %s: is not a directory", svc.dir) + } + + if erase, ok := cfg["erase"].(bool); ok && erase { + if err := os.RemoveAll(dir); err != nil { + return nil, fmt.Errorf("unable to erase 'dir' %s: %w", svc.dir, err) + } + + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("unable to recreate 'dir' %s: %w", svc.dir, err) + } + } + + return svc, nil +} + +func (s *service) BackfeedMatcher() (*regexp.Regexp, error) { + return regexp.Compile(`file://` + s.dir + `/(.*)`) +} + +func (s *service) Connect(bool) error { return nil } +func (s *service) Close() error { return nil } diff --git a/tools/syndicate/services/localonly/interactions.go b/tools/syndicate/services/localonly/interactions.go new file mode 100644 index 00000000..eefebc07 --- /dev/null +++ b/tools/syndicate/services/localonly/interactions.go @@ -0,0 +1,7 @@ +package localonly + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +func (s *service) Interactions(url string) ([]shared.Interaction, error) { + return nil, nil +} diff --git a/tools/syndicate/services/localonly/post.go b/tools/syndicate/services/localonly/post.go new file mode 100644 index 00000000..8f1aef3d --- /dev/null +++ b/tools/syndicate/services/localonly/post.go @@ -0,0 +1,52 @@ +package localonly + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/text" + + "github.com/h2non/filetype" +) + +var kebab = regexp.MustCompile(`[^a-z0-9]+`) + +func (s *service) Post(p shared.Post) (string, error) { + id := kebab.ReplaceAllString(strings.ToLower(p.Title), "-") + dir := filepath.Join(s.dir, id) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("unable to save post: %w", err) + } + + var data string + for i, img := range p.Images { + ft, err := filetype.Match(img) + if err != nil { + return "", fmt.Errorf("unable to determine image type: %w", err) + } + ext := ft.Extension + imgName := fmt.Sprintf("%03d.%s", i+1, ext) + if err := os.WriteFile(filepath.Join(dir, imgName), img, 0644); err != nil { + return "", fmt.Errorf("unable to save post image %d: %w", i, err) + } + + if len(data) > 0 { + data = data + "\n" + } + data = data + "![](" + imgName + ")" + } + + if len(data) > 0 { + data = data + "\n\n" + } + data = data + text.Caption(p) + if err := os.WriteFile(filepath.Join(dir, "index.md"), []byte(data), 0644); err != nil { + return "", fmt.Errorf("unable to save post: %w", err) + } + + return "file://" + dir + "/", nil +} diff --git a/tools/syndicate/services/localonly/types.go b/tools/syndicate/services/localonly/types.go new file mode 100644 index 00000000..b57f4af9 --- /dev/null +++ b/tools/syndicate/services/localonly/types.go @@ -0,0 +1,9 @@ +package localonly + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +type service struct { + dir string +} + +var _ shared.Service = (*service)(nil) diff --git a/tools/syndicate/services/mastodon/init.go b/tools/syndicate/services/mastodon/init.go new file mode 100644 index 00000000..3f6f8f55 --- /dev/null +++ b/tools/syndicate/services/mastodon/init.go @@ -0,0 +1,63 @@ +package mastodon + +import ( + "context" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/config" + "github.com/mattn/go-mastodon" +) + +func init() { + shared.Register("mastodon", New) + shared.Register("pixelfed", New) +} + +func New(cfg map[string]any) (shared.Service, error) { + cfgMap, err := config.GetStrings(cfg, "server", "username", "access_token_envvar") + if err != nil { + return nil, err + } + + u, err := url.Parse(cfgMap["server"]) + if err != nil { + return nil, err + } + + srv := &service{ + masto: mastodon.NewClient(&mastodon.Config{ + Server: u.String(), + AccessToken: cfgMap["access_token"], + }), + username: cfgMap["username"], + } + + return srv, nil +} + +func (s *service) BackfeedMatcher() (*regexp.Regexp, error) { + url := s.masto.Config.Server + if !strings.HasSuffix(url, "/") { + url += "/" + } + + return regexp.Compile( + regexp.QuoteMeta(url) + + `(?P@[a-zA-Z0-9_-]+)/(?P[0-9]+)/`, + ) +} + +func (s *service) Connect(force bool) error { + if s.masto.Config.AccessToken != "" && !force { + return nil + } + return s.masto.Authenticate(context.Background(), s.username, s.password) +} + +func (s *service) Close() error { + return fmt.Errorf("not implemented") +} diff --git a/tools/syndicate/services/mastodon/interactions.go b/tools/syndicate/services/mastodon/interactions.go new file mode 100644 index 00000000..4480bfd1 --- /dev/null +++ b/tools/syndicate/services/mastodon/interactions.go @@ -0,0 +1,7 @@ +package mastodon + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +func (s *service) Interactions(url string) ([]shared.Interaction, error) { + return nil, nil +} diff --git a/tools/syndicate/services/mastodon/post.go b/tools/syndicate/services/mastodon/post.go new file mode 100644 index 00000000..58958851 --- /dev/null +++ b/tools/syndicate/services/mastodon/post.go @@ -0,0 +1,39 @@ +package mastodon + +import ( + "context" + "fmt" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/images" + "github.com/by-jp/www.byjp.me/tools/syndicate/shared/text" + "github.com/mattn/go-mastodon" +) + +func (s *service) Post(p shared.Post) (string, error) { + ctx := context.Background() + + jpgIOs, err := images.ToJPEGs(p.Images) + if err != nil { + return "", fmt.Errorf("failed to convert images to JPEGs: %w", err) + } + + var mediaIDs []mastodon.ID + for _, img := range jpgIOs { + a, err := s.masto.UploadMediaFromReader(ctx, img) + if err != nil { + return "", fmt.Errorf("couldn't upload image: %w", err) + } + mediaIDs = append(mediaIDs, a.ID) + } + + post, err := s.masto.PostStatus(ctx, &mastodon.Toot{ + Status: text.Caption(p), + MediaIDs: mediaIDs, + }) + if err != nil { + return "", fmt.Errorf("couldn't post status: %w", err) + } + + return post.URL, nil +} diff --git a/tools/syndicate/services/mastodon/types.go b/tools/syndicate/services/mastodon/types.go new file mode 100644 index 00000000..1b4dbf78 --- /dev/null +++ b/tools/syndicate/services/mastodon/types.go @@ -0,0 +1,14 @@ +package mastodon + +import ( + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + "github.com/mattn/go-mastodon" +) + +type service struct { + masto *mastodon.Client + username string + password string +} + +var _ shared.Service = (*service)(nil) diff --git a/tools/syndicate/services/medium/init.go b/tools/syndicate/services/medium/init.go new file mode 100644 index 00000000..a3ddec14 --- /dev/null +++ b/tools/syndicate/services/medium/init.go @@ -0,0 +1,28 @@ +package medium + +import ( + "fmt" + "regexp" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func init() { + shared.Register("medium", New) +} + +func New(config map[string]any) (shared.Service, error) { + return &service{}, nil +} + +func (s *service) BackfeedMatcher() (*regexp.Regexp, error) { + return regexp.Compile(`https://medium.com/(?P[a-zA-Z0-9_-]+)/(?P[a-zA-Z0-9_-]+)/`) +} + +func (s *service) Connect(force bool) error { + return fmt.Errorf("not implemented") +} + +func (s *service) Close() error { + return fmt.Errorf("not implemented") +} diff --git a/tools/syndicate/services/medium/interactions.go b/tools/syndicate/services/medium/interactions.go new file mode 100644 index 00000000..dc6badea --- /dev/null +++ b/tools/syndicate/services/medium/interactions.go @@ -0,0 +1,7 @@ +package medium + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +func (s *service) Interactions(url string) ([]shared.Interaction, error) { + return nil, nil +} diff --git a/tools/syndicate/services/medium/post.go b/tools/syndicate/services/medium/post.go new file mode 100644 index 00000000..7b86499f --- /dev/null +++ b/tools/syndicate/services/medium/post.go @@ -0,0 +1,11 @@ +package medium + +import ( + "fmt" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" +) + +func (s *service) Post(p shared.Post) (string, error) { + return "", fmt.Errorf("not implemented") +} diff --git a/tools/syndicate/services/medium/types.go b/tools/syndicate/services/medium/types.go new file mode 100644 index 00000000..b283f78f --- /dev/null +++ b/tools/syndicate/services/medium/types.go @@ -0,0 +1,13 @@ +package medium + +import "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + +type Config struct { + Username string + Password string +} + +type service struct { +} + +var _ shared.Service = (*service)(nil) diff --git a/tools/syndicate/shared/config/config.go b/tools/syndicate/shared/config/config.go new file mode 100644 index 00000000..502f80f5 --- /dev/null +++ b/tools/syndicate/shared/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +func GetStrings(config map[string]any, fields ...string) (map[string]string, error) { + out := make(map[string]string) + for _, field := range fields { + name := field + val, ok := config[field].(string) + if !ok || val == "" { + return nil, fmt.Errorf("missing '%s' config field", field) + } + + if strings.HasSuffix(field, "_envvar") { + name = strings.TrimSuffix(field, "_envvar") + envVal := val + if val, ok = os.LookupEnv(envVal); !ok { + return nil, fmt.Errorf("missing '%s' environment variable (for '%s' config field)", envVal, field) + } + } + + if val == "" { + return nil, fmt.Errorf("empty '%s' config field", field) + } + + out[name] = val + } + return out, nil +} diff --git a/tools/syndicate/shared/images/images.go b/tools/syndicate/shared/images/images.go new file mode 100644 index 00000000..84c474f1 --- /dev/null +++ b/tools/syndicate/shared/images/images.go @@ -0,0 +1,36 @@ +package images + +import ( + "bytes" + "image" + "image/jpeg" + _ "image/png" + "io" + + _ "golang.org/x/image/webp" +) + +func ToJPEG(imgData []byte) (io.Reader, error) { + img, _, err := image.Decode(bytes.NewReader(imgData)) + if err != nil { + return nil, err + } + + var b bytes.Buffer + if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 100}); err != nil { + return nil, err + } + return &b, nil +} + +func ToJPEGs(imgsData [][]byte) ([]io.Reader, error) { + jpgs := make([]io.Reader, len(imgsData)) + for i, img := range imgsData { + var err error + jpgs[i], err = ToJPEG(img) + if err != nil { + return nil, err + } + } + return jpgs, nil +} diff --git a/tools/syndicate/shared/services.go b/tools/syndicate/shared/services.go new file mode 100644 index 00000000..8696d494 --- /dev/null +++ b/tools/syndicate/shared/services.go @@ -0,0 +1,37 @@ +package shared + +import ( + "fmt" +) + +var serviceTypes = make(map[string]ServiceCreator) + +func Register(name string, svc ServiceCreator) { + serviceTypes[name] = svc +} + +func Load(name string, config any) (Service, error) { + cfgMap, ok := config.(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid config for '%s' service", name) + } + + var serviceType string + if t, ok := cfgMap["type"].(string); ok { + serviceType = t + } else { + serviceType = name + } + + svc, ok := serviceTypes[serviceType] + if !ok { + return nil, fmt.Errorf("unknown service type: %s", serviceType) + } + + instance, err := svc(cfgMap) + if err != nil { + return nil, fmt.Errorf("unable to create '%s' service: %w", name, err) + } + + return instance, nil +} diff --git a/tools/syndicate/shared/syndicationid.go b/tools/syndicate/shared/syndicationid.go new file mode 100644 index 00000000..d92d3197 --- /dev/null +++ b/tools/syndicate/shared/syndicationid.go @@ -0,0 +1,23 @@ +package shared + +import ( + "regexp" + "strings" +) + +type SyndicationID struct { + Source string + ID string +} + +func (sid SyndicationID) String() string { + return "syndicate:" + sid.Source + ":" + sid.ID +} + +func (sid SyndicationID) Bytes() []byte { + return []byte(sid.String()) +} + +func TagMatcher(serviceTags []string) (*regexp.Regexp, error) { + return regexp.Compile("syndicate:(" + strings.Join(serviceTags, "|") + `):(\S+)`) +} diff --git a/tools/syndicate/shared/text/strip.go b/tools/syndicate/shared/text/strip.go new file mode 100644 index 00000000..a4c6fa2a --- /dev/null +++ b/tools/syndicate/shared/text/strip.go @@ -0,0 +1,16 @@ +package text + +import ( + "strings" + + "github.com/by-jp/www.byjp.me/tools/syndicate/shared" + strip "github.com/grokify/html-strip-tags-go" +) + +func StripHTML(s string) string { + return strings.TrimSpace(strip.StripTags(s)) +} + +func Caption(p shared.Post) string { + return p.Title + " · " + StripHTML(p.Summary) + "\n·\nOriginal: " + p.URL +} diff --git a/tools/syndicate/shared/types.go b/tools/syndicate/shared/types.go new file mode 100644 index 00000000..c92bf8d0 --- /dev/null +++ b/tools/syndicate/shared/types.go @@ -0,0 +1,41 @@ +package shared + +import ( + "regexp" + "time" +) + +type ServiceCreator func(map[string]any) (Service, error) + +type Service interface { + BackfeedMatcher() (*regexp.Regexp, error) + Connect(force bool) error + Post(Post) (url string, err error) + Interactions(url string) ([]Interaction, error) + Close() error +} + +type Post struct { + ServiceType string + Title string + Summary string + Content string + URL string + Images [][]byte +} + +type Interaction struct { + // eg. Repost is 🔁, Facebook is 👍, Instagram is ♥️, Mastodon is ⭐️, Medium is 👏 + Emoji string + // The URL of the original interaction + URL string + // If there's a comment associated with the interaction + Comment string + // Details of the author + Author struct { + Name string + URL string + Icon []byte + } + Timestamp time.Time +}