Working pixelfed & insta posting

Syndicate is working! Instagra & Pixelfed demonstrated with the new post attached.

Lots of TODOs, but functional enough :)
This commit is contained in:
JP Hastings-Spital 2023-11-06 21:38:42 +00:00
parent bf8c6f496a
commit c135910850
37 changed files with 1192 additions and 0 deletions

34
.syndicate Normal file
View file

@ -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"

View file

@ -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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

1
tools/syndicate/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -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{}),
}
}

85
tools/syndicate/config.go Normal file
View file

@ -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
}

81
tools/syndicate/feeds.go Normal file
View file

@ -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
}

37
tools/syndicate/go.mod Normal file
View file

@ -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
)

86
tools/syndicate/go.sum Normal file
View file

@ -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=

90
tools/syndicate/main.go Normal file
View file

@ -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,
)
}
}
}
}

View file

@ -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 ""
}
}

View file

@ -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<username>[a-zA-Z0-9_-]+)/post/(?P<id>[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")
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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)

View file

@ -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<code>[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()
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

@ -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 }

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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<username>@[a-zA-Z0-9_-]+)/(?P<id>[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")
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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<username>[a-zA-Z0-9_-]+)/(?P<id>[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")
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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+)`)
}

View file

@ -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
}

View file

@ -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
}