Skip to content

Commit cc273c3

Browse files
committed
feat(syndicator-linkedin): add LinkedIn syndicator
1 parent 15725fb commit cc273c3

File tree

9 files changed

+405
-0
lines changed

9 files changed

+405
-0
lines changed

indiekit.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ const config = {
9090
accessKey: process.env.INTERNET_ARCHIVE_ACCESS_KEY,
9191
secretKey: process.env.INTERNET_ARCHIVE_SECRET_KEY,
9292
},
93+
"@indiekit/syndicator-linkedin": {
94+
checked: true,
95+
// accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
96+
// authorId: process.env.LINKEDIN_AUTHOR_ID,
97+
// authorName: process.env.LINKEDIN_AUTHOR_NAME,
98+
authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL,
99+
},
93100
"@indiekit/syndicator-mastodon": {
94101
checked: true,
95102
url: process.env.MASTODON_URL,

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# @indiekit/syndicator-linkedin
2+
3+
[LinkedIn](https://www.linkedin.com/) syndicator for Indiekit.
4+
5+
## Installation
6+
7+
`npm i @indiekit/syndicator-linkedin`
8+
9+
## Requirements
10+
11+
todo
12+
13+
## Usage
14+
15+
Add `@indiekit/syndicator-linkedin` to your list of plug-ins, specifying options as required:
16+
17+
```js
18+
{
19+
"plugins": ["@indiekit/syndicator-linkedin"],
20+
"@indiekit/syndicator-linkedin": {
21+
"accessToken": process.env.LINKEDIN_ACCESS_TOKEN,
22+
"clientId": process.env.LINKEDIN_CLIENT_ID,
23+
"clientSecret": process.env.LINKEDIN_CLIENT_SECRET,
24+
"checked": true
25+
}
26+
}
27+
```
28+
29+
## Options
30+
31+
todo
Lines changed: 6 additions & 0 deletions
Loading
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// import process from "node:process";
2+
import makeDebug from "debug";
3+
import { IndiekitError } from "@indiekit/error";
4+
import { createPost, userInfo } from "./lib/linkedin.js";
5+
6+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
7+
8+
const UID = "https://www.linkedin.com/";
9+
10+
const DEFAULTS = {
11+
// accessToken: process.env.LINKEDIN_ACCESS_TOKEN,
12+
// The character limit for a LinkedIn post is 3000 characters.
13+
// https://www.linkedin.com/help/linkedin/answer/a528176
14+
characterLimit: 3000,
15+
checked: false,
16+
// https://learn.microsoft.com/en-us/linkedin/marketing/versioning
17+
// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
18+
postsAPIVersion: "202401",
19+
};
20+
21+
const retrieveAccessToken = async () => {
22+
// the access token could be stored in an environment variable, in a database, etc
23+
debug(
24+
`retrieve LinkedIn access token from environment variable LINKEDIN_ACCESS_TOKEN`,
25+
);
26+
27+
return process.env.LINKEDIN_ACCESS_TOKEN === undefined
28+
? {
29+
error: new Error(`environment variable LINKEDIN_ACCESS_TOKEN not set`),
30+
}
31+
: { value: process.env.LINKEDIN_ACCESS_TOKEN };
32+
};
33+
34+
export default class LinkedInSyndicator {
35+
/**
36+
* @param {object} [options] - Plug-in options
37+
* @param {string} [options.accessToken] - Linkedin OAuth app access token
38+
* @param {string} [options.authorId] - LinkedIn ID of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
39+
* @param {string} [options.authorName] - Full name of the author
40+
* @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author
41+
* @param {number} [options.characterLimit] - LinkedIn post character limit
42+
* @param {boolean} [options.checked] - Check syndicator in UI
43+
* @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use
44+
*/
45+
constructor(options = {}) {
46+
this.name = "LinkedIn syndicator";
47+
this.options = { ...DEFAULTS, ...options };
48+
}
49+
50+
get environment() {
51+
return ["LINKEDIN_ACCESS_TOKEN", "LINKEDIN_AUTHOR_PROFILE_URL"];
52+
}
53+
54+
get info() {
55+
const service = {
56+
name: "LinkedIn",
57+
photo: "/assets/@indiekit-syndicator-linkedin/icon.svg",
58+
url: "https://www.linkedin.com/",
59+
};
60+
61+
const name = this.options.authorName || "unknown LinkedIn author name";
62+
const uid = this.options.authorProfileUrl || UID;
63+
const url =
64+
this.options.authorProfileUrl || "unknown LinkedIn author profile URL";
65+
66+
return {
67+
checked: this.options.checked,
68+
name,
69+
service,
70+
uid,
71+
user: { name, url },
72+
};
73+
}
74+
75+
get prompts() {
76+
return [
77+
{
78+
type: "text",
79+
name: "postsAPIVersion",
80+
message: "What is the LinkedIn Posts API version you want to use?",
81+
description: "e.g. 202401",
82+
},
83+
];
84+
}
85+
86+
async syndicate(properties, publication) {
87+
debug(`syndicate properties %O`, properties);
88+
debug(`syndicate publication %O: `, {
89+
categories: publication.categories,
90+
me: publication.me,
91+
});
92+
93+
const { error: tokenError, value: accessToken } =
94+
await retrieveAccessToken();
95+
96+
if (tokenError) {
97+
throw new IndiekitError(tokenError.message, {
98+
cause: tokenError,
99+
plugin: this.name,
100+
status: 500,
101+
});
102+
}
103+
104+
let authorName;
105+
let authorUrn;
106+
try {
107+
const userinfo = await userInfo({ accessToken });
108+
authorName = userinfo.name;
109+
authorUrn = userinfo.urn;
110+
} catch (error) {
111+
throw new IndiekitError(error.message, {
112+
cause: error,
113+
plugin: this.name,
114+
status: error.statusCode,
115+
});
116+
}
117+
118+
// TODO: switch on properties['post-type'] // e.g. article, note
119+
const text = properties.content.text;
120+
121+
try {
122+
const { url } = await createPost({
123+
accessToken,
124+
authorName,
125+
authorUrn,
126+
text,
127+
versionString: this.options.postsAPIVersion,
128+
});
129+
debug(`post created, online at ${url}`);
130+
return url;
131+
} catch (error) {
132+
// Axios Error
133+
// https://axios-http.com/docs/handling_errors
134+
const status = error.response.status;
135+
const message = `could not create LinkedIn post: ${error.response.statusText}`;
136+
throw new IndiekitError(message, {
137+
cause: error,
138+
plugin: this.name,
139+
status,
140+
});
141+
}
142+
}
143+
144+
init(Indiekit) {
145+
Indiekit.addSyndicator(this);
146+
}
147+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import makeDebug from "debug";
2+
import { AuthClient, RestliClient } from "linkedin-api-client";
3+
4+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
5+
6+
export const introspectToken = async ({
7+
accessToken,
8+
clientId,
9+
clientSecret,
10+
}) => {
11+
// https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient
12+
const authClient = new AuthClient({
13+
clientId,
14+
clientSecret,
15+
// redirectUrl: 'https://www.linkedin.com/developers/tools/oauth/redirect'
16+
});
17+
18+
debug(`try introspecting LinkedIn access token`);
19+
return await authClient.introspectAccessToken(accessToken);
20+
};
21+
22+
// TODO: implement OAuth 2.0 authorization code flow
23+
// const authorizationUrl = authClient.generateMemberAuthorizationUrl(scopes)
24+
// const code = 'code-generated-by-LinkedIn'
25+
// const accessToken = await authClient.exchangeAuthCodeForAccessToken(code)
26+
// const refreshToken = 'some-refresh-token'
27+
// const accessToken = await authClient.exchangeRefreshTokenForAccessToken(refreshToken)
28+
29+
export const userInfo = async ({ accessToken }) => {
30+
const client = new RestliClient();
31+
32+
// The /v2/userinfo endpoint is unversioned and requires the `openid` OAuth scope
33+
const response = await client.get({
34+
accessToken,
35+
resourcePath: "/userinfo",
36+
});
37+
38+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
39+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
40+
41+
const id = response.data.sub;
42+
debug(`user info %O`, response.data);
43+
44+
return { id, name: response.data.name, urn: `urn:li:person:${id}` };
45+
};
46+
47+
export const createPost = async ({
48+
accessToken,
49+
authorName,
50+
authorUrn,
51+
text,
52+
versionString,
53+
}) => {
54+
const client = new RestliClient();
55+
// client.setDebugParams({ enabled: true });
56+
57+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
58+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
59+
60+
// Text share or create an article
61+
// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
62+
// https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts
63+
debug(
64+
`create post on behalf of author URN ${authorUrn} (${authorName}) using LinkedIn Posts API version ${versionString}`,
65+
);
66+
const response = await client.create({
67+
accessToken,
68+
resourcePath: "/posts",
69+
entity: {
70+
author: authorUrn,
71+
commentary: text,
72+
distribution: {
73+
feedDistribution: "MAIN_FEED",
74+
targetEntities: [],
75+
thirdPartyDistributionChannels: [],
76+
},
77+
lifecycleState: "PUBLISHED",
78+
visibility: "PUBLIC",
79+
},
80+
versionString,
81+
});
82+
83+
// LinkedIn share URNs are different from LinkedIn activity URNs
84+
// https://stackoverflow.com/questions/51857232/what-is-the-distinction-between-share-and-activity-in-linkedin-v2-api
85+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
86+
87+
return {
88+
url: `https://www.linkedin.com/feed/update/${response.createdEntityId}/`,
89+
};
90+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@indiekit/syndicator-linkedin",
3+
"version": "0.1.0",
4+
"description": "LinkedIn syndicator for Indiekit",
5+
"keywords": [
6+
"indiekit",
7+
"indiekit-plugin",
8+
"indieweb",
9+
"linkedin",
10+
"syndication"
11+
],
12+
"homepage": "https://getindiekit.com",
13+
"author": {
14+
"name": "Giacomo Debidda",
15+
"url": "https://giacomodebidda.com"
16+
},
17+
"license": "MIT",
18+
"engines": {
19+
"node": ">=20"
20+
},
21+
"type": "module",
22+
"main": "index.js",
23+
"files": [
24+
"assets",
25+
"lib",
26+
"index.js"
27+
],
28+
"bugs": {
29+
"url": "https://github.com/getindiekit/indiekit/issues"
30+
},
31+
"repository": {
32+
"type": "git",
33+
"url": "https://github.com/getindiekit/indiekit.git",
34+
"directory": "packages/syndicator-linkedin"
35+
},
36+
"dependencies": {
37+
"@indiekit/error": "^1.0.0-beta.15",
38+
"@indiekit/util": "^1.0.0-beta.16",
39+
"brevity": "^0.2.9",
40+
"html-to-text": "^9.0.0",
41+
"linkedin-api-client": "^0.3.0"
42+
},
43+
"publishConfig": {
44+
"access": "public"
45+
}
46+
}

0 commit comments

Comments
 (0)