commit fca89420e456baf1026b3ae74d152263d2dbd934 Author: Zsolt Tasnadi Date: Tue May 12 16:36:17 2026 +0200 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecf0c8f --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# YouTube channel ID (from the channel URL or About page) +YOUTUBE_CHANNEL_ID=UCAVIMKN9_5XthvtVdlmEoHg + +# Facebook Page access token (long-lived, from Graph API Explorer) +FACEBOOK_ACCESS_TOKEN= +# Facebook Page ID (numeric or username) +FACEBOOK_PAGE_ID= + +# LinkedIn OAuth2 access token +LINKEDIN_ACCESS_TOKEN= +# LinkedIn author URN — person: urn:li:person:XXXXX or org: urn:li:organization:XXXXX +LINKEDIN_AUTHOR_URN=urn:li:person:XXXXX + +# Path inside the container where the CSV cache is stored (optional) +CACHE_FILE_PATH=/data/cache.csv diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bc7eb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.claude +data/cache.csv +social-updater diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c2ac87 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o social-updater . + +FROM alpine:latest +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=builder /app/social-updater . +RUN mkdir -p /data +CMD ["./social-updater"] diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf6d18c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + social-updater: + build: . + env_file: + - .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71d793d --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module social-updater + +go 1.26.3 + +require ( + github.com/huandu/facebook/v2 v2.9.1 + github.com/mmcdole/gofeed v1.3.0 +) + +require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + golang.org/x/net v0.4.0 // indirect + golang.org/x/text v0.5.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68e92be --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +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/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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/huandu/facebook/v2 v2.9.1 h1:REo8wQL2Yo/8J29WiiLRf7LAqwHlr72QnxZiEjvpVxE= +github.com/huandu/facebook/v2 v2.9.1/go.mod h1:lk/dUK+JQuXylOhO+b6QtNJNpzo/C4wAasE+YHHZUf4= +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/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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/lib/cache.go b/lib/cache.go new file mode 100644 index 0000000..4cc99db --- /dev/null +++ b/lib/cache.go @@ -0,0 +1,63 @@ +package lib + +import ( + "encoding/csv" + "os" + "strings" +) + +type Cache struct { + filePath string + seen map[string]bool +} + +func NewCache(filePath string) (*Cache, error) { + c := &Cache{ + filePath: filePath, + seen: make(map[string]bool), + } + if err := c.load(); err != nil { + return nil, err + } + return c, nil +} + +func (c *Cache) load() error { + f, err := os.Open(c.filePath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer f.Close() + + records, err := csv.NewReader(f).ReadAll() + if err != nil { + return err + } + for _, record := range records { + if len(record) > 0 { + c.seen[strings.TrimSpace(record[0])] = true + } + } + return nil +} + +func (c *Cache) Has(id string) bool { + return c.seen[id] +} + +func (c *Cache) Add(id string) error { + c.seen[id] = true + + f, err := os.OpenFile(c.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + return w.Write([]string{id}) +} diff --git a/lib/facebook.go b/lib/facebook.go new file mode 100644 index 0000000..9a9dafc --- /dev/null +++ b/lib/facebook.go @@ -0,0 +1,28 @@ +package lib + +import ( + "fmt" + + fb "github.com/huandu/facebook/v2" +) + +type Facebook struct { + accessToken string + pageID string +} + +func NewFacebook(accessToken, pageID string) *Facebook { + return &Facebook{ + accessToken: accessToken, + pageID: pageID, + } +} + +func (f *Facebook) Post(video Video) error { + session := fb.New("", "").Session(f.accessToken) + _, err := session.Post(fmt.Sprintf("/%s/feed", f.pageID), fb.Params{ + "message": video.Title, + "link": video.URL, + }) + return err +} diff --git a/lib/linkedin.go b/lib/linkedin.go new file mode 100644 index 0000000..1aed10f --- /dev/null +++ b/lib/linkedin.go @@ -0,0 +1,70 @@ +package lib + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type Linkedin struct { + accessToken string + authorURN string + client *http.Client +} + +func NewLinkedin(accessToken, authorURN string) *Linkedin { + return &Linkedin{ + accessToken: accessToken, + authorURN: authorURN, + client: &http.Client{}, + } +} + +func (l *Linkedin) Post(video Video) error { + payload := map[string]any{ + "author": l.authorURN, + "lifecycleState": "PUBLISHED", + "specificContent": map[string]any{ + "com.linkedin.ugc.ShareContent": map[string]any{ + "shareCommentary": map[string]any{ + "text": video.Title, + }, + "shareMediaCategory": "ARTICLE", + "media": []map[string]any{ + { + "status": "READY", + "originalUrl": video.URL, + }, + }, + }, + }, + "visibility": map[string]any{ + "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC", + }, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, "https://api.linkedin.com/v2/ugcPosts", bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+l.accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Restli-Protocol-Version", "2.0.0") + + resp, err := l.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("linkedin api error: status %d", resp.StatusCode) + } + return nil +} diff --git a/lib/runner.go b/lib/runner.go new file mode 100644 index 0000000..2251613 --- /dev/null +++ b/lib/runner.go @@ -0,0 +1,80 @@ +package lib + +import ( + "fmt" + "log" + "os" + "time" +) + +type Runner struct { + youtube *Youtube + facebook *Facebook + linkedin *Linkedin + cache *Cache +} + +func NewRunner() (*Runner, error) { + channelID := os.Getenv("YOUTUBE_CHANNEL_ID") + if channelID == "" { + return nil, fmt.Errorf("YOUTUBE_CHANNEL_ID is required") + } + + cacheFile := os.Getenv("CACHE_FILE_PATH") + if cacheFile == "" { + cacheFile = "/data/cache.csv" + } + + cache, err := NewCache(cacheFile) + if err != nil { + return nil, fmt.Errorf("cache init: %w", err) + } + + return &Runner{ + youtube: NewYoutube(channelID), + facebook: NewFacebook(os.Getenv("FACEBOOK_ACCESS_TOKEN"), os.Getenv("FACEBOOK_PAGE_ID")), + linkedin: NewLinkedin(os.Getenv("LINKEDIN_ACCESS_TOKEN"), os.Getenv("LINKEDIN_AUTHOR_URN")), + cache: cache, + }, nil +} + +func (r *Runner) Run() { + r.process() + + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + + for range ticker.C { + r.process() + } +} + +func (r *Runner) process() { + log.Println("checking for new YouTube videos") + + videos, err := r.youtube.FetchLatest() + if err != nil { + log.Printf("youtube fetch error: %v", err) + return + } + + for _, video := range videos { + if r.cache.Has(video.ID) { + continue + } + + log.Printf("new video: %s", video.Title) + + if err := r.facebook.Post(video); err != nil { + log.Printf("facebook post error: %v", err) + } + + if err := r.linkedin.Post(video); err != nil { + log.Printf("linkedin post error: %v", err) + } + + if err := r.cache.Add(video.ID); err != nil { + log.Printf("cache write error: %v", err) + } + } +} diff --git a/lib/youtube.go b/lib/youtube.go new file mode 100644 index 0000000..fb22ecc --- /dev/null +++ b/lib/youtube.go @@ -0,0 +1,43 @@ +package lib + +import ( + "fmt" + + "github.com/mmcdole/gofeed" +) + +type Video struct { + ID string + Title string + URL string +} + +type Youtube struct { + channelID string + parser *gofeed.Parser +} + +func NewYoutube(channelID string) *Youtube { + return &Youtube{ + channelID: channelID, + parser: gofeed.NewParser(), + } +} + +func (y *Youtube) FetchLatest() ([]Video, error) { + feedURL := fmt.Sprintf("https://www.youtube.com/feeds/videos.xml?channel_id=%s", y.channelID) + feed, err := y.parser.ParseURL(feedURL) + if err != nil { + return nil, fmt.Errorf("parse feed: %w", err) + } + + videos := make([]Video, 0, len(feed.Items)) + for _, item := range feed.Items { + videos = append(videos, Video{ + ID: item.GUID, + Title: item.Title, + URL: item.Link, + }) + } + return videos, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bbbb4eb --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "log" + + "social-updater/lib" +) + +func main() { + runner, err := lib.NewRunner() + if err != nil { + log.Fatalf("init failed: %v", err) + } + runner.Run() +}