diff options
| author | Paul Buetow <paul@buetow.org> | 2024-11-10 11:55:04 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2024-11-10 11:55:04 +0200 |
| commit | 103b800fa849f0565b4eaa38dee054379b96cd57 (patch) | |
| tree | b2ca4aaa99225e202d1575ea3d5bab5a82e1dd1f | |
| parent | ad6a9f11b728ddce21953a3a56cb90c4e3bd40f2 (diff) | |
can post thumbnails to linkedin
| -rw-r--r-- | internal/platforms/linkedin/linkedin.go | 135 | ||||
| -rw-r--r-- | internal/platforms/linkedin/preview.go | 55 |
2 files changed, 153 insertions, 37 deletions
diff --git a/internal/platforms/linkedin/linkedin.go b/internal/platforms/linkedin/linkedin.go index 283ab14..b8790dd 100644 --- a/internal/platforms/linkedin/linkedin.go +++ b/internal/platforms/linkedin/linkedin.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" "time" "codeberg.org/snonux/gos/internal/colour" @@ -19,10 +20,7 @@ import ( var errUnauthorized = errors.New("unauthorized access, refresh or create token?") -const ( - linkedInPostsURL = "https://api.linkedin.com/rest/posts" - linkedInTimeout = 10 * time.Second -) +const linkedInTimeout = 10 * time.Second func Post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) error { err := post(ctx, args, sizeLimit, en) @@ -53,19 +51,12 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) newCtx, cancel = context.WithTimeout(ctx, linkedInTimeout) defer cancel() - prev, err := NewPreview(newCtx, urls) + + prev, err := NewPreview(newCtx, args, urls) if err != nil { return err } - var filePath string - if prev.imageURL != "" { - if filePath, err = prev.DownloadImage(args.CacheDir); err != nil { - return err - } - colour.Infoln("Downloaded preview image to ", filePath) - } - question := fmt.Sprintf("Do you want to post this message to Linkedin (%v)?", prev) if err := prompt.FileAction(question, content, en.Path); err != nil { return err @@ -73,13 +64,16 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) newCtx, cancel = context.WithTimeout(ctx, linkedInTimeout) defer cancel() - return callLinkedInAPI(newCtx, personID, accessToken, content, prev) + return postMessageToLinkedInAPI(newCtx, personID, accessToken, content, prev) } -// TODO: Also post preview images -func callLinkedInAPI(ctx context.Context, personID, accessToken, content string, prev preview) error { +// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api +func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, content string, prev preview) error { + const linkedInPostsURL = "https://api.linkedin.com/rest/posts" + + personURN := fmt.Sprintf("urn:li:person:%s", personID) post := map[string]interface{}{ - "author": fmt.Sprintf("urn:li:person:%s", personID), + "author": personURN, "commentary": escapeLinkedInText(content), "visibility": "PUBLIC", "distribution": map[string]interface{}{ @@ -91,11 +85,20 @@ func callLinkedInAPI(ctx context.Context, personID, accessToken, content string, "isReshareDisabledByAuthor": false, } - if !prev.Empty() { + var thumbnailURN string + if thumbnailPath, ok := prev.Thumbnail(); ok { + imageURN, err := postImageToLinkedInAPI(ctx, personURN, accessToken, thumbnailPath) + if err != nil { + return err + } + thumbnailURN = imageURN + } + if title, url, ok := prev.TitleAndURL(); ok { post["content"] = map[string]interface{}{ "article": map[string]interface{}{ - "title": prev.title, - "source": prev.url, + "title": title, + "source": url, + "thumbnail": thumbnailURN, }, } } @@ -135,3 +138,95 @@ func callLinkedInAPI(ctx context.Context, personID, accessToken, content string, } return err } + +// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api +func postImageToLinkedInAPI(ctx context.Context, personURN, accessToken, imagePath string) (string, error) { + uploadURL, imageURN, err := initializeImageUpload(ctx, personURN, accessToken) + if err != nil { + return imageURN, err + } + return imageURN, uploadImage(ctx, imagePath, uploadURL, accessToken) +} + +func initializeImageUpload(ctx context.Context, personURN, accessToken string) (string, string, error) { + const linkedInAPIURL = "https://api.linkedin.com/rest/images?action=initializeUpload" + + type InitializeUploadRequest struct { + Owner string `json:"owner"` + } + requestBody, err := json.Marshal(map[string]interface{}{ + "initializeUploadRequest": InitializeUploadRequest{Owner: personURN}, + }) + + if err != nil { + return "", "", fmt.Errorf("error creating request body: %w", err) + } + + // Initialize image upload + req, err := http.NewRequestWithContext(ctx, "POST", linkedInAPIURL, bytes.NewBuffer(requestBody)) + if err != nil { + return "", "", fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Add("LinkedIn-Version", "202409") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("error sending request: %w", err) + } + defer resp.Body.Close() + + type InitializeUploadResponse struct { + Value struct { + UploadURL string `json:"uploadUrl"` + Image string `json:"image"` + } `json:"value"` + } + var response InitializeUploadResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return "", "", fmt.Errorf("error decoding response: %w", err) + } + + return response.Value.UploadURL, response.Value.Image, nil +} + +func uploadImage(ctx context.Context, imagePath, uploadURL, accessToken string) error { + file, err := os.Open(imagePath) + if err != nil { + return err + } + defer file.Close() + + imageData, err := io.ReadAll(file) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "PUT", uploadURL, bytes.NewBuffer(imageData)) + if err != nil { + return fmt.Errorf("error creating upload request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/octet-stream") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error sending upload request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + colour.Infoln(string(body)) + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed with status %s: %s", resp.Status, string(body)) + } + + return nil +} diff --git a/internal/platforms/linkedin/preview.go b/internal/platforms/linkedin/preview.go index 6a16536..41b99a5 100644 --- a/internal/platforms/linkedin/preview.go +++ b/internal/platforms/linkedin/preview.go @@ -11,6 +11,7 @@ import ( "path/filepath" "codeberg.org/snonux/gos/internal/colour" + "codeberg.org/snonux/gos/internal/config" "codeberg.org/snonux/gos/internal/oi" "golang.org/x/net/html" ) @@ -21,41 +22,61 @@ var ( ) type preview struct { - title, imageURL, url string + title, thumbnailURL, thumbnailDownloadPath, url string } -func NewPreview(ctx context.Context, urls []string) (preview, error) { +func NewPreview(ctx context.Context, args config.Args, urls []string) (preview, error) { + var ( + p preview + err error + ) if len(urls) == 0 { - return preview{}, nil + return p, nil } - title, imageURL, err := extractFromURL(ctx, urls[0]) - if errors.Is(err, errNoTitleElementFound) || title == "" { - colour.Infoln("Setting title to", urls[0]) - title = urls[0] + p.url = urls[0] + + if p.title, p.thumbnailURL, err = extractFromURL(ctx, urls[0]); err != nil { + if errors.Is(err, errNoTitleElementFound) || p.title == "" { + colour.Infoln("Setting title to", urls[0]) + p.title = urls[0] + } + if errors.Is(err, errNoImageElementFound) { + colour.Infoln("URL", urls[0], "without any image, that's fine, though.") + } + if !errors.Is(err, errNoTitleElementFound) && !errors.Is(err, errNoImageElementFound) { + return p, err + } } - if errors.Is(err, errNoImageElementFound) { - colour.Infoln("URL", urls[0], "without any image, that's fine, though.") - err = nil + + if p.thumbnailURL != "" { + if p.thumbnailDownloadPath, err = p.DownloadImage(args.CacheDir); err != nil { + return p, err + } + colour.Infoln("Downloaded preview image to ", p.thumbnailDownloadPath) } - return preview{title: title, imageURL: imageURL, url: urls[0]}, err + return p, nil } func (p preview) String() string { - if p.imageURL != "" { - return fmt.Sprintf("Title: %s; URL: %s, Image: %s", p.title, p.url, p.imageURL) + if p.thumbnailURL != "" { + return fmt.Sprintf("Title: %s; URL: %s, Image: %s", p.title, p.url, p.thumbnailURL) } return fmt.Sprintf("Title: %s; URL: %s", p.title, p.url) } -func (p preview) Empty() bool { - return p.url == "" +func (p preview) TitleAndURL() (string, string, bool) { + return p.title, p.url, p.url != "" +} + +func (p preview) Thumbnail() (string, bool) { + return p.thumbnailDownloadPath, p.thumbnailDownloadPath != "" } func (p preview) DownloadImage(destPath string) (string, error) { if err := oi.EnsureDir(destPath); err != nil { return "", err } - resp, err := http.Get(p.imageURL) + resp, err := http.Get(p.thumbnailURL) if err != nil { return "", err } @@ -65,7 +86,7 @@ func (p preview) DownloadImage(destPath string) (string, error) { return "", fmt.Errorf("bad status while trying to download image: %s", resp.Status) } - destFile := fmt.Sprintf("%s/%s", destPath, filepath.Base(p.imageURL)) + destFile := fmt.Sprintf("%s/%s", destPath, filepath.Base(p.thumbnailURL)) out, err := os.Create(destFile) if err != nil { return destFile, fmt.Errorf("%s: %w", destFile, err) |
