From 1ecf1ae99c6a520eae453bfdfe0779bd76ca01f6 Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 15 Oct 2023 11:45:31 +0200 Subject: [PATCH 01/10] feat: Update facebook-api --- docs/Ayrshare.md | 32 ++++ docs/Facebook.md | 2 +- src/platforms/Facebook.ts | 333 ++++++++++++++++++++++---------------- src/platforms/index.ts | 6 +- 4 files changed, 232 insertions(+), 141 deletions(-) create mode 100644 docs/Ayrshare.md diff --git a/docs/Ayrshare.md b/docs/Ayrshare.md new file mode 100644 index 0000000..7bc41f6 --- /dev/null +++ b/docs/Ayrshare.md @@ -0,0 +1,32 @@ +# Platform: Ayrshare + +Ayrshare (https://www.ayrshare.com/) is a platform / service +that does what FairPost does. I don't know why you would +use both. + +But if you have an Ayrshare account, you can enable +it here and enable the platforms that you have connected +to Ayrshare, to publish to those platforms via Ayrshare. + +The Ayrshare platforms supported by FairPost are +- asfacebook +- asinstagram +- aslinkedin +- asreddit +- astiktok +- asyoutube + +## Setting up the Ayrshare platform + +- get an account at Ayrshare +- get your Api key at https://app.ayrshare.com/api +- store this key as FAIRPOST_AYRSHARE_API_KEY + +### Enable and test the facebook platform + - Add one or more of the 'as*' platforms to `FAIRPOST_FEED_PLATFORMS` in `.env` + - call `./fairpost.js test-platforms` + +# Limitations + +Ayrshare applies different limitations to each platform. +For details, check the Ayrshare documentation. \ No newline at end of file diff --git a/docs/Facebook.md b/docs/Facebook.md index f19484a..42905dd 100644 --- a/docs/Facebook.md +++ b/docs/Facebook.md @@ -60,7 +60,7 @@ This token should last forever. It involves get a long-lived user token and then ### Enable and test the facebook platform - Add 'facebook' to your `FAIRPOST_FEED_PLATFORMS` in `.env` - - call `./fairpost.js test --platforms=facebook` + - call `./fairpost.js test-platform --platform=facebook` # Limitations diff --git a/src/platforms/Facebook.ts b/src/platforms/Facebook.ts index ed917a2..7260c3b 100644 --- a/src/platforms/Facebook.ts +++ b/src/platforms/Facebook.ts @@ -60,28 +60,39 @@ export default class Facebook extends Platform { if (post.files.image.length) { for (const image of post.files.image) { attachments.push({ - media_fbid: await this.uploadPhoto(post.folder.path + "/" + image), + media_fbid: ( + await this.uploadPhoto(post.folder.path + "/" + image) + )["id"], }); } } if (!dryrun) { - result = await this.post("feed", { + result = (await this.postJson("%PAGE%/feed", { message: post.body, published: process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, - //"scheduled_publish_time":"tomorrow", attached_media: attachments, - }); + })) as { id: string }; } } - post.results.push(result); + post.results.push({ + date: new Date(), + link: "https://facebook.com/" + result.id, + dryrun: dryrun, + result: result, + }); if (result.id) { if (!dryrun) { post.status = PostStatus.PUBLISHED; post.published = new Date(); } } else { - console.error(this.id, "No id returned in post", result); + Logger.error( + "Facebook.publishPost", + this.id, + "No id returned in post", + result, + ); } post.save(); return !!result.id; @@ -92,68 +103,7 @@ export default class Facebook extends Platform { } /* - * Do a GET request on the page. - * - * arguments: - * endpoint: the path to call - * query: query string as object - */ - - private async get( - endpoint: string = "", - query: { [key: string]: string } = {}, - ) { - const url = new URL("https://graph.facebook.com"); - url.pathname = - this.GRAPH_API_VERSION + "/" + process.env.FAIRPOST_FACEBOOK_PAGE_ID; - (url.pathname += "/" + endpoint), - (url.search = new URLSearchParams(query).toString()); - Logger.trace("GET", url.href); - const res = await fetch(url, { - method: "GET", - headers: process.env.FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN - ? { - Accept: "application/json", - Authorization: - "Bearer " + process.env.FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN, - } - : { - Accept: "application/json", - }, - }); - const result = await res.json(); - return result; - } - - /* - * Do a POST request on the page. - * - * arguments: - * endpoint: the path to call - * body: body as object - */ - - private async post(endpoint: string = "", body = {}) { - const url = new URL("https://graph.facebook.com"); - url.pathname = - this.GRAPH_API_VERSION + "/" + process.env.FAIRPOST_FACEBOOK_PAGE_ID; - (url.pathname += "/" + endpoint), Logger.trace("POST", url.href); - const res = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - Authorization: - "Bearer " + process.env.FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN, - }, - body: JSON.stringify(body), - }); - const result = await res.json(); - return result; - } - - /* - * POST an image to the /photos endpoint using multipart/form-data + * POST an image to the page/photos endpoint using multipart/form-data * * arguments: * file: path to the file to post @@ -164,41 +114,27 @@ export default class Facebook extends Platform { private async uploadPhoto( file: string = "", published = false, - ): Promise { + ): Promise<{ id: string }> { Logger.trace("Reading file", file); const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); - const url = new URL("https://graph.facebook.com"); - url.pathname = - this.GRAPH_API_VERSION + "/" + process.env.FAIRPOST_FACEBOOK_PAGE_ID; - url.pathname += "/photos"; - const body = new FormData(); body.set("published", published ? "true" : "false"); body.set("source", blob, path.basename(file)); - Logger.trace("POST", url.href); - const res = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: - "Bearer " + process.env.FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN, - }, - body, - }); + const result = (await this.postFormData("%PAGE%/photos", body)) as { + id: "string"; + }; - const result = await res.json(); if (!result["id"]) { - console.error(result); throw new Error("No id returned when uploading photo"); } - return result["id"]; + return result; } /* - * POST a video to the page using multipart/form-data + * POST a video to the page/videos endpoint using multipart/form-data * * arguments: * file: path to the video to post @@ -216,31 +152,17 @@ export default class Facebook extends Platform { const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); - const url = new URL("https://graph.facebook.com"); - url.pathname = - this.GRAPH_API_VERSION + "/" + process.env.FAIRPOST_FACEBOOK_PAGE_ID; - url.pathname += "/videos"; - const body = new FormData(); body.set("title", title); body.set("description", description); body.set("published", process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS); body.set("source", blob, path.basename(file)); - Logger.trace("POST", url.href); - const res = await fetch(url, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: - "Bearer " + process.env.FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN, - }, - body, - }); + const result = (await this.postFormData("%PAGE%/videos", body)) as { + id: string; + }; - const result = await res.json(); if (!result["id"]) { - console.error(result); throw new Error("No id returned when uploading video"); } return result; @@ -249,60 +171,43 @@ export default class Facebook extends Platform { /* * Return a long lived page access token. * + * appUserId: a app-scoped-user-id * UserAccessToken: a shortlived user access token */ async getPageToken( appUserId: string, userAccessToken: string, ): Promise { - // get a long lived UserAccessToken - - const url = new URL("https://graph.facebook.com"); - url.pathname = this.GRAPH_API_VERSION + "/oauth/access_token"; - const query = { + // 1. Get a long lived UserAccessToken + const query1 = { grant_type: "fb_exchange_token", client_id: process.env.FAIRPOST_FACEBOOK_APP_ID, client_secret: process.env.FAIRPOST_FACEBOOK_APP_SECRET, fb_exchange_token: userAccessToken, }; - url.search = new URLSearchParams(query).toString(); - - Logger.trace("fetching", url.href); - const res1 = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - }); - const data1 = await res1.json(); + const data1 = (await this.get("oauth/access_token", query1)) as { + access_token: string; + }; const llUserAccessToken = data1["access_token"]; - if (!llUserAccessToken) { console.error(data1); throw new Error("No llUserAccessToken access_token in response."); } - // get a long lived PageAccessToken - - const url2 = new URL("https://graph.facebook.com"); - url2.pathname = appUserId + "/accounts"; + // 2. Get a long lived PageAccessToken const query2 = { access_token: llUserAccessToken, }; - url2.search = new URLSearchParams(query2).toString(); - Logger.trace("fetching", url.href); - const res2 = await fetch(url2, { - method: "GET", - headers: { Accept: "application/json" }, - }); - const data2 = await res2.json(); + const data2 = (await this.get(appUserId + "/accounts", query2)) as { + data: { + id: string; + access_token: string; + }[]; + }; + const llPageAccessToken = data2.data?.find( + (page) => page.id === process.env.FAIRPOST_FACEBOOK_PAGE_ID, + )["access_token"]; - let llPageAccessToken = ""; - if (data2.data) { - data2.data.forEach((page) => { - if (page.id === process.env.FAIRPOST_FACEBOOK_PAGE_ID) { - llPageAccessToken = page.access_token; - } - }); - } if (!llPageAccessToken) { console.error(data2); throw new Error( @@ -314,4 +219,156 @@ export default class Facebook extends Platform { return llPageAccessToken; } + + // API implementation ------------------- + + /* + * Do a GET request on the graph. + * + * arguments: + * endpoint: the path to call + * query: query string as object + */ + + private async get( + endpoint: string = "%USER%", + query: { [key: string]: string } = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_FACEBOOK_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_FACEBOOK_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + Logger.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN + ? { + Accept: "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + } + : { + Accept: "application/json", + }, + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Do a Json POST request on the graph. + * + * arguments: + * endpoint: the path to call + * body: body as object + */ + + private async postJson( + endpoint: string = "%USER%", + body = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_FACEBOOK_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_FACEBOOK_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + Logger.trace("POST", url.href); + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + }, + body: JSON.stringify(body), + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Do a FormData POST request on the graph. + * + * arguments: + * endpoint: the path to call + * body: body as object + */ + + private async postFormData( + endpoint: string, + body: FormData, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_FACEBOOK_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_FACEBOOK_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + Logger.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + }, + body: body, + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Handle api response + * + */ + private async handleApiResponse(response: Response): Promise { + const data = await response.json(); + if (data.error) { + const error = + response.status + + ":" + + data.error.type + + "(" + + data.error.code + + "/" + + data.error.error_subcode + + ") " + + data.error.message; + Logger.error("Facebook.handleApiResponse", error); + throw new Error(error); + } + Logger.trace("Facebook.handleApiResponse", "success"); + return data; + } + + /* + * Handle api error + * + */ + private handleApiError(error: Error): Promise { + Logger.error("Facebook.handleApiError", error); + throw error; + } } diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 07be3a3..f0b4518 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -1,3 +1,5 @@ +export { default as Facebook } from "./Facebook"; +export { default as Instagram } from "./Instagram"; export { default as AsYouTube } from "./AsYouTube"; export { default as AsInstagram } from "./AsInstagram"; export { default as AsTwitter } from "./AsTwitter"; @@ -5,10 +7,11 @@ export { default as AsFacebook } from "./AsFacebook"; export { default as AsTikTok } from "./AsTikTok"; export { default as AsLinkedIn } from "./AsLinkedIn"; export { default as AsReddit } from "./AsReddit"; -export { default as Facebook } from "./Facebook"; export enum PlatformId { UNKNOWN = "unknown", + FACEBOOK = "facebook", + INSTAGRAM = "instagram", ASYOUTUBE = "asyoutube", ASINSTAGRAM = "asinstagram", ASFACEBOOK = "asfacebook", @@ -16,5 +19,4 @@ export enum PlatformId { ASTIKTOK = "astiktok", ASLINKEDIN = "aslinkedin", ASREDDIT = "asreddit", - FACEBOOK = "facebook", } From 794dd59ea3667d012e6065299ae72ae0afda1805 Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 15 Oct 2023 11:48:01 +0200 Subject: [PATCH 02/10] feat: Add instagram setup --- docs/Instagram.md | 111 +++++++++++++ src/platforms/Instagram.ts | 309 +++++++++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 docs/Instagram.md create mode 100644 src/platforms/Instagram.ts diff --git a/docs/Instagram.md b/docs/Instagram.md new file mode 100644 index 0000000..ab6a0cd --- /dev/null +++ b/docs/Instagram.md @@ -0,0 +1,111 @@ +# Platform: Instagram + +The `instagram` platform manage a instagram account +that is connected to a facebook **page* +using the plain facebook graph api - no extensions installed. + +It publishes **carousels** on that instagram account. + +## Setting up the Instagram platform + + +### Create a new App in your facebook account + - create an Instagram business account + - connect a Facebook page to your Instagram business account + - go to https://developers.facebook.com/ + - create an app that can manage pages + - include the "Instagram Graph API" product as a new product + - under 'settings', find your app ID + - save this as `FAIRPOST_INSTAGRAM_APP_ID` in your .env + - under 'settings', find your app secret + - save this as `FAIRPOST_INSTAGRAM_APP_SECRET` in your .env + + +### Find your instagram user id + - go to https://www.instagram.com/web/search/topsearch/?query={username} + - find your fbid_v2 + - note the user id + - save this as `FAIRPOST_INSTAGRAM_USER_ID` in your .env + +### Get a (short lived) Page Access Token for the page related to the instagram account you want the app to manage + +This is good for testing, but you'll have to refresh this token often. + + - go to https://developers.facebook.com/tools/explorer/ + - select your app + - add permissions + - pages_manage_engagement + - pages_manage_posts + - pages_read_engagement + - pages_read_user_engagement + - publish_video + - business_management + - instagram_basic + - instagram_content_publish + - request a (short lived) page access token + - save this as `FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN` in your .env + +### Get a (long lived) Page Access Token for the page related to the instagram account you want the app to manage + +This token should last forever. It involves get a long-lived user token and then requesting the 'accounts' for your 'app scoped user id'; but this app provides a tool to help you do that: + + - go to https://developers.facebook.com/tools/explorer/ + - select your app + - add permissions + - pages_manage_engagement + - pages_manage_posts + - pages_read_engagement + - pages_read_user_engagement + - publish_video + - business_management + - instagram_basic + - instagram_content_publish + - request a (short lived) user access token + - click 'submit' to submit the default `?me` query + - remember the `id` in the response as your id + - call `./fairpost.js facebook-get-page-token + --app-user-id={your id} --user-token={your token}` + - note the token returned + - save this as `FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN` in your .env + +### Enable and test the instagram platform + - Add 'instagram' to your `FAIRPOST_FEED_PLATFORMS` in `.env` + - call `./fairpost.js test-platform --platform=instagram` + +# Limitations + +## Images + +- Carousels are limited to 10 images, videos, or a mix of the two. +- Carousel images are all cropped based on the first image in the carousel, with the default being a 1:1 aspect ratio. + + +### Supported Formats +Instagram supports the following formats: + - JPEG + +### File Size + +xxx + +# Random documentation + +https://developers.facebook.com/docs/instagram-api/guides/content-publishing + +- only jpeg +- rate limit w endpoint +- upload media first + +POST /{ig-user-id}/media — upload media and create media containers. +POST /{ig-user-id}/media_publish — publish uploaded media using their media containers. +GET /{ig-container-id}?fields=status_code — check media container publishing eligibility and status. +GET /{ig-user-id}/content_publishing_limit — check app user's current publishing rate limit usage. + +~~~ +GET /{ig-container-id}?fields=status_code endpoint. This endpoint will return one of the following: + +EXPIRED — The container was not published within 24 hours and has expired. +ERROR — The container failed to complete the publishing process. +FINISHED — The container and its media object are ready to be published. +IN_PROGRESS — The container is still in the publishing process. +PUBLISHED — The container's media object has been published. diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts new file mode 100644 index 0000000..055f902 --- /dev/null +++ b/src/platforms/Instagram.ts @@ -0,0 +1,309 @@ +import Logger from "../Logger"; +import Platform from "../Platform"; +import { PlatformId } from "."; +import Folder from "../Folder"; +import Post from "../Post"; +import { PostStatus } from "../Post"; +import * as fs from "fs"; +import * as path from "path"; +import * as sharp from "sharp"; + +export default class Instagram extends Platform { + id: PlatformId = PlatformId.INSTAGRAM; + GRAPH_API_VERSION: string = "v18.0"; + + constructor() { + super(); + } + + async preparePost(folder: Folder): Promise { + const post = await super.preparePost(folder); + if (post && post.files) { + // instagram: 1 video for reel + if (post.files.video.length) { + Logger.trace("Removing images for instagram reel.."); + post.files.image = []; + if (post.files.video.length > 1) { + Logger.trace("Using first video for instagram reel.."); + post.files.video = [post.files.video[0]]; + } + } + // instagram : scale images, jpeg only + for (const image of post.files.image) { + const metadata = await sharp(post.folder.path + "/" + image).metadata(); + if (metadata.width > 1440) { + Logger.trace("Resizing " + image + " for instagram .."); + const extension = image.split(".")?.pop(); + const basename = path.basename( + image, + extension ? "." + extension : "", + ); + await sharp(post.folder.path + "/" + image) + .resize({ + width: 1440, + }) + .toFile(post.folder.path + "/_instagram-" + basename + ".JPEG"); + post.files.image.push("_instagram-" + image); + post.files.image = post.files.image.filter((file) => file !== image); + } + } + + // instagram: require media + if (post.files.image.length + post.files.video.length === 0) { + post.valid = false; + } + post.save(); + } + return post; + } + + + async publishPost(post: Post, dryrun: boolean = false): Promise { + console.log("Instagram.publishPost",post,dryrun); + throw new Error("not implemented"); + } + + async test() { + return this.testUploadCarousel(); + } + + async testUploadCarousel() { + // upload photo to facebook + const photoId = (await this.uploadPhoto( + "/Users/pike/Desktop/test/test.jpg", + false, + ))["id"]; + if (!photoId) return; + + // get photo link + const photoData = (await this.get(photoId, { + fields: "link,images,picture", + })) as { + images: { + width: number; + height: number; + source: string; + }[]; + }; + if (!photoData) return; + + const maxPhoto = photoData.images?.reduce(function (prev, current) { + return prev && prev.width > current.width ? prev : current; + }); + if (!maxPhoto) return; + + const photoLink = maxPhoto["source"]; + + // upload link to instagram + const uploadId = ( + await this.postJson("%USER%/media", { + is_carousel_item: true, + image_url: photoLink, + }) + )["id"]; + if (!uploadId) return; + + // create carousel + const carouselId = ( + await this.postJson("%USER%/media", { + media_type: "CAROUSEL", + caption: "test", + children: [uploadId, uploadId].join(","), + }) + )["id"]; + if (!carouselId) return; + + // publish carousel + const igMediaId = ( + await this.postJson("%USER%/media_publish", { + creation_id: carouselId, + }) + )["id"]; + if (!igMediaId) return; + + return igMediaId; + } + + /* + * POST an image to the page/photos endpoint using multipart/form-data + * + * arguments: + * file: path to the file to post + * + * returns: + * id of the uploaded photo to use in post attachments + */ + private async uploadPhoto( + file: string = "", + published = false, + ): Promise<{ id: string }> { + Logger.trace("Reading file", file); + const rawData = fs.readFileSync(file); + const blob = new Blob([rawData]); + + const body = new FormData(); + body.set("published", published ? "true" : "false"); + body.set("source", blob, path.basename(file)); + + const result = (await this.postFormData("%PAGE%/photos", body)) as { + id: "string"; + }; + + if (!result["id"]) { + throw new Error("No id returned when uploading photo"); + } + return result; + } + + // API implementation ------------------- + + /* + * Do a GET request on the graph. + * + * arguments: + * endpoint: the path to call + * query: query string as object + */ + + private async get( + endpoint: string = "%USER%", + query: { [key: string]: string } = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_INSTAGRAM_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_INSTAGRAM_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + url.search = new URLSearchParams(query).toString(); + Logger.trace("GET", url.href); + return await fetch(url, { + method: "GET", + headers: process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN + ? { + Accept: "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + } + : { + Accept: "application/json", + }, + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Do a Json POST request on the graph. + * + * arguments: + * endpoint: the path to call + * body: body as object + */ + + private async postJson( + endpoint: string = "%USER%", + body = {}, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_INSTAGRAM_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_INSTAGRAM_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + Logger.trace("POST", url.href); + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + }, + body: JSON.stringify(body), + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Do a FormData POST request on the graph. + * + * arguments: + * endpoint: the path to call + * body: body as object + */ + + private async postFormData( + endpoint: string, + body: FormData, + ): Promise { + endpoint = endpoint.replace( + "%USER%", + process.env.FAIRPOST_INSTAGRAM_USER_ID, + ); + endpoint = endpoint.replace( + "%PAGE%", + process.env.FAIRPOST_INSTAGRAM_PAGE_ID, + ); + + const url = new URL("https://graph.facebook.com"); + url.pathname = this.GRAPH_API_VERSION + "/" + endpoint; + Logger.trace("POST", url.href); + + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: + "Bearer " + process.env.FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN, + }, + body: body, + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err)); + } + + /* + * Handle api response + * + */ + private async handleApiResponse(response: Response): Promise { + const data = await response.json(); + if (data.error) { + const error = + response.status + + ":" + + data.error.type + + "(" + + data.error.code + + "/" + + data.error.error_subcode + + ") " + + data.error.message; + Logger.error("Facebook.handleApiResponse", error); + throw new Error(error); + } + Logger.trace("Facebook.handleApiResponse", "success"); + return data; + } + + /* + * Handle api error + * + */ + private handleApiError(error: Error): Promise { + Logger.error("Facebook.handleApiError", error); + throw error; + } +} From a1837a39b67e2b48e8b1beba76d59ecc06439ff7 Mon Sep 17 00:00:00 2001 From: pike Date: Sun, 15 Oct 2023 12:04:19 +0200 Subject: [PATCH 03/10] fix: Lint --- src/platforms/Instagram.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index 055f902..05f9b1b 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -3,7 +3,7 @@ import Platform from "../Platform"; import { PlatformId } from "."; import Folder from "../Folder"; import Post from "../Post"; -import { PostStatus } from "../Post"; +//import { PostStatus } from "../Post"; import * as fs from "fs"; import * as path from "path"; import * as sharp from "sharp"; @@ -57,9 +57,8 @@ export default class Instagram extends Platform { return post; } - async publishPost(post: Post, dryrun: boolean = false): Promise { - console.log("Instagram.publishPost",post,dryrun); + console.log("Instagram.publishPost", post, dryrun); throw new Error("not implemented"); } @@ -69,10 +68,9 @@ export default class Instagram extends Platform { async testUploadCarousel() { // upload photo to facebook - const photoId = (await this.uploadPhoto( - "/Users/pike/Desktop/test/test.jpg", - false, - ))["id"]; + const photoId = ( + await this.uploadPhoto("/Users/pike/Desktop/test/test.jpg", false) + )["id"]; if (!photoId) return; // get photo link From 3161dfc1a9e6a34d43c4b2cdf82cdd3cc84ce4e8 Mon Sep 17 00:00:00 2001 From: pike Date: Fri, 20 Oct 2023 10:23:20 +0200 Subject: [PATCH 04/10] feat: Use interface PostResult --- src/Post.ts | 10 +++++++++- src/platforms/Ayrshare.ts | 38 +++++++++++++++++++++++++------------- src/platforms/Facebook.ts | 29 ++++++++++++++++------------- src/platforms/Instagram.ts | 16 +++++++++++++++- 4 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/Post.ts b/src/Post.ts index 8fc6597..759c7ca 100644 --- a/src/Post.ts +++ b/src/Post.ts @@ -11,7 +11,7 @@ export default class Post { status: PostStatus = PostStatus.UNKNOWN; scheduled?: Date; published?: Date; - results: object[] = []; + results: PostResult[] = []; title: string = ""; body?: string; tags?: string; @@ -70,6 +70,14 @@ export default class Post { } } +export interface PostResult { + date: Date; + dryrun: boolean; + success: boolean; + link?: string; + response: object; +} + export enum PostStatus { UNKNOWN = "unknown", UNSCHEDULED = "unscheduled", diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts index 6d78222..6c2766b 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -11,7 +11,13 @@ import { PostStatus } from "../Post"; interface AyrshareResult { success: boolean; error?: Error; - response: object; + status?: string; + response: { + id: string; + postIds?: { + postUrl: string; + }[]; + }; } export default abstract class Ayrshare extends Platform { @@ -51,26 +57,31 @@ export default abstract class Ayrshare extends Platform { post.results.push({ date: new Date(), dryrun: true, - uploads: uploads, + response: uploads, success: true, - response: {}, }); post.save(); return true; } const result = await this.postAyrshare(post, platformOptions, uploads); - post.results.push(result); - if (result.success) { + const success = !!result.success; + const link = result.response.postIds[0]?.postUrl ?? ""; + post.results.push({ + date: new Date(), + dryrun: dryrun, + success: success, + link: link, + response: result, + }); + + if (success && !dryrun) { post.status = PostStatus.PUBLISHED; post.published = new Date(); } post.save(); - if (!result.success) { - console.error(result.error); - } - return result.success ?? false; + return success; } async uploadMedia(media: string[]): Promise { @@ -193,28 +204,29 @@ export default abstract class Ayrshare extends Platform { if (res.ok) { //console.log(res.json()); result.response = (await res.json()) as unknown as { + id: string; status?: string; }; if ( result.response["status"] !== "success" && result.response["status"] !== "scheduled" ) { - console.error("* Failed."); + Logger.error("* Failed."); result.error = new Error( "Bad result status: " + result.response["status"], ); } else { - console.error(" .. Published."); + Logger.trace(" .. Published."); result.success = true; } return result; } const response = await res.json(); - console.error("* Failed."); + Logger.error("* Failed."); result.error = new Error(JSON.stringify(response)); return result; } - console.error("* Failed."); + Logger.error("* Failed."); result.error = new Error("no result"); return result; } diff --git a/src/platforms/Facebook.ts b/src/platforms/Facebook.ts index 7260c3b..fedbbec 100644 --- a/src/platforms/Facebook.ts +++ b/src/platforms/Facebook.ts @@ -45,11 +45,12 @@ export default class Facebook extends Platform { } async publishPost(post: Post, dryrun: boolean = false): Promise { - let result = dryrun ? { id: "-99" } : ({} as { id: string }); + Logger.trace("Facebook.publishPost", post, dryrun); + let response = dryrun ? { id: "-99" } : ({} as { id: string }); if (post.files.video.length) { if (!dryrun) { - result = await this.publishVideo( + response = await this.publishVideo( post.files.video[0], post.title, post.body, @@ -67,35 +68,37 @@ export default class Facebook extends Platform { } } if (!dryrun) { - result = (await this.postJson("%PAGE%/feed", { + response = (await this.postJson("%PAGE%/feed", { message: post.body, published: process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, attached_media: attachments, })) as { id: string }; } } + const success = !!response.id; post.results.push({ date: new Date(), - link: "https://facebook.com/" + result.id, dryrun: dryrun, - result: result, + success: success, + link: "https://facebook.com/" + response.id, + response: response, }); - if (result.id) { - if (!dryrun) { - post.status = PostStatus.PUBLISHED; - post.published = new Date(); - } - } else { + + if (!success) { Logger.error( "Facebook.publishPost", this.id, "No id returned in post", - result, + response, ); + } else if (!dryrun) { + post.status = PostStatus.PUBLISHED; + post.published = new Date(); } + post.save(); - return !!result.id; + return success; } async test() { diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index 05f9b1b..34a2cdb 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -28,6 +28,7 @@ export default class Instagram extends Platform { post.files.video = [post.files.video[0]]; } } + // instagram : scale images, jpeg only for (const image of post.files.image) { const metadata = await sharp(post.folder.path + "/" + image).metadata(); @@ -58,7 +59,20 @@ export default class Instagram extends Platform { } async publishPost(post: Post, dryrun: boolean = false): Promise { - console.log("Instagram.publishPost", post, dryrun); + Logger.trace("Instagram.publishPost", post, dryrun); + /* + let result = dryrun ? { id: "-99" } : ({} as { id: string }); + + if (post.files.video.length) { + if (!dryrun) { + result = await this.publishVideo( + post.files.video[0], + post.title, + post.body, + ); + } + } else { + }*/ throw new Error("not implemented"); } From e0f6f056ce4dfebc3e6f77b69e23a8478d39d8c8 Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 15:01:19 +0200 Subject: [PATCH 05/10] chore: Clean up post error handling all over --- README.md | 4 +- docs/Ayrshare.md | 4 + docs/Instagram.md | 9 +- src/Platform.ts | 5 +- src/Post.ts | 5 +- src/platforms/Ayrshare.ts | 225 +++++++++++++++++++------------------ src/platforms/Facebook.ts | 87 ++++++++------ src/platforms/Instagram.ts | 219 +++++++++++++++++++++++++++++++----- 8 files changed, 383 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index 0c016cc..940ed73 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,11 @@ fairpost.js [command] [arguments] --report=json To add support for a new platform, add a class to `src/platforms` extending `src/classes/Platform`. You want to override at least the method `preparePost(folder: Folder)` and -`publishPost(post: Post, dryrun:boolean = false)`. +`publishPost(post: Post, dryrun:boolean = false)`. Then add a platformId for your platform to `src/platforms/index.js` and enable your platform in your `.env`. + + diff --git a/docs/Ayrshare.md b/docs/Ayrshare.md index 7bc41f6..6ae2f77 100644 --- a/docs/Ayrshare.md +++ b/docs/Ayrshare.md @@ -8,6 +8,10 @@ But if you have an Ayrshare account, you can enable it here and enable the platforms that you have connected to Ayrshare, to publish to those platforms via Ayrshare. +Ayrshare posts will not be scheduled on Ayrshare; +they will be published instantly. Use Fairpost for +scheduling posts. + The Ayrshare platforms supported by FairPost are - asfacebook - asinstagram diff --git a/docs/Instagram.md b/docs/Instagram.md index ab6a0cd..b5affd3 100644 --- a/docs/Instagram.md +++ b/docs/Instagram.md @@ -1,10 +1,15 @@ # Platform: Instagram The `instagram` platform manage a instagram account -that is connected to a facebook **page* +that is connected to a facebook **page** using the plain facebook graph api - no extensions installed. -It publishes **carousels** on that instagram account. +It publishes **photo**, **video**, or +**carousels** posts on that instagram account. + +It uses the related facebook account to +upload temporary files, because the instagram +api requires files in posts to have an url. ## Setting up the Instagram platform diff --git a/src/Platform.ts b/src/Platform.ts index c2f3fcf..39b752e 100644 --- a/src/Platform.ts +++ b/src/Platform.ts @@ -112,7 +112,10 @@ export default class Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Platform", "publishPost", post.id, dryrun); post.results.push({ - error: "publishing not implemented for " + this.id, + date: new Date(), + success: false, + error: new Error("publishing not implemented for " + this.id), + response: {}, }); post.published = undefined; post.status = PostStatus.FAILED; diff --git a/src/Post.ts b/src/Post.ts index 759c7ca..00742c8 100644 --- a/src/Post.ts +++ b/src/Post.ts @@ -21,6 +21,7 @@ export default class Post { video: string[]; other: string[]; }; + link?: string; constructor(folder: Folder, platform: Platform, data?: object) { this.folder = folder; @@ -72,9 +73,9 @@ export default class Post { export interface PostResult { date: Date; - dryrun: boolean; + dryrun?: boolean; + error?: Error; success: boolean; - link?: string; response: object; } diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts index 6c2766b..14f63cd 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -8,18 +8,6 @@ import Folder from "../Folder"; import Post from "../Post"; import { PostStatus } from "../Post"; -interface AyrshareResult { - success: boolean; - error?: Error; - status?: string; - response: { - id: string; - postIds?: { - postUrl: string; - }[]; - }; -} - export default abstract class Ayrshare extends Platform { requiresApproval: boolean = false; @@ -49,39 +37,52 @@ export default abstract class Ayrshare extends Platform { platformOptions: object, dryrun: boolean = false, ): Promise { + let error = undefined; + let response = dryrun + ? { postIds: [] } + : ({} as { + postIds?: { + postUrl: string; + }[]; + }); + const media = [...post.files.image, ...post.files.video].map( (f) => post.folder.path + "/" + f, ); - const uploads = media.length ? await this.uploadMedia(media) : []; - if (dryrun) { - post.results.push({ - date: new Date(), - dryrun: true, - response: uploads, - success: true, - }); - post.save(); - return true; + + try { + const uploads = media.length ? await this.uploadMedia(media) : []; + if (!dryrun) { + response = await this.postAyrshare(post, platformOptions, uploads); + } + } catch (e) { + error = e; } - const result = await this.postAyrshare(post, platformOptions, uploads); - const success = !!result.success; - const link = result.response.postIds[0]?.postUrl ?? ""; post.results.push({ date: new Date(), dryrun: dryrun, - success: success, - link: link, - response: result, + success: !error, + error: error, + response: response, }); - if (success && !dryrun) { - post.status = PostStatus.PUBLISHED; - post.published = new Date(); + if (error) { + Logger.error("Ayrshare.publishPost", this.id, "failed", response); } - post.save(); - return success; + if (!dryrun) { + if (!error) { + post.link = response.postIds[0]?.postUrl ?? ""; + post.status = PostStatus.PUBLISHED; + post.published = new Date(); + } else { + post.status = PostStatus.FAILED; + } + } + + post.save(); + return !error; } async uploadMedia(media: string[]): Promise { @@ -93,7 +94,7 @@ export default abstract class Ayrshare extends Platform { const basename = path.basename(file, ext); const uname = basename + "-" + randomUUID() + ext; Logger.trace("fetching uploadid...", file); - const res1 = await fetch( + const data = (await fetch( "https://app.ayrshare.com/api/media/uploadUrl?fileName=" + uname + "&contentType=" + @@ -104,33 +105,32 @@ export default abstract class Ayrshare extends Platform { Authorization: `Bearer ${APIKEY}`, }, }, - ); - - if (!res1) { - return []; - } + ) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err))) as { + uploadUrl: string; + contentType: string; + accessUrl: string; + }; - const data = await res1.json(); - //console.log(data); - Logger.trace("uploading..", uname); - const uploadUrl = data.uploadUrl; - const contentType = data.contentType; - const accessUrl = data.accessUrl; + Logger.trace("uploading..", uname, data); - const res2 = await fetch(uploadUrl, { + (await fetch(data.uploadUrl, { method: "PUT", headers: { - "Content-Type": contentType, + "Content-Type": data.contentType, Authorization: `Bearer ${APIKEY}`, }, body: buffer, - }); - - if (!res2) { - return []; - } - - urls.push(accessUrl.replace(/ /g, "%20")); + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err))) as { + uploadUrl: string; + contentType: string; + accessUrl: string; + }; + + urls.push(data.accessUrl.replace(/ /g, "%20")); } return urls; } @@ -139,23 +139,16 @@ export default abstract class Ayrshare extends Platform { post: Post, platformOptions: object, uploads: string[], - ): Promise { + ): Promise { const APIKEY = process.env.FAIRPOST_AYRSHARE_API_KEY; const scheduleDate = post.scheduled; //scheduleDate.setDate(scheduleDate.getDate()+100); - const result = { - success: false, - error: undefined, - response: {}, - } as AyrshareResult; - const postPlatform = this.platforms[this.id]; if (!postPlatform) { - result.error = new Error( + throw new Error( "No ayrshare platform associated with platform " + this.id, ); - return result; } const body = JSON.stringify( uploads.length @@ -166,19 +159,6 @@ export default abstract class Ayrshare extends Platform { scheduleDate: scheduleDate, requiresApproval: this.requiresApproval, ...platformOptions, - /* - youTubeOptions: { - title: post.title, // required max 100 - visibility: "public" // opt 'private' - }, - instagramOptions: { - // "autoResize": true -- only enterprise plans - // isVideo: (this.data.type==='video'), - }, - redditOptions: { - title: this.data.title, // required - subreddit: REDDIT_SUBREDDIT, // required (no "/r/" needed) - }*/ } : { post: post.body, // required @@ -188,46 +168,73 @@ export default abstract class Ayrshare extends Platform { }, ); Logger.trace("scheduling...", postPlatform); - //console.log(body); - const res = await fetch("https://app.ayrshare.com/api/post", { + const response = (await fetch("https://app.ayrshare.com/api/post", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${APIKEY}`, }, body: body, - }).catch((e) => { - result.error = e; - }); + }) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err))) as { + id: string; + status?: string; + }; + + if ( + response["status"] !== "success" && + response["status"] !== "scheduled" + ) { + const error = "Bad result status: " + response["status"]; + Logger.error(error); + throw new Error(error); + } + return response; + } - if (res) { - if (res.ok) { - //console.log(res.json()); - result.response = (await res.json()) as unknown as { - id: string; - status?: string; - }; - if ( - result.response["status"] !== "success" && - result.response["status"] !== "scheduled" - ) { - Logger.error("* Failed."); - result.error = new Error( - "Bad result status: " + result.response["status"], - ); - } else { - Logger.trace(" .. Published."); - result.success = true; - } - return result; - } - const response = await res.json(); - Logger.error("* Failed."); - result.error = new Error(JSON.stringify(response)); - return result; + /* + * Handle api response + * + */ + private async handleApiResponse(response: Response): Promise { + if (!response.ok) { + Logger.error("Ayrshare.handleApiResponse", response); + throw new Error(response.status + ":" + response.statusText); + } + const data = await response.json(); + if (data.status === "error") { + let error = response.status + ":"; + data.status.errors.forEach( + (err: { + action: string; + platform: string; + code: number; + message: string; + }) => { + error += + err.action + + "(" + + err.code + + "/" + + err.platform + + ") " + + err.message; + }, + ); + Logger.error("Ayrshare.handleApiResponse", error); + throw new Error(error); } - Logger.error("* Failed."); - result.error = new Error("no result"); - return result; + Logger.trace("Ayrshare.handleApiResponse", "success"); + return data; + } + + /* + * Handle api error + * + */ + private handleApiError(error: Error): Promise { + Logger.error("Ayrshare.handleApiError", error); + throw error; } } diff --git a/src/platforms/Facebook.ts b/src/platforms/Facebook.ts index fedbbec..a0f3fbe 100644 --- a/src/platforms/Facebook.ts +++ b/src/platforms/Facebook.ts @@ -46,59 +46,72 @@ export default class Facebook extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Facebook.publishPost", post, dryrun); - let response = dryrun ? { id: "-99" } : ({} as { id: string }); + + let response = dryrun + ? { id: "-99" } + : ({} as { id?: string; error?: string }); + let error = undefined; if (post.files.video.length) { if (!dryrun) { - response = await this.publishVideo( - post.files.video[0], - post.title, - post.body, - ); + try { + response = await this.publishVideo( + post.files.video[0], + post.title, + post.body, + ); + } catch (e) { + error = e; + } } } else { - const attachments = []; - if (post.files.image.length) { - for (const image of post.files.image) { - attachments.push({ - media_fbid: ( - await this.uploadPhoto(post.folder.path + "/" + image) - )["id"], - }); + try { + const attachments = []; + if (post.files.image.length) { + for (const image of post.files.image) { + attachments.push({ + media_fbid: ( + await this.uploadPhoto(post.folder.path + "/" + image) + )["id"], + }); + } } - } - if (!dryrun) { - response = (await this.postJson("%PAGE%/feed", { - message: post.body, - published: process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, - attached_media: attachments, - })) as { id: string }; + if (!dryrun) { + response = (await this.postJson("%PAGE%/feed", { + message: post.body, + published: process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, + attached_media: attachments, + })) as { id: string }; + } + } catch (e) { + error = e; } } - const success = !!response.id; post.results.push({ date: new Date(), dryrun: dryrun, - success: success, - link: "https://facebook.com/" + response.id, + success: !error, + error: error, response: response, }); - if (!success) { - Logger.error( - "Facebook.publishPost", - this.id, - "No id returned in post", - response, - ); - } else if (!dryrun) { - post.status = PostStatus.PUBLISHED; - post.published = new Date(); + if (error) { + Logger.error("Facebook.publishPost", this.id, "failed", response); + } + + if (!dryrun) { + if (!error) { + (post.link = "https://facebook.com/" + response.id), + (post.status = PostStatus.PUBLISHED); + post.published = new Date(); + } else { + post.status = PostStatus.FAILED; + } } post.save(); - return success; + return !error; } async test() { @@ -347,6 +360,10 @@ export default class Facebook extends Platform { * */ private async handleApiResponse(response: Response): Promise { + if (!response.ok) { + Logger.error("Facebook.handleApiResponse", response); + throw new Error(response.status + ":" + response.statusText); + } const data = await response.json(); if (data.error) { const error = diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index 34a2cdb..eaf7f23 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -3,7 +3,7 @@ import Platform from "../Platform"; import { PlatformId } from "."; import Folder from "../Folder"; import Post from "../Post"; -//import { PostStatus } from "../Post"; +import { PostStatus } from "../Post"; import * as fs from "fs"; import * as path from "path"; import * as sharp from "sharp"; @@ -21,11 +21,14 @@ export default class Instagram extends Platform { if (post && post.files) { // instagram: 1 video for reel if (post.files.video.length) { - Logger.trace("Removing images for instagram reel.."); - post.files.image = []; - if (post.files.video.length > 1) { - Logger.trace("Using first video for instagram reel.."); - post.files.video = [post.files.video[0]]; + if (post.files.video.length > 10) { + Logger.trace("Removing > 10 videos for instagram caroussel.."); + post.files.video.length = 10; + } + const remaining = 10 - post.files.video.length; + if (post.files.image.length > remaining) { + Logger.trace("Removing some images for instagram caroussel.."); + post.files.image.length = remaining; } } @@ -60,20 +63,48 @@ export default class Instagram extends Platform { async publishPost(post: Post, dryrun: boolean = false): Promise { Logger.trace("Instagram.publishPost", post, dryrun); - /* - let result = dryrun ? { id: "-99" } : ({} as { id: string }); - if (post.files.video.length) { - if (!dryrun) { - result = await this.publishVideo( - post.files.video[0], - post.title, - post.body, - ); - } + let response = dryrun ? { id: "-99" } : ({} as { id: string }); + let error = undefined; + + try { + if (post.files.video.length === 1 && !post.files.image.length) { + response = await this.publishVideo( + post.files.video[0], + post.body, + dryrun, + ); + } else if (post.files.image.length === 1 && !post.files.video.length) { + response = await this.publishPhoto( + post.files.image[0], + post.body, + dryrun, + ); } else { - }*/ - throw new Error("not implemented"); + response = await this.publishCaroussel(post, dryrun); + } + } catch (e) { + error = e; + } + + post.results.push({ + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }); + + if (error) { + Logger.error("Instagram.publishPost", this.id, "failed", response); + } else if (!dryrun) { + // post.link = ""; // todo : get instagram shortcode + post.status = PostStatus.PUBLISHED; + post.published = new Date(); + } + + post.save(); + return !error; } async test() { @@ -83,7 +114,7 @@ export default class Instagram extends Platform { async testUploadCarousel() { // upload photo to facebook const photoId = ( - await this.uploadPhoto("/Users/pike/Desktop/test/test.jpg", false) + await this.fbUploadPhoto("/Users/pike/Desktop/test/test.jpg") )["id"]; if (!photoId) return; @@ -136,6 +167,73 @@ export default class Instagram extends Platform { return igMediaId; } + private async publishPhoto( + file, + caption: string = "", + dryrun: boolean = false, + ): Promise<{ id: string }> { + const photoId = (await this.fbUploadPhoto(file))["id"]; + const photoLink = await this.fbGetPhotoLink(photoId); + const container = (await this.postJson("%USER%/media", { + image_url: photoLink, + caption: caption, + })) as { id: string }; + if (!container?.id) { + throw new Error("No id returned for container for " + file); + } + + if (!dryrun) { + // wait for upload ? + // https://github.com/fbsamples/reels_publishing_apis/blob/main/insta_reels_publishing_api_sample/utils.js#L23 + const response = (await this.postJson("%USER%/media_publish", { + creation_id: container.id, + })) as { id: string }; + if (!response?.id) { + throw new Error("No id returned for igMedia for " + file); + } + return response; + } + + return { id: "-99" }; + } + + private async publishVideo( + file, + caption: string = "", + dryrun: boolean = false, + ): Promise<{ id: string }> { + const videoId = (await this.fbUploadVideo(file))["id"]; + const videoLink = await this.fbGetVideoLink(videoId); + const container = (await this.postJson("%USER%/media", { + video_url: videoLink, + caption: caption, + })) as { id: string }; + if (!container?.id) { + throw new Error("No id returned for container for " + file); + } + + if (!dryrun) { + // wait for upload ? + // https://github.com/fbsamples/reels_publishing_apis/blob/main/insta_reels_publishing_api_sample/utils.js#L23 + const response = (await this.postJson("%USER%/media_publish", { + creation_id: container.id, + })) as { id: string }; + if (!response?.id) { + throw new Error("No id returned for igMedia for " + file); + } + return response; + } + + return { id: "-99" }; + } + + private async publishCaroussel( + post: Post, + dryrun: boolean = false, + ): Promise<{ id: string }> { + return { id: "-99" }; + } + /* * POST an image to the page/photos endpoint using multipart/form-data * @@ -145,16 +243,13 @@ export default class Instagram extends Platform { * returns: * id of the uploaded photo to use in post attachments */ - private async uploadPhoto( - file: string = "", - published = false, - ): Promise<{ id: string }> { + private async fbUploadPhoto(file: string = ""): Promise<{ id: string }> { Logger.trace("Reading file", file); const rawData = fs.readFileSync(file); const blob = new Blob([rawData]); const body = new FormData(); - body.set("published", published ? "true" : "false"); + body.set("published", "false"); body.set("source", blob, path.basename(file)); const result = (await this.postFormData("%PAGE%/photos", body)) as { @@ -162,11 +257,81 @@ export default class Instagram extends Platform { }; if (!result["id"]) { - throw new Error("No id returned when uploading photo"); + throw new Error("No id returned after uploading photo " + file); + } + return result; + } + + private async fbGetPhotoLink(id: string): Promise { + // get photo derivatives + const photoData = (await this.get(id, { + fields: "link,images,picture", + })) as { + link: string; + images: { + width: number; + height: number; + source: string; + }[]; + picture: string; + }; + if (!photoData.images?.length) { + throw new Error("No derivates found for photo " + id); + } + + // find largest derivative + const largestPhoto = photoData.images?.reduce(function (prev, current) { + return prev && prev.width > current.width ? prev : current; + }); + if (!largestPhoto["source"]) { + throw new Error("Largest derivate for photo " + id + " has no source"); + } + return largestPhoto["source"]; + } + + /* + * POST a video to the page/videos endpoint using multipart/form-data + * + * arguments: + * file: path to the video to post + * published: wether to publish it or not + * + * returns: + * { id: string } + */ + private async fbUploadVideo(file: string): Promise<{ id: string }> { + Logger.trace("Reading file", file); + const rawData = fs.readFileSync(file); + const blob = new Blob([rawData]); + + const body = new FormData(); + body.set("title", "Fairpost temp instagram upload"); + body.set("published", "false"); + body.set("source", blob, path.basename(file)); + + const result = (await this.postFormData("%PAGE%/videos", body)) as { + id: string; + }; + + if (!result["id"]) { + throw new Error("No id returned when uploading video"); } return result; } + private async fbGetVideoLink(id: string): Promise { + const videoData = (await this.get(id, { + fields: "permalink_url,source", + })) as { + permalink_url: string; + source: string; + }; + if (!videoData.source) { + throw new Error("No source url found for video " + id); + } + return videoData["source"]; + } + // API implementation ------------------- /* @@ -291,6 +456,10 @@ export default class Instagram extends Platform { * */ private async handleApiResponse(response: Response): Promise { + if (!response.ok) { + Logger.error("Ayrshare.handleApiResponse", response); + throw new Error(response.status + ":" + response.statusText); + } const data = await response.json(); if (data.error) { const error = From dedde1d1927a1c5f15620144cdcdaf8a70ae6ec1 Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 16:56:38 +0200 Subject: [PATCH 06/10] feat: Finish Instagram post methods --- .env.dist | 18 +++++++---- src/platforms/AsReddit.ts | 2 +- src/platforms/Facebook.ts | 2 +- src/platforms/Instagram.ts | 64 +++++++++++++++++++++++++++++++++++++- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/.env.dist b/.env.dist index a496be8..ab219f5 100644 --- a/.env.dist +++ b/.env.dist @@ -2,12 +2,11 @@ FAIRPOST_FEED_PATH=feed FAIRPOST_FEED_INTERVAL=6 #days FAIRPOST_FEED_PLATFORMS= -# FAIRPOST_FEED_PLATFORMS=facebook,asyoutube,asfacebook,aslinkedin,asinstagram,astiktok,asreddit,astwitter +# FAIRPOST_FEED_PLATFORMS=facebook,instagram,asyoutube,asfacebook,aslinkedin,asinstagram,astiktok,asreddit,astwitter -# AYRSHARE -FAIRPOST_AYRSHARE_API_KEY=xxxx - -FAIRPOST_REDDIT_SUBREDDIT=generative +# ayrshare +# FAIRPOST_AYRSHARE_API_KEY=xxxx +# FAIRPOST_AYRSHARE_SUBREDDIT=xxxx # facebook @@ -15,4 +14,11 @@ FAIRPOST_REDDIT_SUBREDDIT=generative # FAIRPOST_FACEBOOK_APP_SECRET=xxx # FAIRPOST_FACEBOOK_PAGE_ID=xxx # FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN=xxx -# FAIRPOST_FACEBOOK_PUBLISH_POSTS=true \ No newline at end of file +# FAIRPOST_FACEBOOK_PUBLISH_POSTS=true + +# instagram +# FAIRPOST_INSTAGRAM_APP_ID=xxx +# FAIRPOST_INSTAGRAM_APP_SECRET=xxx +# FAIRPOST_INSTAGRAM_USER_ID=xxx +# FAIRPOST_INSTAGRAM_PAGE_ID=xxx +# FAIRPOST_INSTAGRAM_PAGE_ACCESS_TOKEN=xxx diff --git a/src/platforms/AsReddit.ts b/src/platforms/AsReddit.ts index dd34398..cdaab8b 100644 --- a/src/platforms/AsReddit.ts +++ b/src/platforms/AsReddit.ts @@ -9,7 +9,7 @@ export default class AsReddit extends Ayrshare { constructor() { super(); - this.SUBREDDIT = process.env.FAIRPOST_REDDIT_SUBREDDIT; + this.SUBREDDIT = process.env.FAIRPOST_AYRSHARE_SUBREDDIT; } async preparePost(folder: Folder): Promise { diff --git a/src/platforms/Facebook.ts b/src/platforms/Facebook.ts index a0f3fbe..d5c00c9 100644 --- a/src/platforms/Facebook.ts +++ b/src/platforms/Facebook.ts @@ -56,7 +56,7 @@ export default class Facebook extends Platform { if (!dryrun) { try { response = await this.publishVideo( - post.files.video[0], + post.folder.path + "/" + post.files.video[0], post.title, post.body, ); diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index eaf7f23..cb73eb5 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -179,6 +179,7 @@ export default class Instagram extends Platform { caption: caption, })) as { id: string }; if (!container?.id) { + Logger.error("No id returned for container for " + file, container); throw new Error("No id returned for container for " + file); } @@ -189,6 +190,7 @@ export default class Instagram extends Platform { creation_id: container.id, })) as { id: string }; if (!response?.id) { + Logger.error("No id returned for igMedia for " + file, response); throw new Error("No id returned for igMedia for " + file); } return response; @@ -209,6 +211,7 @@ export default class Instagram extends Platform { caption: caption, })) as { id: string }; if (!container?.id) { + Logger.error("No id returned for container for " + file, container); throw new Error("No id returned for container for " + file); } @@ -219,6 +222,7 @@ export default class Instagram extends Platform { creation_id: container.id, })) as { id: string }; if (!response?.id) { + Logger.error("No id returned for igMedia for " + file, response); throw new Error("No id returned for igMedia for " + file); } return response; @@ -231,7 +235,65 @@ export default class Instagram extends Platform { post: Post, dryrun: boolean = false, ): Promise<{ id: string }> { - return { id: "-99" }; + const uploadIds = [] as string[]; + + for (const video of post.files.video) { + const videoId = ( + await this.fbUploadVideo(post.folder.path + "/" + video) + )["id"]; + const videoLink = await this.fbGetVideoLink(videoId); + uploadIds.push( + ( + await this.postJson("%USER%/media", { + is_carousel_item: true, + video_url: videoLink, + }) + )["id"], + ); + } + + for (const image of post.files.image) { + const photoId = ( + await this.fbUploadPhoto(post.folder.path + "/" + image) + )["id"]; + const photoLink = await this.fbGetPhotoLink(photoId); + uploadIds.push( + ( + await this.postJson("%USER%/media", { + is_carousel_item: true, + image_url: photoLink, + }) + )["id"], + ); + } + + // create carousel + const container = (await this.postJson("%USER%/media", { + media_type: "CAROUSEL", + caption: post.body, + children: uploadIds.join(","), + })) as { + id: string; + }; + if (!container["id"]) { + Logger.error("No id returned for carroussel container ", container); + throw new Error("No id returned for carroussel container "); + } + + // publish carousel + const response = dryrun + ? { id: "-99" } + : ((await this.postJson("%USER%/media_publish", { + creation_id: container["id"], + })) as { + id: string; + }); + if (!response["id"]) { + Logger.error("No id returned for igMedia for carroussel", response); + throw new Error("No id returned for igMedia for carroussel"); + } + + return response; } /* From dcd7b1cb70c3ff464ee450f5fdf09af1cf5f2bd6 Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 17:01:27 +0200 Subject: [PATCH 07/10] chore: Clean up instagram --- src/platforms/Instagram.ts | 82 +++++++------------------------------- 1 file changed, 14 insertions(+), 68 deletions(-) diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index cb73eb5..e5e404b 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -108,63 +108,7 @@ export default class Instagram extends Platform { } async test() { - return this.testUploadCarousel(); - } - - async testUploadCarousel() { - // upload photo to facebook - const photoId = ( - await this.fbUploadPhoto("/Users/pike/Desktop/test/test.jpg") - )["id"]; - if (!photoId) return; - - // get photo link - const photoData = (await this.get(photoId, { - fields: "link,images,picture", - })) as { - images: { - width: number; - height: number; - source: string; - }[]; - }; - if (!photoData) return; - - const maxPhoto = photoData.images?.reduce(function (prev, current) { - return prev && prev.width > current.width ? prev : current; - }); - if (!maxPhoto) return; - - const photoLink = maxPhoto["source"]; - - // upload link to instagram - const uploadId = ( - await this.postJson("%USER%/media", { - is_carousel_item: true, - image_url: photoLink, - }) - )["id"]; - if (!uploadId) return; - - // create carousel - const carouselId = ( - await this.postJson("%USER%/media", { - media_type: "CAROUSEL", - caption: "test", - children: [uploadId, uploadId].join(","), - }) - )["id"]; - if (!carouselId) return; - - // publish carousel - const igMediaId = ( - await this.postJson("%USER%/media_publish", { - creation_id: carouselId, - }) - )["id"]; - if (!igMediaId) return; - - return igMediaId; + return this.get(); } private async publishPhoto( @@ -281,19 +225,21 @@ export default class Instagram extends Platform { } // publish carousel - const response = dryrun - ? { id: "-99" } - : ((await this.postJson("%USER%/media_publish", { - creation_id: container["id"], - })) as { - id: string; - }); - if (!response["id"]) { - Logger.error("No id returned for igMedia for carroussel", response); - throw new Error("No id returned for igMedia for carroussel"); + if (!dryrun) { + const response = (await this.postJson("%USER%/media_publish", { + creation_id: container["id"], + })) as { + id: string; + }; + if (!response["id"]) { + Logger.error("No id returned for igMedia for carroussel", response); + throw new Error("No id returned for igMedia for carroussel"); + } + + return response; } - return response; + return { id: "-99" }; } /* From 117656e249214d677cf33a7d8f5d0dc740447a60 Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 23:25:25 +0200 Subject: [PATCH 08/10] feat: Tested instagram --- src/Feed.ts | 7 +++++-- src/platforms/Ayrshare.ts | 17 +++++++---------- src/platforms/Instagram.ts | 5 +++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Feed.ts b/src/Feed.ts index e212a61..2bf8910 100644 --- a/src/Feed.ts +++ b/src/Feed.ts @@ -60,8 +60,11 @@ export default class Feed { getPlatforms(platformIds?: PlatformId[]): Platform[] { Logger.trace("Feed", "getPlatforms", platformIds); return ( - platformIds?.map((platformId) => this.platforms[platformId]) ?? - Object.values(this.platforms) + platformIds + ?.map((platformId) => this.platforms[platformId]) + .filter(function (p) { + return p !== undefined; + }) ?? Object.values(this.platforms) ); } diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts index 14f63cd..9d2c85d 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -73,7 +73,7 @@ export default abstract class Ayrshare extends Platform { if (!dryrun) { if (!error) { - post.link = response.postIds[0]?.postUrl ?? ""; + post.link = response.postIds?.find(e=>!!e)?.postUrl ?? ""; post.status = PostStatus.PUBLISHED; post.published = new Date(); } else { @@ -115,20 +115,17 @@ export default abstract class Ayrshare extends Platform { Logger.trace("uploading..", uname, data); - (await fetch(data.uploadUrl, { + await fetch(data.uploadUrl, { method: "PUT", headers: { "Content-Type": data.contentType, Authorization: `Bearer ${APIKEY}`, }, body: buffer, - }) - .then((res) => this.handleApiResponse(res)) - .catch((err) => this.handleApiError(err))) as { - uploadUrl: string; - contentType: string; - accessUrl: string; - }; + }).catch(error => { + Logger.error(error); + throw new Error('Failed uploading '+file); + }); urls.push(data.accessUrl.replace(/ /g, "%20")); } @@ -167,7 +164,7 @@ export default abstract class Ayrshare extends Platform { requiresApproval: this.requiresApproval, }, ); - Logger.trace("scheduling...", postPlatform); + Logger.trace("publishing...", postPlatform); const response = (await fetch("https://app.ayrshare.com/api/post", { method: "POST", headers: { diff --git a/src/platforms/Instagram.ts b/src/platforms/Instagram.ts index e5e404b..679bf2f 100644 --- a/src/platforms/Instagram.ts +++ b/src/platforms/Instagram.ts @@ -42,12 +42,13 @@ export default class Instagram extends Platform { image, extension ? "." + extension : "", ); + const outfile = "_instagram-" + basename + ".JPEG"; await sharp(post.folder.path + "/" + image) .resize({ width: 1440, }) - .toFile(post.folder.path + "/_instagram-" + basename + ".JPEG"); - post.files.image.push("_instagram-" + image); + .toFile(post.folder.path + "/" + outfile); + post.files.image.push(outfile); post.files.image = post.files.image.filter((file) => file !== image); } } From 3fc3a83387ea234182c3aa35012b6f4b53b1ca8c Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 23:30:58 +0200 Subject: [PATCH 09/10] chore: Up version number --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 63978b0..e50bb48 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ayrshare-feed", - "version": "1.0.0", - "description": "Feeds data to ayrshare based on folders in feed dir; marks folder that are done.", + "name": "fairpost", + "version": "1.1.0", + "description": "Feeds data to social media platforms based on folders in a dir", "main": "index.js", "scripts": { "build": "tsc", From 2deaa8b9e2ffea05bc77b7a9cbc68c8ee6c8ca82 Mon Sep 17 00:00:00 2001 From: pike Date: Sat, 21 Oct 2023 23:31:36 +0200 Subject: [PATCH 10/10] fix: Lint --- src/platforms/Ayrshare.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts index 9d2c85d..77b17d1 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -73,7 +73,7 @@ export default abstract class Ayrshare extends Platform { if (!dryrun) { if (!error) { - post.link = response.postIds?.find(e=>!!e)?.postUrl ?? ""; + post.link = response.postIds?.find((e) => !!e)?.postUrl ?? ""; post.status = PostStatus.PUBLISHED; post.published = new Date(); } else { @@ -122,9 +122,9 @@ export default abstract class Ayrshare extends Platform { Authorization: `Bearer ${APIKEY}`, }, body: buffer, - }).catch(error => { - Logger.error(error); - throw new Error('Failed uploading '+file); + }).catch((error) => { + Logger.error(error); + throw new Error("Failed uploading " + file); }); urls.push(data.accessUrl.replace(/ /g, "%20"));