Skip to content

Commit f51cfd7

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

File tree

9 files changed

+390
-0
lines changed

9 files changed

+390
-0
lines changed

indiekit.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ 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+
authorProfileUrl: process.env.LINKEDIN_AUTHOR_PROFILE_URL,
96+
},
9397
"@indiekit/syndicator-mastodon": {
9498
checked: true,
9599
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: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import makeDebug from "debug";
2+
import { IndiekitError } from "@indiekit/error";
3+
import { createPost, userInfo } from "./lib/linkedin.js";
4+
5+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
6+
7+
const DEFAULTS = {
8+
// The character limit for a LinkedIn post is 3000 characters.
9+
// https://www.linkedin.com/help/linkedin/answer/a528176
10+
characterLimit: 3000,
11+
12+
checked: false,
13+
14+
// https://learn.microsoft.com/en-us/linkedin/marketing/versioning
15+
// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
16+
postsAPIVersion: "202401",
17+
};
18+
19+
const retrieveAccessToken = async () => {
20+
// the access token could be stored in an environment variable, in a database, etc
21+
debug(
22+
`retrieve LinkedIn access token from environment variable LINKEDIN_ACCESS_TOKEN`,
23+
);
24+
25+
return process.env.LINKEDIN_ACCESS_TOKEN === undefined
26+
? {
27+
error: new Error(`environment variable LINKEDIN_ACCESS_TOKEN not set`),
28+
}
29+
: { value: process.env.LINKEDIN_ACCESS_TOKEN };
30+
};
31+
32+
export default class LinkedInSyndicator {
33+
/**
34+
* @param {object} [options] - Plug-in options
35+
* @param {string} [options.authorName] - Full name of the author
36+
* @param {string} [options.authorProfileUrl] - LinkedIn profile URL of the author
37+
* @param {number} [options.characterLimit] - LinkedIn post character limit
38+
* @param {boolean} [options.checked] - Check syndicator in UI
39+
* @param {string} [options.postsAPIVersion] - Version of the Linkedin /posts API to use
40+
*/
41+
constructor(options = {}) {
42+
this.name = "LinkedIn syndicator";
43+
this.options = { ...DEFAULTS, ...options };
44+
}
45+
46+
get environment() {
47+
return ["LINKEDIN_ACCESS_TOKEN", "LINKEDIN_AUTHOR_PROFILE_URL"];
48+
}
49+
50+
get info() {
51+
const service = {
52+
name: "LinkedIn",
53+
photo: "/assets/@indiekit-syndicator-linkedin/icon.svg",
54+
url: "https://www.linkedin.com/",
55+
};
56+
57+
const name = this.options.authorName || "unknown LinkedIn author name";
58+
const uid = this.options.authorProfileUrl || "https://www.linkedin.com/";
59+
const url =
60+
this.options.authorProfileUrl || "unknown LinkedIn author profile URL";
61+
62+
return {
63+
checked: this.options.checked,
64+
name,
65+
service,
66+
uid,
67+
user: { name, url },
68+
};
69+
}
70+
71+
get prompts() {
72+
return [
73+
{
74+
type: "text",
75+
name: "postsAPIVersion",
76+
message: "What is the LinkedIn Posts API version you want to use?",
77+
description: "e.g. 202401",
78+
},
79+
];
80+
}
81+
82+
async syndicate(properties, publication) {
83+
// debug(`syndicate properties %O`, properties);
84+
debug(`syndicate publication %O: `, {
85+
categories: publication.categories,
86+
me: publication.me,
87+
});
88+
89+
const { error: tokenError, value: accessToken } =
90+
await retrieveAccessToken();
91+
92+
if (tokenError) {
93+
throw new IndiekitError(tokenError.message, {
94+
cause: tokenError,
95+
plugin: this.name,
96+
status: 500,
97+
});
98+
}
99+
100+
let authorName;
101+
// LinkedIn URN of the author. See https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
102+
let authorUrn;
103+
try {
104+
const userinfo = await userInfo({ accessToken });
105+
authorName = userinfo.name;
106+
authorUrn = userinfo.urn;
107+
} catch (error) {
108+
throw new IndiekitError(error.message, {
109+
cause: error,
110+
plugin: this.name,
111+
status: error.statusCode || 500,
112+
});
113+
}
114+
115+
// TODO: switch on properties['post-type'] // e.g. article, note
116+
const text = properties.content.text;
117+
118+
try {
119+
const { url } = await createPost({
120+
accessToken,
121+
authorName,
122+
authorUrn,
123+
text,
124+
versionString: this.options.postsAPIVersion,
125+
});
126+
debug(`post created, now online at ${url}`);
127+
return url;
128+
} catch (error) {
129+
// Axios Error
130+
// https://axios-http.com/docs/handling_errors
131+
const status = error.response.status;
132+
const message = `could not create LinkedIn post: ${error.response.statusText}`;
133+
throw new IndiekitError(message, {
134+
cause: error,
135+
plugin: this.name,
136+
status,
137+
});
138+
}
139+
}
140+
141+
init(Indiekit) {
142+
Indiekit.addSyndicator(this);
143+
}
144+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import makeDebug from "debug";
2+
import { AuthClient, RestliClient } from "linkedin-api-client";
3+
4+
const debug = makeDebug(`indiekit-syndicator:linkedin`);
5+
6+
// TODO: introspecting the token could be useful to show the token expiration
7+
// date somewhere in the Indiekit UI (maybe in the syndicator detail page).
8+
export const introspectToken = async ({
9+
accessToken,
10+
clientId,
11+
clientSecret,
12+
}) => {
13+
// https://github.com/linkedin-developers/linkedin-api-js-client?tab=readme-ov-file#authclient
14+
const client = new AuthClient({ clientId, clientSecret });
15+
16+
debug(`try introspecting LinkedIn access token`);
17+
return await client.introspectAccessToken(accessToken);
18+
};
19+
20+
export const userInfo = async ({ accessToken }) => {
21+
const client = new RestliClient();
22+
23+
// The /v2/userinfo endpoint is unversioned and requires the `openid` OAuth scope
24+
const response = await client.get({
25+
accessToken,
26+
resourcePath: "/userinfo",
27+
});
28+
29+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
30+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
31+
32+
const id = response.data.sub;
33+
// debug(`user info %O`, response.data);
34+
35+
return { id, name: response.data.name, urn: `urn:li:person:${id}` };
36+
};
37+
38+
export const createPost = async ({
39+
accessToken,
40+
authorName,
41+
authorUrn,
42+
text,
43+
versionString,
44+
}) => {
45+
const client = new RestliClient();
46+
// client.setDebugParams({ enabled: true });
47+
48+
// https://stackoverflow.com/questions/59249318/how-to-get-linkedin-person-id-for-v2-api
49+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
50+
51+
// Text share or create an article
52+
// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/share-on-linkedin
53+
// https://github.com/linkedin-developers/linkedin-api-js-client/blob/master/examples/create-posts.ts
54+
debug(
55+
`create post on behalf of author URN ${authorUrn} (${authorName}) using LinkedIn Posts API version ${versionString}`,
56+
);
57+
const response = await client.create({
58+
accessToken,
59+
resourcePath: "/posts",
60+
entity: {
61+
author: authorUrn,
62+
commentary: text,
63+
distribution: {
64+
feedDistribution: "MAIN_FEED",
65+
targetEntities: [],
66+
thirdPartyDistributionChannels: [],
67+
},
68+
lifecycleState: "PUBLISHED",
69+
visibility: "PUBLIC",
70+
},
71+
versionString,
72+
});
73+
74+
// LinkedIn share URNs are different from LinkedIn activity URNs
75+
// https://stackoverflow.com/questions/51857232/what-is-the-distinction-between-share-and-activity-in-linkedin-v2-api
76+
// https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/urns
77+
78+
return {
79+
url: `https://www.linkedin.com/feed/update/${response.createdEntityId}/`,
80+
};
81+
};
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)