diff --git a/.env.dist b/.env.dist index 3885c58..a496be8 100644 --- a/.env.dist +++ b/.env.dist @@ -1,7 +1,18 @@ FAIRPOST_FEED_PATH=feed FAIRPOST_FEED_INTERVAL=6 #days -FAIRPOST_FEED_PLATFORMS=asyoutube,asfacebook,aslinkedin,asinstagram,astiktok,asreddit,astwitter +FAIRPOST_FEED_PLATFORMS= +# FAIRPOST_FEED_PLATFORMS=facebook,asyoutube,asfacebook,aslinkedin,asinstagram,astiktok,asreddit,astwitter + +# AYRSHARE FAIRPOST_AYRSHARE_API_KEY=xxxx -FAIRPOST_REDDIT_SUBREDDIT=generative \ No newline at end of file +FAIRPOST_REDDIT_SUBREDDIT=generative + + +# facebook +# FAIRPOST_FACEBOOK_APP_ID=xxx +# 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 diff --git a/README.md b/README.md index 4a60dcd..61a1bfe 100644 --- a/README.md +++ b/README.md @@ -71,32 +71,50 @@ fairpost.js publish-due-posts This will publish any scheduled posts that are past their due date. -## Arguments +## Other commands -Each of these commands (and others) accept `--arguments` +Other commands accept `--arguments` that may help you, for example, to immediately publish a certain post to a certain platform if you like. But more commonly, you would call this script -every day and just add posts to the feed folder as -time goes by. -The script will then automatically prepare these posts, +every day. +The script will then automatically prepare the posts, schedule the next post using a certain interval, publish any post when it is due, and schedule the -next post automatically. +next post automatically. All you have to do is +add folders with content. ## Cli ``` -fairpost.js help -fairpost.js get-feed +# basic commands: +# basic commands: +fairpost.js help +fairpost.js get-feed [--config=xxx] +fairpost.js test-platform --platform=xxx +fairpost.js test-platforms [--platforms=xxx,xxx] +fairpost.js get-platform --platform=xxx +fairpost.js get-platforms [--platforms=xxx,xxx] +fairpost.js get-folder --folder=xxx fairpost.js get-folders [--folders=xxx,xxx] -fairpost.js prepare-posts [--platforms=xxx,xxx] [--folders=xxx,xxx] -fairpost.js get-posts [--status=xxx] [--platforms=xxx,xxx] [--folders=xxx,xxx] -fairpost.js schedule-next-post [--date=xxxx-xx-xx] [--platforms=xxx,xxx] [--folders=xxx,xxx] -fairpost.js publish-due-posts [--platforms=xxx,xxx] [--folders=xxx,xxx] [--dry-run] +fairpost.js get-post --folder=xxx --platform=xxx +fairpost.js get-posts [--status=xxx] [--folders=xxx,xxx] [--platforms=xxx,xxx] +fairpost.js prepare-post --folder=xxx --platform=xxx +fairpost.js prepare-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] +fairpost.js schedule-post --folder=xxx --platform=xxx --date=xxxx-xx-xx +fairpost.js schedule-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] --date=xxxx-xx-xx +fairpost.js publish-post --folders=xxx --platforms=xxx [--dry-run] +fairpost.js publish-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] + +# feed planning: +fairpost.js schedule-next-post [--date=xxxx-xx-xx] [--folders=xxx,xxx] [--platforms=xxx,xxx] +fairpost.js publish-due-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] [--dry-run] + +# platform tools: +fairpost.js facebook-get-page-token --app-user-id=xxx --user-token=xxx ``` ### Common arguments diff --git a/docs/Facebook.md b/docs/Facebook.md new file mode 100644 index 0000000..f19484a --- /dev/null +++ b/docs/Facebook.md @@ -0,0 +1,99 @@ +# Platform: Facebook + +The `facebook` platform manage a facebook **page* (not your feed) +using the plain graph api - no extensions installed. + +## Setting up the Facebook platform + + +### Create a new App in your facebook account + - go to https://developers.facebook.com/ + - create an app that can manage pages + - for instagram, you'll need to attach a business account (...) that is connected to a facebook page + - under 'settings', find your app ID + - save this as `FAIRPOST_FACEBOOK_APP_ID` in your .env + - under 'settings', find your app secret + - save this as `FAIRPOST_FACEBOOK_APP_SECRET` in your .env + +### Find the page id of the page you want the app to manage + - go to https://business.facebook.com/ + - find your page (currently under 'settings > business assets') + - note the page id + - save this as `FAIRPOST_FACEBOOK_PAGE_ID` in your .env + +### Get a (short lived) Page Access Token for the page 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 + - request a (short lived) page access token + - save this as `FAIRPOST_FACEBOOK_PAGE_ACCESS_TOKEN` in your .env + +### Get a (long lived) Page Access Token for the page 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 + - 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_FACEBOOK_PAGE_ACCESS_TOKEN` in your .env + +### Enable and test the facebook platform + - Add 'facebook' to your `FAIRPOST_FEED_PLATFORMS` in `.env` + - call `./fairpost.js test --platforms=facebook` + +# Limitations + +## Images + +From https://developers.facebook.com/docs/graph-api/reference/page/photos/ : + +Facebook strips all location metadata before publishing and resizes images to different dimensions to best support rendering in multiple sizes. + + +### Supported Formats +Facebook supports the following formats: + - JPEG + - BMP + - PNG + - GIF + - TIFF + +### File Size + +Files must be 4MB or smaller in size. +For PNG files, try keep the file size below 1 MB. PNG files larger than 1 MB may appear pixelated after upload. + +# Random documentation + +https://dev.to/xaypanya/how-to-connect-your-nodejs-server-to-facebook-page-api-1hol +https://developers.facebook.com/docs/pages/getting-started +https://developers.facebook.com/docs/pages-api/posts +https://developers.facebook.com/docs/graph-api/reference/page/photos/ +https://developers.facebook.com/docs/video-api/guides/publishing + +large uploads: +https://developers.facebook.com/docs/graph-api/guides/upload/ + +https://www.npmjs.com/package/formdata-node +https://medium.com/deno-the-complete-reference/sending-form-data-using-fetch-in-node-js-8cedd0b2af85 \ No newline at end of file diff --git a/index.ts b/index.ts index 52aa835..052d22d 100644 --- a/index.ts +++ b/index.ts @@ -3,10 +3,12 @@ Fairpost cli handler */ +import * as path from 'path'; import Logger from './src/Logger'; import Feed from './src/Feed'; import { PostStatus } from './src/Post'; import { PlatformSlug } from './src/platforms'; +import Facebook from './src/platforms/Facebook'; // arguments const COMMAND = process.argv[2] ?? 'help' @@ -16,7 +18,9 @@ const CONFIG = (getOption('config') as string ) ?? '.env'; const DRY_RUN = !!getOption('dry-run') ?? false; const REPORT = (getOption('report') as string ) ?? 'text'; const PLATFORMS = (getOption('platforms') as string)?.split(',') as PlatformSlug[] ?? undefined; +const PLATFORM = (getOption('platform') as string) as PlatformSlug ?? undefined; const FOLDERS = (getOption('folders') as string)?.split(',') ?? undefined; +const FOLDER = (getOption('folder') as string) ?? undefined; const DATE = (getOption('date') as string) ?? undefined; const STATUS = (getOption('status') as PostStatus) ?? undefined; @@ -34,7 +38,7 @@ function getOption(key:string):boolean|string|null { /* main */ async function main() { - let result: any = ''; + let result: any; let report = ''; const feed = new Feed(CONFIG); @@ -47,6 +51,11 @@ async function main() { result = feed; report = 'Feed: '+feed.path; break; + case 'get-platform': + const platform = feed.getPlatform(PLATFORM); + report += 'Platform: '+platform.slug+'\n'; + result = platform; + break; case 'get-platforms': const platforms = feed.getPlatforms(PLATFORMS); platforms.forEach(platform => { @@ -54,6 +63,19 @@ async function main() { }); result = platforms; break; + case 'test-platform': + result = await feed.testPlatform(PLATFORM); + report = "Result: \n"+ JSON.stringify(result,null,'\t'); + break; + case 'test-platforms': + result = await feed.testPlatforms(PLATFORMS); + report = "Result: \n"+ JSON.stringify(result,null,'\t'); + break; + case 'get-folder': + const folder = feed.getFolder(FOLDER); + report += 'Folder: '+folder.path+'\n'; + result = folder; + break; case 'get-folders': const folders = feed.getFolders(FOLDERS); folders.forEach(folder => { @@ -61,6 +83,11 @@ async function main() { }); result = folders; break; + case 'get-post': + const post = feed.getPost(FOLDER, PLATFORM); + report += post.report(); + result = post; + break; case 'get-posts': const allposts = feed.getPosts({ paths:FOLDERS, @@ -72,6 +99,11 @@ async function main() { }); result = allposts; break; + case 'prepare-post': + const preppost = await feed.preparePost(FOLDER,PLATFORM); + report += preppost.report(); + result = preppost; + break; case 'prepare-posts': const prepposts = await feed.preparePosts({ paths:FOLDERS, @@ -82,6 +114,41 @@ async function main() { }); result = prepposts; break; + case 'schedule-post': + const schedpost = feed.schedulePost( + FOLDER,PLATFORM, new Date(DATE), + ); + report += schedpost.report(); + result = schedpost; + break; + case 'schedule-posts': + const schedposts = feed.schedulePosts({ + paths: FOLDERS, + platforms: PLATFORMS + }, new Date(DATE)); + schedposts.forEach(post => { + report += post.report(); + }); + result = schedposts; + break; + case 'publish-post': + const pubpost = await feed.publishPost(FOLDER,PLATFORM, DRY_RUN); + report += pubpost.report(); + result = pubpost; + break; + + case 'publish-posts': + const pubposts = await feed.publishPosts({ + paths:FOLDERS, + platforms:PLATFORMS + }, DRY_RUN); + pubposts.forEach(post => { + report += post.report(); + }); + result = pubposts; + break; + + /* feed planning */ case 'schedule-next-posts': const nextposts = feed.scheduleNextPosts(DATE ? new Date(DATE): undefined,{ paths:FOLDERS, @@ -93,31 +160,55 @@ async function main() { result = nextposts; break; case 'publish-due-posts': - const pubposts = await feed.publishDuePosts({ + const dueposts = await feed.publishDuePosts({ paths:FOLDERS, platforms:PLATFORMS }, DRY_RUN); pubposts.forEach(post => { report += post.report(); }); - result = nextposts; + result = dueposts; break; + + /* platform specific tools */ + case 'facebook-get-page-token': + const userToken = (getOption('user-token') as string ); + const appUserId = (getOption('app-user-id') as string ); + const facebook = new Facebook(); + result = await facebook.getPageToken(appUserId, userToken); + report = 'Page Token: '+result; + break; + default: - const cmd = process.argv[1]; + const cmd = path.basename(process.argv[1]); result = [ + '# basic commands:', `${cmd} help`, `${cmd} get-feed [--config=xxx]`, + `${cmd} test-platform --platform=xxx`, + `${cmd} test-platforms [--platforms=xxx,xxx]`, + `${cmd} get-platform --platform=xxx`, `${cmd} get-platforms [--platforms=xxx,xxx]`, + `${cmd} get-folder --folder=xxx`, `${cmd} get-folders [--folders=xxx,xxx]`, - `${cmd} prepare-posts [--platforms=xxx,xxx] [--folders=xxx,xxx]`, - `${cmd} get-posts [--status=xxx] [--platforms=xxx,xxx] [--folders=xxx,xxx]`, - `${cmd} schedule-next-post [--date=xxxx-xx-xx] [--platforms=xxx,xxx] [--folders=xxx,xxx]`, - `${cmd} publish-due-posts [--platforms=xxx,xxx] [--folders=xxx,xxx] [--dry-run]` + `${cmd} get-post --folder=xxx --platform=xxx`, + `${cmd} get-posts [--status=xxx] [--folders=xxx,xxx] [--platforms=xxx,xxx] `, + `${cmd} prepare-post --folder=xxx --platform=xxx`, + `${cmd} prepare-posts [--folders=xxx,xxx] [--platforms=xxx,xxx]`, + `${cmd} schedule-post --folder=xxx --platform=xxx --date=xxxx-xx-xx `, + `${cmd} schedule-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] --date=xxxx-xx-xx`, + `${cmd} publish-post --folders=xxx --platforms=xxx [--dry-run]`, + `${cmd} publish-posts [--folders=xxx,xxx] [--platforms=xxx,xxx]`, + '\n# feed planning:', + `${cmd} schedule-next-post [--date=xxxx-xx-xx] [--folders=xxx,xxx] [--platforms=xxx,xxx] `, + `${cmd} publish-due-posts [--folders=xxx,xxx] [--platforms=xxx,xxx] [--dry-run]`, + '\n# platform tools:', + `${cmd} facebook-get-page-token --app-user-id=xxx --user-token=xxx` ]; result.forEach(line => report += '\n'+line); } } catch (e) { - console.error(e.getMessage()); + console.error(e.message); } switch(REPORT) { diff --git a/src/Feed.ts b/src/Feed.ts index aebe7c8..ff523cb 100644 --- a/src/Feed.ts +++ b/src/Feed.ts @@ -37,6 +37,7 @@ export default class Feed { const platformClasses = fs.readdirSync(path.resolve(__dirname+'/platforms')); platformClasses.forEach(file=> { const constructor = file.replace('.ts','').replace('.js',''); + // nb import * as platforms loaded the constructors if (platforms[constructor] !== undefined) { const platform = new platforms[constructor](); platform.active = activePlatformSlugs.includes(platform.slug); @@ -47,11 +48,31 @@ export default class Feed { }); } + getPlatform(platform:PlatformSlug): Platform { + Logger.trace('Feed','getPlatform',platform); + return this.getPlatforms([platform])[0]; + } + getPlatforms(platforms?:PlatformSlug[]): Platform[] { - Logger.trace('Feed','getPlatforms'); + Logger.trace('Feed','getPlatforms',platforms); return platforms?.map(platform=>this.platforms[platform]) ?? Object.values(this.platforms); } + async testPlatform(platform:PlatformSlug): Promise<{}> { + Logger.trace('Feed','testPlatform',platform); + const results = await this.testPlatforms([platform]); + return results[platform]; + } + + async testPlatforms(platforms?:PlatformSlug[]): Promise<{ [slug:string] : {}}> { + Logger.trace('Feed','testPlatforms',platforms); + const results = {}; + for (const platform of this.getPlatforms(platforms)) { + results[platform.slug] = await platform.test(); + } + return results; + } + getAllFolders(): Folder[] { Logger.trace('Feed','getAllFolders'); if (this.folders.length) { @@ -70,11 +91,21 @@ export default class Feed { return this.folders; } + getFolder(path: string): Folder | undefined { + Logger.trace('Feed','getFolder',path); + return this.getFolders([path])[0]; + } + getFolders(paths?: string[]): Folder[] { - Logger.trace('Feed','getFolders'); + Logger.trace('Feed','getFolders',paths); return paths?.map(path=>new Folder(this.path+'/'+path)) ?? this.getAllFolders(); } + getPost(path: string, platform: PlatformSlug): Post | undefined { + Logger.trace('Feed','getPost'); + return this.getPosts({paths:[path],platforms:[platform]})[0]; + } + getPosts(filters?: { paths?:string[] platforms?:PlatformSlug[], @@ -95,11 +126,16 @@ export default class Feed { return posts; } + async preparePost(path: string, platform: PlatformSlug): Promise { + Logger.trace('Feed','preparePost',path,platform); + return (await this.preparePosts({paths:[path],platforms:[platform]}))[0]; + } + async preparePosts(filters?: { paths?:string[] platforms?:PlatformSlug[] }): Promise { - Logger.trace('Feed','preparePosts'); + Logger.trace('Feed','preparePosts',filters); const posts: Post[] = []; const platforms = this.getPlatforms(filters?.platforms); const folders = this.getFolders(filters?.paths); @@ -117,7 +153,92 @@ export default class Feed { return posts; } - + schedulePost(path: string, platform: PlatformSlug, date: Date): Post { + Logger.trace('Feed','schedulePost',path,platform,date); + const post = this.getPost(path,platform); + if (!post.valid) { + throw new Error('Post is not valid'); + } + if (post.status!==PostStatus.UNSCHEDULED) { + throw new Error('Post is not unscheduled'); + } + post.schedule(date); + return post; + } + + schedulePosts(filters: { + paths?:string[] + platforms?:PlatformSlug[] + }, date: Date): Post[] { + Logger.trace('Feed','schedulePosts',filters,date); + const posts: Post[] = []; + const platforms = this.getPlatforms(filters?.platforms); + const folders = this.getFolders(filters?.paths); + for (const platform of platforms) { + for (const folder of folders) { + const post = platform.getPost(folder); + if (!post.valid) { + throw new Error('Post is not valid'); + } + if (post.status!==PostStatus.UNSCHEDULED) { + throw new Error('Post is not unscheduled'); + } + post.schedule(date); + posts.push(post); + } + } + return posts; + } + + async publishPost( + path:string, + slug:PlatformSlug, + dryrun:boolean = false + ): Promise { + Logger.trace('Feed','publishPost',path,slug,dryrun); + const now = new Date(); + const platform = this.getPlatform(slug); + const folder = this.getFolder(path); + const post = platform.getPost(folder); + if (post.valid) { + post.schedule(now); + Logger.trace('Posting',slug,path); + await platform.publishPost(post,dryrun); + } else { + throw new Error('Post is not valid'); + } + return post; + } + + async publishPosts(filters?: { + paths?:string[] + platforms?:PlatformSlug[] + }, dryrun:boolean = false): Promise { + Logger.trace('Feed','publishPosts',filters,dryrun); + const now = new Date(); + const posts: Post[] = []; + const platforms = this.getPlatforms(filters?.platforms); + const folders = this.getFolders(filters?.paths); + for (const platform of platforms) { + for (const folder of folders) { + const post = platform.getPost(folder); + if (post.valid) { + post.schedule(now); + Logger.trace('Posting',platform.slug,folder.path); + await platform.publishPost(post,dryrun); + posts.push(post); + } else { + Logger.warn('Skipping invalid post',platform.slug,folder.path); + } + } + } + return posts; + } + + /* + feed planning + */ + getLastPost(platform:PlatformSlug): Post | void { Logger.trace('Feed','getLastPost'); let lastPost: Post = undefined; diff --git a/src/Platform.ts b/src/Platform.ts index 8d09a18..1e3e180 100644 --- a/src/Platform.ts +++ b/src/Platform.ts @@ -122,6 +122,17 @@ export default class Platform { post.save(); return false; } + + /* + * test + * + * Test the platform installation. This should not post + * anything, but test access tokens et al. It can return + * anything. + */ + async test(): Promise { + return 'No tests'; + } } diff --git a/src/platforms/AsFacebook.ts b/src/platforms/AsFacebook.ts index 17a1105..e17eaf0 100644 --- a/src/platforms/AsFacebook.ts +++ b/src/platforms/AsFacebook.ts @@ -1,4 +1,5 @@ +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; diff --git a/src/platforms/AsInstagram.ts b/src/platforms/AsInstagram.ts index 425943c..91c384b 100644 --- a/src/platforms/AsInstagram.ts +++ b/src/platforms/AsInstagram.ts @@ -1,4 +1,5 @@ +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; @@ -17,10 +18,10 @@ export default class AsInstagram extends Ayrshare { if (post) { // instagram: 1 video for reel if (post.files.video.length) { - console.log('Removing images for instagram reel..'); + Logger.trace('Removing images for instagram reel..'); post.files.image = []; if (post.files.video.length > 1) { - console.log('Using first video for instagram reel..'); + Logger.trace('Using first video for instagram reel..'); post.files.video = [post.files.video[0]]; } } @@ -28,7 +29,7 @@ export default class AsInstagram extends Ayrshare { for (const image of post.files.image) { const metadata = await sharp(post.folder.path+'/'+image).metadata(); if (metadata.width > 1440) { - console.log('Resizing '+image+' for instagram ..'); + Logger.trace('Resizing '+image+' for instagram ..'); await sharp(post.folder.path+'/'+image).resize({ width: 1440 }).toFile(post.folder.path+'/_instagram-'+image); diff --git a/src/platforms/AsLinkedIn.ts b/src/platforms/AsLinkedIn.ts index cd2fd6a..ab44b07 100644 --- a/src/platforms/AsLinkedIn.ts +++ b/src/platforms/AsLinkedIn.ts @@ -1,4 +1,5 @@ +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; @@ -27,7 +28,7 @@ export default class AsLinkedIn extends Ayrshare { for (const image of post.files.image) { var size = fs.statSync(post.folder.path+'/'+image).size / (1024*1024); if (size>=5) { - console.log('Resizing '+image+' for linkedin ..'); + Logger.trace('Resizing '+image+' for linkedin ..'); await sharp(post.folder.path+'/'+image).resize({ width: 1200 }).toFile(post.folder.path+'/_linkedin-'+image); diff --git a/src/platforms/AsReddit.ts b/src/platforms/AsReddit.ts index fa80d19..e0c2577 100644 --- a/src/platforms/AsReddit.ts +++ b/src/platforms/AsReddit.ts @@ -1,4 +1,4 @@ - +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; diff --git a/src/platforms/AsTikTok.ts b/src/platforms/AsTikTok.ts index 12cd2a3..aa1e871 100644 --- a/src/platforms/AsTikTok.ts +++ b/src/platforms/AsTikTok.ts @@ -1,4 +1,4 @@ - +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; diff --git a/src/platforms/AsTwitter.ts b/src/platforms/AsTwitter.ts index 9a35071..4db6804 100644 --- a/src/platforms/AsTwitter.ts +++ b/src/platforms/AsTwitter.ts @@ -1,4 +1,4 @@ - +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; @@ -26,7 +26,7 @@ export default class AsTwitter extends Ayrshare { for (const image of post.files.image) { var size = fs.statSync(post.folder.path+'/'+image).size / (1024*1024); if (size>=5) { - console.log('Resizing '+image+' for twitter ..'); + Logger.trace('Resizing '+image+' for twitter ..'); await sharp(post.folder.path+'/'+image).resize({ width: 1200 }).toFile(post.folder.path+'/_twitter-'+image); diff --git a/src/platforms/AsYouTube.ts b/src/platforms/AsYouTube.ts index e74513c..9cdf0ef 100644 --- a/src/platforms/AsYouTube.ts +++ b/src/platforms/AsYouTube.ts @@ -1,4 +1,4 @@ - +import Logger from '../Logger'; import Ayrshare from "./Ayrshare"; import { PlatformSlug } from "."; import Folder from "../Folder"; diff --git a/src/platforms/Ayrshare.ts b/src/platforms/Ayrshare.ts index 7d0e7b1..3f2d125 100644 --- a/src/platforms/Ayrshare.ts +++ b/src/platforms/Ayrshare.ts @@ -1,4 +1,5 @@ +import Logger from '../Logger'; import * as fs from 'fs'; import * as path from 'path'; import { randomUUID } from 'crypto'; @@ -80,7 +81,7 @@ export default abstract class Ayrshare extends Platform { const ext = path.extname(file); const basename = path.basename(file, ext); const uname = basename+'-'+randomUUID()+ext; - console.log('fetching uploadid...',file); + Logger.trace('fetching uploadid...',file); const res1 = await fetch("https://app.ayrshare.com/api/media/uploadUrl?fileName="+uname+"&contentType="+ext.substring(1), { method: "GET", headers: { @@ -94,7 +95,7 @@ export default abstract class Ayrshare extends Platform { const data = await res1.json(); //console.log(data); - console.log('uploading..',uname); + Logger.trace('uploading..',uname); const uploadUrl = data.uploadUrl; const contentType = data.contentType; const accessUrl = data.accessUrl; @@ -162,7 +163,7 @@ export default abstract class Ayrshare extends Platform { scheduleDate: scheduleDate, requiresApproval: this.requiresApproval }); - console.log('scheduling...',postPlatform); + Logger.trace('scheduling...',postPlatform); //console.log(body); const res = await fetch("https://app.ayrshare.com/api/post", { method: "POST", diff --git a/src/platforms/Facebook.ts b/src/platforms/Facebook.ts new file mode 100644 index 0000000..aac7b27 --- /dev/null +++ b/src/platforms/Facebook.ts @@ -0,0 +1,309 @@ + +import Logger from "../Logger"; +import Platform from "../Platform"; +import { PlatformSlug } 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 Facebook extends Platform { + slug: PlatformSlug = PlatformSlug.FACEBOOK; + GRAPH_API_VERSION: string = 'v18.0'; + + constructor() { + super(); + } + + async preparePost(folder: Folder): Promise { + const post = await super.preparePost(folder); + if (post && post.files) { + // facebook: video post can only contain 1 video + if (post.files.video.length) { + post.files.video.length = 1; + post.files.image = []; + } + // facebook : max 4mb images + for (const image of post.files.image) { + var size = fs.statSync(post.folder.path+'/'+image).size / (1024*1024); + if (size>=4) { + Logger.trace('Resizing '+image+' for facebook ..'); + await sharp(post.folder.path+'/'+image).resize({ + width: 1200 + }).toFile(post.folder.path+'/_facebook-'+image); + post.files.image.push('_facebook-'+image); + post.files.image = post.files.image.filter(file => file !== image); + } + } + post.save(); + } + return post; + } + + async publishPost(post: Post, dryrun:boolean = false): Promise { + + 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 { + 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)}); + } + } + if (!dryrun) { + result = await this.post( + 'feed', + { + "message":post.body, + "published":process.env.FAIRPOST_FACEBOOK_PUBLISH_POSTS, + //"scheduled_publish_time":"tomorrow", + "attached_media": attachments + } + ); + } + } + + post.results.push(result); + if (result.id) { + if (!dryrun) { + post.status = PostStatus.PUBLISHED; + } + } else { + console.error(this.slug,"No id returned in post",result); + } + post.save(); + return !!result.id; + + } + + async test() { + return this.get(); + } + + + /* + * 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 + * + * 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 { + + 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 res.json(); + if (!result['id']) { + console.error(result); + throw new Error('No id returned when uploading photo'); + } + return result['id']; + + } + + /* + * POST a video to the page using multipart/form-data + * + * arguments: + * file: path to the video to post + * published: wether to publish it or not + * + * returns: + * { id: string } + */ + private async publishVideo( + file: string, + title: string, + description: string + ): 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 += "/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 res.json(); + if (!result['id']) { + console.error(result); + throw new Error('No id returned when uploading video'); + } + return result; + + } + + /* + * Return a long lived page access token. + * + * 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 = { + 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 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"; + 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(); + + 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('No llPageAccessToken for page '+process.env.FAIRPOST_FACEBOOK_PAGE_ID+' in response.'); + } + + return llPageAccessToken; + } + + +} \ No newline at end of file diff --git a/src/platforms/index.ts b/src/platforms/index.ts index 72c22bd..7c5e71f 100644 --- a/src/platforms/index.ts +++ b/src/platforms/index.ts @@ -5,6 +5,7 @@ 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 PlatformSlug { UNKNOWN = "unknown", @@ -14,5 +15,6 @@ export enum PlatformSlug { ASTWITTER = "astwitter", ASTIKTOK = "astiktok", ASLINKEDIN = "aslinkedin", - ASREDDIT = "asreddit" + ASREDDIT = "asreddit", + FACEBOOK = "facebook" } \ No newline at end of file