initial commit
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.claude
|
||||
data/cache.csv
|
||||
social-updater
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
0
data/.gitkeep
Normal file
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
social-updater:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
19
go.mod
Normal file
19
go.mod
Normal 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
39
go.sum
Normal 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
63
lib/cache.go
Normal 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
28
lib/facebook.go
Normal 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
70
lib/linkedin.go
Normal 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
80
lib/runner.go
Normal 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
43
lib/youtube.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user