mirror of
https://github.com/by-jp/www.byjp.me.git
synced 2025-08-09 22:16:07 +01:00
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:
parent
bf8c6f496a
commit
c135910850
37 changed files with 1192 additions and 0 deletions
34
.syndicate
Normal file
34
.syndicate
Normal 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"
|
12
content/photos/sunny-brunch/index.md
Normal file
12
content/photos/sunny-brunch/index.md
Normal 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!
|
BIN
content/photos/sunny-brunch/sunny-brunch.webp
Normal file
BIN
content/photos/sunny-brunch/sunny-brunch.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 501 KiB |
1
tools/syndicate/.gitignore
vendored
Normal file
1
tools/syndicate/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.env
|
15
tools/syndicate/backfeeder/backfeeder.go
Normal file
15
tools/syndicate/backfeeder/backfeeder.go
Normal 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
85
tools/syndicate/config.go
Normal 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
81
tools/syndicate/feeds.go
Normal 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
37
tools/syndicate/go.mod
Normal 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
86
tools/syndicate/go.sum
Normal 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
90
tools/syndicate/main.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
44
tools/syndicate/poster/poster.go
Normal file
44
tools/syndicate/poster/poster.go
Normal 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 ""
|
||||
}
|
||||
}
|
28
tools/syndicate/services/bluesky/init.go
Normal file
28
tools/syndicate/services/bluesky/init.go
Normal 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")
|
||||
}
|
7
tools/syndicate/services/bluesky/interactions.go
Normal file
7
tools/syndicate/services/bluesky/interactions.go
Normal 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
|
||||
}
|
11
tools/syndicate/services/bluesky/post.go
Normal file
11
tools/syndicate/services/bluesky/post.go
Normal 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")
|
||||
}
|
14
tools/syndicate/services/bluesky/types.go
Normal file
14
tools/syndicate/services/bluesky/types.go
Normal 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)
|
89
tools/syndicate/services/instagram/init.go
Normal file
89
tools/syndicate/services/instagram/init.go
Normal 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()
|
||||
}
|
7
tools/syndicate/services/instagram/interactions.go
Normal file
7
tools/syndicate/services/instagram/interactions.go
Normal 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
|
||||
}
|
41
tools/syndicate/services/instagram/post.go
Normal file
41
tools/syndicate/services/instagram/post.go
Normal 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)
|
||||
}
|
16
tools/syndicate/services/instagram/types.go
Normal file
16
tools/syndicate/services/instagram/types.go
Normal 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)
|
58
tools/syndicate/services/localonly/init.go
Normal file
58
tools/syndicate/services/localonly/init.go
Normal 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 }
|
7
tools/syndicate/services/localonly/interactions.go
Normal file
7
tools/syndicate/services/localonly/interactions.go
Normal 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
|
||||
}
|
52
tools/syndicate/services/localonly/post.go
Normal file
52
tools/syndicate/services/localonly/post.go
Normal 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 + ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
9
tools/syndicate/services/localonly/types.go
Normal file
9
tools/syndicate/services/localonly/types.go
Normal 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)
|
63
tools/syndicate/services/mastodon/init.go
Normal file
63
tools/syndicate/services/mastodon/init.go
Normal 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")
|
||||
}
|
7
tools/syndicate/services/mastodon/interactions.go
Normal file
7
tools/syndicate/services/mastodon/interactions.go
Normal 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
|
||||
}
|
39
tools/syndicate/services/mastodon/post.go
Normal file
39
tools/syndicate/services/mastodon/post.go
Normal 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
|
||||
}
|
14
tools/syndicate/services/mastodon/types.go
Normal file
14
tools/syndicate/services/mastodon/types.go
Normal 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)
|
28
tools/syndicate/services/medium/init.go
Normal file
28
tools/syndicate/services/medium/init.go
Normal 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")
|
||||
}
|
7
tools/syndicate/services/medium/interactions.go
Normal file
7
tools/syndicate/services/medium/interactions.go
Normal 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
|
||||
}
|
11
tools/syndicate/services/medium/post.go
Normal file
11
tools/syndicate/services/medium/post.go
Normal 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")
|
||||
}
|
13
tools/syndicate/services/medium/types.go
Normal file
13
tools/syndicate/services/medium/types.go
Normal 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)
|
33
tools/syndicate/shared/config/config.go
Normal file
33
tools/syndicate/shared/config/config.go
Normal 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
|
||||
}
|
36
tools/syndicate/shared/images/images.go
Normal file
36
tools/syndicate/shared/images/images.go
Normal 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
|
||||
}
|
37
tools/syndicate/shared/services.go
Normal file
37
tools/syndicate/shared/services.go
Normal 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
|
||||
}
|
23
tools/syndicate/shared/syndicationid.go
Normal file
23
tools/syndicate/shared/syndicationid.go
Normal 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+)`)
|
||||
}
|
16
tools/syndicate/shared/text/strip.go
Normal file
16
tools/syndicate/shared/text/strip.go
Normal 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
|
||||
}
|
41
tools/syndicate/shared/types.go
Normal file
41
tools/syndicate/shared/types.go
Normal 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
|
||||
}
|
Loading…
Reference in a new issue