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

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
}