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/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 new file mode 100644 index 0000000..6ae2f77 --- /dev/null +++ b/docs/Ayrshare.md @@ -0,0 +1,36 @@ +# 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. + +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 +- 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/docs/Instagram.md b/docs/Instagram.md new file mode 100644 index 0000000..b5affd3 --- /dev/null +++ b/docs/Instagram.md @@ -0,0 +1,116 @@ +# 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 **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 + + +### 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/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", 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/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 8fc6597..00742c8 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; @@ -21,6 +21,7 @@ export default class Post { video: string[]; other: string[]; }; + link?: string; constructor(folder: Folder, platform: Platform, data?: object) { this.folder = folder; @@ -70,6 +71,14 @@ export default class Post { } } +export interface PostResult { + date: Date; + dryrun?: boolean; + error?: Error; + success: boolean; + response: object; +} + export enum PostStatus { UNKNOWN = "unknown", UNSCHEDULED = "unscheduled", 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/Ayrshare.ts b/src/platforms/Ayrshare.ts index 6d78222..77b17d1 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -8,12 +8,6 @@ import Folder from "../Folder"; import Post from "../Post"; import { PostStatus } from "../Post"; -interface AyrshareResult { - success: boolean; - error?: Error; - response: object; -} - export default abstract class Ayrshare extends Platform { requiresApproval: boolean = false; @@ -43,34 +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, - uploads: uploads, - success: true, - response: {}, - }); - 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); - post.results.push(result); - if (result.success) { - post.status = PostStatus.PUBLISHED; - post.published = new Date(); + post.results.push({ + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }); + + if (error) { + Logger.error("Ayrshare.publishPost", this.id, "failed", response); } - post.save(); - if (!result.success) { - console.error(result.error); + if (!dryrun) { + if (!error) { + post.link = response.postIds?.find((e) => !!e)?.postUrl ?? ""; + post.status = PostStatus.PUBLISHED; + post.published = new Date(); + } else { + post.status = PostStatus.FAILED; + } } - return result.success ?? false; + + post.save(); + return !error; } async uploadMedia(media: string[]): Promise { @@ -82,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=" + @@ -93,33 +105,29 @@ export default abstract class Ayrshare extends Platform { Authorization: `Bearer ${APIKEY}`, }, }, - ); + ) + .then((res) => this.handleApiResponse(res)) + .catch((err) => this.handleApiError(err))) as { + uploadUrl: string; + contentType: string; + accessUrl: string; + }; - if (!res1) { - return []; - } + Logger.trace("uploading..", uname, data); - const data = await res1.json(); - //console.log(data); - Logger.trace("uploading..", uname); - const uploadUrl = data.uploadUrl; - const contentType = data.contentType; - const accessUrl = data.accessUrl; - - const res2 = await fetch(uploadUrl, { + await fetch(data.uploadUrl, { method: "PUT", headers: { - "Content-Type": contentType, + "Content-Type": data.contentType, Authorization: `Bearer ${APIKEY}`, }, body: buffer, + }).catch((error) => { + Logger.error(error); + throw new Error("Failed uploading " + file); }); - if (!res2) { - return []; - } - - urls.push(accessUrl.replace(/ /g, "%20")); + urls.push(data.accessUrl.replace(/ /g, "%20")); } return urls; } @@ -128,23 +136,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 @@ -155,19 +156,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 @@ -176,46 +164,74 @@ export default abstract class Ayrshare extends Platform { requiresApproval: this.requiresApproval, }, ); - Logger.trace("scheduling...", postPlatform); - //console.log(body); - const res = await fetch("https://app.ayrshare.com/api/post", { + Logger.trace("publishing...", postPlatform); + 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 { - status?: string; - }; - if ( - result.response["status"] !== "success" && - result.response["status"] !== "scheduled" - ) { - console.error("* Failed."); - result.error = new Error( - "Bad result status: " + result.response["status"], - ); - } else { - console.error(" .. Published."); - result.success = true; - } - return result; - } - const response = await res.json(); - console.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); } - console.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 ed917a2..d5c00c9 100644 --- a/src/platforms/Facebook.ts +++ b/src/platforms/Facebook.ts @@ -45,46 +45,73 @@ 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; error?: string }); + let error = undefined; if (post.files.video.length) { if (!dryrun) { - result = await this.publishVideo( - post.files.video[0], - post.title, - post.body, - ); + try { + response = await this.publishVideo( + post.folder.path + "/" + 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), - }); + 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) { - result = await this.post("feed", { - message: post.body, - published: process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, - //"scheduled_publish_time":"tomorrow", - attached_media: attachments, - }); + 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; } } - post.results.push(result); - if (result.id) { - if (!dryrun) { - post.status = PostStatus.PUBLISHED; + post.results.push({ + date: new Date(), + dryrun: dryrun, + success: !error, + error: error, + response: response, + }); + + 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; } - } else { - console.error(this.id, "No id returned in post", result); } + post.save(); - return !!result.id; + return !error; } async test() { @@ -92,68 +119,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 +130,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 +168,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 +187,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 +235,160 @@ 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 { + 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 = + 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/Instagram.ts b/src/platforms/Instagram.ts new file mode 100644 index 0000000..679bf2f --- /dev/null +++ b/src/platforms/Instagram.ts @@ -0,0 +1,499 @@ +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) { + 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; + } + } + + // 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 : "", + ); + const outfile = "_instagram-" + basename + ".JPEG"; + await sharp(post.folder.path + "/" + image) + .resize({ + width: 1440, + }) + .toFile(post.folder.path + "/" + outfile); + post.files.image.push(outfile); + 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 { + Logger.trace("Instagram.publishPost", post, dryrun); + + 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 { + 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() { + return this.get(); + } + + 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) { + Logger.error("No id returned for container for " + file, container); + 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) { + Logger.error("No id returned for igMedia for " + file, response); + 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) { + Logger.error("No id returned for container for " + file, container); + 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) { + Logger.error("No id returned for igMedia for " + file, response); + 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 }> { + 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 + 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 { id: "-99" }; + } + + /* + * 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 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", "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 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 ------------------- + + /* + * 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 { + 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 = + 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", }