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