@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
325 lines (324 loc) • 13.1 kB
JavaScript
/**
* @fileoverview LinkedIn strategy for posting messages.
* @author Nicholas C. Zakas
*/
/* global fetch */
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @typedef {Object} LinkedInOptions
* @property {string} accessToken The access token for the LinkedIn API.
*/
/**
* @typedef {Object} LinkedInPostResponse
* @property {string} id The URN of the newly created post.
*/
/**
* @typedef {Object} LinkedInUserInfoResponse
* @property {string} name The name of the user.
* @property {string} sub The subject identifier for the user.
* @property {Object} locale The locale information of the user.
* @property {string} locale.country The country code of the user's locale.
* @property {string} locale.language The language code of the user's locale.
* @property {string} given_name The given name of the user.
* @property {string} family_name The family name of the user.
* @property {string} picture The URL of the user's profile picture.
*/
/*
{"errorDetailType":"com.linkedin.common.
error.BadRequest","message":"com.linkedi
n.content.common.exception.BadRequestRes
ponseException: Content is a duplicate o
f urn:li:share:7295588489876647936","err
orDetails":{"inputErrors":[{"description
":"Duplicate post is detected","input":{
},"code":"DUPLICATE_POST"}]},"status":42
2}
*/
/**
* @typedef {Object} LinkedInMedia
* @property {string} media - The URN of the media asset
* @property {string} status - The status of the media (e.g., "READY")
* @property {Object} title - The title of the media
* @property {Array<Object>} [title.attributes] - Attributes for the title
* @property {string} title.text - The text of the media title
*/
/**
* @typedef {Object} LinkedInShareContent
* @property {LinkedInMedia[]} [media] - Array of media attachments
* @property {Object} shareCommentary - The main text content of the post
* @property {Array<Object>} [shareCommentary.attributes] - Attributes for the text
* @property {string} shareCommentary.text - The text content
* @property {string} shareMediaCategory - The type of media being shared (e.g., "VIDEO", "IMAGE", "NONE")
*/
/**
* @typedef {Object} LinkedInPostBody
* @property {string} author - The URN identifier of the post author (person or organization)
* @property {string} lifecycleState - The state of the post (e.g., "PUBLISHED")
* @property {Record<"com.linkedin.ugc.ShareContent",LinkedInShareContent>} specificContent - The content-specific details of the post
* @property {Record<string,string>} visibility - Visibility settings for the post
*/
/**
* @typedef {Object} LinkedInErrorResponse
* @property {string} errorDetailType The type of error detail.
* @property {string} message The error message.
* @property {Object} errorDetails The details of the error.
* @property {Array<Object>} errorDetails.inputErrors The input errors.
* @property {string} errorDetails.inputErrors[].description The description of the input error.
* @property {Object} errorDetails.inputErrors[].input The input object.
* @property {string} errorDetails.inputErrors[].code The error code.
* @property {number} status The HTTP status code.
*/
/**
* @typedef {Object} LinkedInServiceRelationship
* @property {string} identifier The service identifier URN
* @property {string} relationshipType The type of relationship (e.g. "OWNER")
*/
/**
* @typedef {Object} LinkedInRequestUploadRequestBody
* @property {string} owner The URN identifier of the owner (organization or person)
* @property {string[]} recipes Array of recipe URNs for the upload (e.g. "urn:li:digitalmediaRecipe:feedshare-image")
* @property {LinkedInServiceRelationship[]} serviceRelationships Array of service relationship objects
* @property {string[]} supportedUploadMechanism Array of supported upload mechanisms (e.g. "SYNCHRONOUS_UPLOAD")
*/
/**
* @typedef {Object} LinkedInUploadMechanism
* @property {string} uploadUrl The URL to upload the media to.
* @property {Record<string,string>} headers The headers for the upload request.
*/
/**
* @typedef {Object} LinkedInRequestUploadResponse
* @property {Object} value The response value object
* @property {string} value.mediaArtifact The URN of the media artifact
* @property {Record<string, LinkedInUploadMechanism>} value.uploadMechanism The upload mechanism details
* @property {string} value.asset The URN of the digital media asset
* @property {string} value.assetRealTimeTopic The real-time topic URN for asset updates
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const POST_URL = "https://api.linkedin.com/v2/ugcPosts";
const USER_INFO_URL = "https://api.linkedin.com/v2/userinfo";
/**
* Retrieves the person URN from LinkedIn.
* @param {string} accessToken The access token for the LinkedIn API.
* @param {AbortSignal} [signal] The abort signal for the request.
* @returns {Promise<string>} A promise that resolves with the person URN.
* @throws {Error} When the request fails.
*/
async function fetchPersonUrn(accessToken, signal) {
const response = await fetch(USER_INFO_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
signal,
});
if (!response.ok) {
throw new Error(`${response.status} Failed to retrieve person URN: ${response.statusText}`);
}
const data = /** @type {LinkedInUserInfoResponse} */ (await response.json());
return `urn:li:person:${data.sub}`;
}
/**
* Uploads an image to LinkedIn.
* @param {string} accessToken The access token for the LinkedIn API.
* @param {string} personUrn The person URN to use for the upload.
* @param {Uint8Array} imageData The image data to upload.
* @param {AbortSignal} [signal] The abort signal for the request.
* @returns {Promise<string>} A promise that resolves with the asset URN.
* @throws {Error} When the request fails.
*/
async function uploadImage(accessToken, personUrn, imageData, signal) {
const response = await fetch("https://api.linkedin.com/v2/assets?action=registerUpload", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
registerUploadRequest: {
recipes: ["urn:li:digitalmediaRecipe:feedshare-image"],
owner: personUrn,
serviceRelationships: [
{
relationshipType: "OWNER",
identifier: "urn:li:userGeneratedContent",
},
],
},
signal,
}),
});
if (!response.ok) {
throw new Error(`${response.status} Failed to register image upload: ${response.statusText}`);
}
const { value: { asset, uploadMechanism }, } = /** @type {LinkedInRequestUploadResponse} */ (await response.json());
const uploadResponse = await fetch(uploadMechanism["com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"].uploadUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "image/*",
},
body: imageData,
signal,
});
if (!uploadResponse.ok) {
throw new Error(`${uploadResponse.status} Failed to upload image: ${uploadResponse.statusText}`);
}
return asset;
}
/**
* Creates a post on LinkedIn.
* @param {LinkedInOptions} options The options for the strategy.
* @param {string} personUrn The person URN to use for the post.
* @param {string} message The message to post.
* @param {import("../types.js").PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<LinkedInPostResponse>} A promise that resolves with the post data.
*/
async function createPost(options, personUrn, message, postOptions) {
/** @type {LinkedInPostBody} */
const body = {
author: personUrn,
lifecycleState: "PUBLISHED",
specificContent: {
"com.linkedin.ugc.ShareContent": {
shareCommentary: {
text: message,
},
shareMediaCategory: "NONE",
},
},
visibility: {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC",
},
};
// handle image uploads if present
if (postOptions?.images?.length) {
const images = postOptions.images;
const mediaAssets = await Promise.all(images.map(image => uploadImage(options.accessToken, personUrn, image.data, postOptions?.signal)));
body.specificContent["com.linkedin.ugc.ShareContent"].shareMediaCategory = "IMAGE";
body.specificContent["com.linkedin.ugc.ShareContent"].media =
mediaAssets.map((asset, index) => ({
status: "READY",
description: {
text: images[index].alt || "",
},
media: asset,
title: {
text: "",
},
}));
}
const response = await fetch(POST_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${options.accessToken}`,
"Content-Type": "application/json",
"X-Restli-Protocol-Version": "2.0.0",
},
body: JSON.stringify(body),
signal: postOptions?.signal,
});
if (!response.ok) {
const errorResponse = /** @type {LinkedInErrorResponse} */ (await response.json());
throw new Error(`${response.status} Failed to create post: ${response.statusText}\n${errorResponse.message}`);
}
return /** @type {Promise<LinkedInPostResponse>} */ (response.json());
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* A strategy for posting messages to LinkedIn.
*/
export class LinkedInStrategy {
/**
* The ID of the strategy.
* @type {string}
* @readonly
*/
id = "linkedin";
/**
* The display name of the strategy.
* @type {string}
* @readonly
*/
name = "LinkedIn";
/**
* Maximum length of a LinkedIn post in characters.
* @type {number}
* @const
*/
MAX_MESSAGE_LENGTH = 3000;
/**
* Options for this instance.
* @type {LinkedInOptions}
*/
#options;
/**
* Cached person URN.
* @type {Promise<string>|null}
*/
#personUrn = null;
/**
* Creates a new instance.
* @param {LinkedInOptions} options Options for the instance.
* @throws {Error} When required options are missing.
*/
constructor(options) {
const { accessToken } = options;
if (!accessToken) {
throw new TypeError("Missing access token.");
}
this.#options = options;
}
/**
* Gets the person URN, fetching it if not already cached.
* @returns {Promise<string>} A promise that resolves with the person URN.
*/
async #getPersonUrn() {
if (!this.#personUrn) {
this.#personUrn = fetchPersonUrn(this.#options.accessToken);
}
return this.#personUrn;
}
/**
* Posts a message to LinkedIn.
* @param {string} message The message to post.
* @param {import("../types.js").PostOptions} [postOptions] Additional options for the post.
* @returns {Promise<LinkedInPostResponse>} A promise that resolves with the post data.
* @throws {TypeError} If message is missing.
*/
async post(message, postOptions) {
if (!message) {
throw new TypeError("Missing message to post.");
}
const personUrn = await this.#getPersonUrn();
return createPost(this.#options, personUrn, message, postOptions);
}
/**
* Extracts a URL from a LinkedIn API response.
* @param {LinkedInPostResponse} response The response from the LinkedIn API post request.
* @returns {string} The URL for the LinkedIn post.
*/
getUrlFromResponse(response) {
if (!response?.id) {
throw new Error("Post ID not found in response");
}
// LinkedIn post URN looks like: urn:li:share:123456789
// Convert this to a URL like https://www.linkedin.com/feed/update/urn:li:share:123456789
return `https://www.linkedin.com/feed/update/${response.id}`;
}
/**
* Calculates the length of a message according to LinkedIn's algorithm.
* All Unicode characters are counted as is.
* @param {string} message The message to calculate the length of.
* @returns {number} The calculated length of the message.
*/
calculateMessageLength(message) {
return [...message].length;
}
}