initial commit
This commit is contained in:
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