initial commit

This commit is contained in:
2026-05-12 16:36:17 +02:00
commit fca89420e4
13 changed files with 397 additions and 0 deletions

15
.env.example Normal file
View File

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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
.claude
data/cache.csv
social-updater

13
Dockerfile Normal file
View File

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

0
data/.gitkeep Normal file
View File

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
social-updater:
build: .
env_file:
- .env
volumes:
- ./data:/data
restart: unless-stopped

19
go.mod Normal file
View File

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

39
go.sum Normal file
View File

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

63
lib/cache.go Normal file
View File

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

28
lib/facebook.go Normal file
View File

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

70
lib/linkedin.go Normal file
View File

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

80
lib/runner.go Normal file
View File

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

43
lib/youtube.go Normal file
View File

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

15
main.go Normal file
View File

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