@volter/twin-linkedin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +39 -0
- package/dist/client/linkedin-mirror.bundle.js +323 -0
- package/dist/client/linkedin-mirror.d.ts +43 -0
- package/dist/client/linkedin-mirror.js +393 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +74 -0
- package/dist/src/linkedin-budget.d.ts +36 -0
- package/dist/src/linkedin-budget.js +87 -0
- package/dist/src/linkedin-capabilities.d.ts +4 -0
- package/dist/src/linkedin-capabilities.js +1027 -0
- package/dist/src/linkedin-conformance.d.ts +8 -0
- package/dist/src/linkedin-conformance.js +58 -0
- package/dist/src/linkedin-connector.d.ts +66 -0
- package/dist/src/linkedin-connector.js +326 -0
- package/dist/src/linkedin-errors.d.ts +21 -0
- package/dist/src/linkedin-errors.js +38 -0
- package/dist/src/linkedin-media.d.ts +125 -0
- package/dist/src/linkedin-media.js +331 -0
- package/dist/src/linkedin-mirror-ui.d.ts +59 -0
- package/dist/src/linkedin-mirror-ui.js +174 -0
- package/dist/src/linkedin-server.d.ts +10 -0
- package/dist/src/linkedin-server.js +154 -0
- package/dist/src/linkedin-twin.d.ts +23 -0
- package/dist/src/linkedin-twin.js +1220 -0
- package/package.json +58 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +118 -0
- package/src/linkedin-budget.ts +108 -0
- package/src/linkedin-capabilities.ts +1057 -0
- package/src/linkedin-conformance.ts +73 -0
- package/src/linkedin-connector.ts +314 -0
- package/src/linkedin-errors.ts +58 -0
- package/src/linkedin-media.ts +363 -0
- package/src/linkedin-mirror-ui.ts +182 -0
- package/src/linkedin-server.ts +150 -0
- package/src/linkedin-twin.ts +1143 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type LiRow, type MirrorOrg } from '../src/linkedin-mirror-ui.js';
|
|
2
|
+
/** The org's logo: the image its logoV2 names (resolved to a downloadUrl), else linkedin.com's
|
|
3
|
+
* building placeholder. */
|
|
4
|
+
export declare function OrgLogo({ src, name, size }: {
|
|
5
|
+
src?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
9
|
+
export declare function Commentary({ text, clamp }: {
|
|
10
|
+
text: unknown;
|
|
11
|
+
clamp?: boolean;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/** A post's asset, resolved through the Images / Videos API it names. */
|
|
14
|
+
type Resolved = {
|
|
15
|
+
kind: 'image' | 'video';
|
|
16
|
+
asset: LiRow;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'article';
|
|
19
|
+
thumb?: LiRow;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'missing';
|
|
22
|
+
why: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function VideoPlayer({ asset, title }: {
|
|
25
|
+
asset: LiRow;
|
|
26
|
+
title?: string;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
28
|
+
export declare function PostMedia({ post, resolved }: {
|
|
29
|
+
post: LiRow;
|
|
30
|
+
resolved: Resolved | null;
|
|
31
|
+
}): import("react").JSX.Element | null;
|
|
32
|
+
type Org = MirrorOrg;
|
|
33
|
+
export declare function PostCard({ post, org, nowMs, resolved, full }: {
|
|
34
|
+
post: LiRow;
|
|
35
|
+
org: Org;
|
|
36
|
+
nowMs: number;
|
|
37
|
+
resolved: Resolved | null;
|
|
38
|
+
full?: boolean;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
export declare function CompanyHeader({ org }: {
|
|
41
|
+
org: Org;
|
|
42
|
+
}): import("react").JSX.Element;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// LinkedIn mirror — React/TSX client. linkedin.com's own view of the twin's state: sign in as the
|
|
3
|
+
// page's admin, see the company page (header, follower line, Posts feed) and a single post. Every
|
|
4
|
+
// read is the twin's OWN LinkedIn API on the same origin; nothing is kept but the signed-in email and
|
|
5
|
+
// token, in the tab's sessionStorage.
|
|
6
|
+
import { createRoot } from 'react-dom/client';
|
|
7
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
8
|
+
import { commentarySegments, followerLine, mediaKind, MIRROR_VERSION, needsFold, orgFromReads, postRoute, postsQuery, relativeTime, sourceHost, } from "../src/linkedin-mirror-ui.js";
|
|
9
|
+
// WHERE THIS MIRROR LIVES. At a vendor root the base is '' (fetches are root-relative); under a
|
|
10
|
+
// World's <base> every read resolves inside that prefix.
|
|
11
|
+
const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
|
|
12
|
+
const SESSION_EMAIL = 'linkedin-mirror.email';
|
|
13
|
+
const SESSION_TOKEN = 'linkedin-mirror.token';
|
|
14
|
+
function readSession() {
|
|
15
|
+
if (typeof sessionStorage === 'undefined')
|
|
16
|
+
return null;
|
|
17
|
+
const email = sessionStorage.getItem(SESSION_EMAIL);
|
|
18
|
+
const token = sessionStorage.getItem(SESSION_TOKEN);
|
|
19
|
+
return email && token ? { email, token } : null;
|
|
20
|
+
}
|
|
21
|
+
// THE WORLD'S CLOCK. A World's twins stamp from the World's instant, which need not be the browser's
|
|
22
|
+
// (a frozen or advanced clock); every answer carries it as its HTTP `Date`, so relative times are drawn
|
|
23
|
+
// against the World's now, never the browser's.
|
|
24
|
+
let worldNowMs = null;
|
|
25
|
+
const currentNow = () => worldNowMs ?? Date.now();
|
|
26
|
+
async function wire(token, path) {
|
|
27
|
+
const res = await fetch(`${WIRE_BASE}${path}`, {
|
|
28
|
+
headers: { authorization: `Bearer ${token}`, 'linkedin-version': MIRROR_VERSION, 'x-restli-protocol-version': '2.0.0' },
|
|
29
|
+
});
|
|
30
|
+
const served = Date.parse(res.headers.get('date') ?? '');
|
|
31
|
+
if (Number.isFinite(served))
|
|
32
|
+
worldNowMs = served;
|
|
33
|
+
let body = {};
|
|
34
|
+
try {
|
|
35
|
+
body = await res.json();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
body = {};
|
|
39
|
+
}
|
|
40
|
+
return { status: res.status, body };
|
|
41
|
+
}
|
|
42
|
+
const problemText = (w) => String(w.body.message ?? `HTTP ${w.status}`);
|
|
43
|
+
function parseRoute(hash) {
|
|
44
|
+
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean);
|
|
45
|
+
if (parts[0] === 'login')
|
|
46
|
+
return { page: 'login' };
|
|
47
|
+
if (parts[0] === 'company' && parts[1])
|
|
48
|
+
return { page: 'company', vanity: decodeURIComponent(parts[1]) };
|
|
49
|
+
if (parts[0] === 'feed' && parts[1] === 'update' && parts[2])
|
|
50
|
+
return { page: 'post', urn: decodeURIComponent(parts[2]) };
|
|
51
|
+
return { page: 'home' };
|
|
52
|
+
}
|
|
53
|
+
const go = (path) => { location.hash = `#/${path}`; };
|
|
54
|
+
// ── icons (vendor mirror: linkedin.com's look, drawn here) ──────────────────────────────────────
|
|
55
|
+
function Icon({ d, size = 24, fill = true, className }) {
|
|
56
|
+
return (_jsx("svg", { className: className, viewBox: "0 0 24 24", width: size, height: size, "aria-hidden": "true", fill: fill ? 'currentColor' : 'none', stroke: fill ? 'none' : 'currentColor', strokeWidth: 1.6, children: d.map((p) => _jsx("path", { d: p }, p)) }));
|
|
57
|
+
}
|
|
58
|
+
const I_HOME = ['M23 9v2h-2v7a3 3 0 01-3 3h-4v-6h-4v6H6a3 3 0 01-3-3v-7H1V9l11-7 5 3.18V2h3v5.09z'];
|
|
59
|
+
const I_NETWORK = ['M12 16v6H3v-6a3 3 0 013-3h3a3 3 0 013 3zm5.5-3A3.5 3.5 0 1014 9.5a3.5 3.5 0 003.5 3.5zm1 2h-2a2.5 2.5 0 00-2.5 2.5V22h7v-4.5a2.5 2.5 0 00-2.5-2.5zM7.5 2A4.5 4.5 0 1012 6.5 4.49 4.49 0 007.5 2z'];
|
|
60
|
+
const I_JOBS = ['M17 6V5a3 3 0 00-3-3h-4a3 3 0 00-3 3v1H2v4a3 3 0 003 3h14a3 3 0 003-3V6zM9 5a1 1 0 011-1h4a1 1 0 011 1v1H9zm10 9a4 4 0 003-1.38V17a3 3 0 01-3 3H5a3 3 0 01-3-3v-4.38A4 4 0 005 14z'];
|
|
61
|
+
const I_MSG = ['M16 4H8a7 7 0 000 14h4v4l8.16-5.39A6.78 6.78 0 0023 11a7 7 0 00-7-7zm-8 8.25A1.25 1.25 0 119.25 11 1.25 1.25 0 018 12.25zm4 0A1.25 1.25 0 1113.25 11 1.25 1.25 0 0112 12.25zm4 0A1.25 1.25 0 1117.25 11 1.25 1.25 0 0116 12.25z'];
|
|
62
|
+
const I_BELL = ['M22 19h-8.28a2 2 0 11-3.44 0H2v-1a4.52 4.52 0 011.17-2.83l1-1.17h15.7l1 1.17A4.42 4.42 0 0122 18zM18.21 7.44A6.27 6.27 0 0012 2a6.27 6.27 0 00-6.21 5.44L5 13h14z'];
|
|
63
|
+
const I_SEARCH = ['M14.56 12.44L11.3 9.18a5.51 5.51 0 10-2.12 2.12l3.26 3.26a1.5 1.5 0 102.12-2.12zM3 6.5A3.5 3.5 0 116.5 10 3.5 3.5 0 013 6.5z'];
|
|
64
|
+
const I_GLOBE = ['M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 01.68 0L9 7.5l.38-.7a1 1 0 00.12-.48v-.85a.78.78 0 01.21-.53l1.07-1.09a5 5 0 01-1.54 9z'];
|
|
65
|
+
const I_LIKE = ['M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11zM19 19h-1l-.73.37a6.14 6.14 0 01-2.69.63H7.72a1 1 0 01-1-.72l-.25-.87-.85-.41A1 1 0 015 17l.17-1-.76-.74A1 1 0 014.27 14l.66-1.09-.73-1.1a.49.49 0 01.08-.7.48.48 0 01.34-.11h7.05l-1.31-3.92A7 7 0 0110 4.86V3.75a.77.77 0 01.75-.75.75.75 0 01.71.51L12 5a9 9 0 002.13 3.5l4.5 4.5H19z'];
|
|
66
|
+
const I_COMMENT = ['M7 9h10v1H7zm0 4h7v-1H7zm16-2a6.78 6.78 0 01-2.84 5.61L12 22v-4H8A7 7 0 018 4h8a7 7 0 017 7zm-2 0a5 5 0 00-5-5H8a5 5 0 000 10h6v2.28L19 15a4.79 4.79 0 002-4z'];
|
|
67
|
+
const I_REPOST = ['M13.96 5H6c-.55 0-1 .45-1 1v10H3V6c0-1.66 1.34-3 3-3h7.96L12 0h2.5L17 4l-2.5 4H12l1.96-3zm.04 14H18c.55 0 1-.45 1-1V8h2v10c0 1.66-1.34 3-3 3h-4l1.96 3H13.5L11 20l2.5-4H16l-2 3z'];
|
|
68
|
+
const I_SEND = ['M21 3L0 10l7.66 4.26L16 8l-6.26 8.34L14 24l7-21z'];
|
|
69
|
+
const I_BACK = ['M9 4l-4.87 7H22v2H4.13L9 20H6.5L1 12l5.5-8z'];
|
|
70
|
+
// ── pieces ───────────────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
function InLogo({ size = 34 }) {
|
|
72
|
+
return (_jsxs("svg", { className: "in-logo", viewBox: "0 0 34 34", width: size, height: size, "aria-label": "LinkedIn", children: [_jsx("rect", { width: "34", height: "34", rx: "4", fill: "#0a66c2" }), _jsx("path", { fill: "#fff", d: "M7.6 13.2h4.3v13.9H7.6zM9.8 6.3a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM14.6 13.2h4.1v1.9h.1c.6-1.1 2-2.2 4.1-2.2 4.4 0 5.2 2.9 5.2 6.6v7.6h-4.3v-6.8c0-1.6 0-3.7-2.3-3.7s-2.6 1.8-2.6 3.6v6.9h-4.3z" })] }));
|
|
73
|
+
}
|
|
74
|
+
/** The org's logo: the image its logoV2 names (resolved to a downloadUrl), else linkedin.com's
|
|
75
|
+
* building placeholder. */
|
|
76
|
+
export function OrgLogo({ src, name, size = 48 }) {
|
|
77
|
+
if (src)
|
|
78
|
+
return _jsx("img", { className: "org-logo", src: src, width: size, height: size, alt: name ? `${name} logo` : 'logo' });
|
|
79
|
+
return (_jsx("div", { className: "org-logo org-logo-empty", style: { width: size, height: size }, "aria-hidden": "true", children: _jsx("svg", { viewBox: "0 0 24 24", width: size * 0.55, height: size * 0.55, children: _jsx("path", { fill: "#a0b4b7", d: "M4 2v20h16V2zm6 17H8v-2h2zm0-4H8v-2h2zm0-4H8V9h2zm0-4H8V5h2zm6 12h-4v-4h4zm0-6h-2v-2h2zm0-4h-2V5h2z" }) }) }));
|
|
80
|
+
}
|
|
81
|
+
export function Commentary({ text, clamp = false }) {
|
|
82
|
+
const [open, setOpen] = useState(!clamp);
|
|
83
|
+
const fold = clamp && needsFold(text);
|
|
84
|
+
return (_jsxs("div", { className: fold && !open ? 'commentary commentary-folded' : 'commentary', children: [_jsx("span", { className: "commentary-text", children: commentarySegments(text).map((s, i) => {
|
|
85
|
+
if (s.kind === 'text')
|
|
86
|
+
return _jsx("span", { children: s.value }, i);
|
|
87
|
+
if (s.kind === 'url')
|
|
88
|
+
return _jsx("a", { className: "li-link", href: s.value, target: "_blank", rel: "noreferrer", children: s.value.replace(/^https?:\/\//, '') }, i);
|
|
89
|
+
if (s.kind === 'mention')
|
|
90
|
+
return _jsx("span", { className: "li-link li-mention", children: s.value }, i);
|
|
91
|
+
return _jsx("span", { className: "li-link li-hashtag", children: s.value }, i);
|
|
92
|
+
}) }), fold && !open ? _jsx("button", { className: "see-more", onClick: (e) => { e.stopPropagation(); setOpen(true); }, children: "\u2026more" }) : null] }));
|
|
93
|
+
}
|
|
94
|
+
function useAsset(token, post, reloadKey) {
|
|
95
|
+
const [resolved, setResolved] = useState(null);
|
|
96
|
+
const kind = mediaKind(post);
|
|
97
|
+
const id = kind === 'article' ? post.content?.article?.thumbnail : post.content?.media?.id;
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
let live = true;
|
|
100
|
+
if (!kind) {
|
|
101
|
+
setResolved(null);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (typeof id !== 'string') {
|
|
105
|
+
setResolved(kind === 'article' ? { kind: 'article' } : null);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const path = id.startsWith('urn:li:video:') ? `/rest/videos/${encodeURIComponent(id)}` : `/rest/images/${encodeURIComponent(id)}`;
|
|
109
|
+
const read = async () => {
|
|
110
|
+
// one read: a video still PROCESSING shows as being processed, as linkedin.com's player does
|
|
111
|
+
const w = await wire(token, path);
|
|
112
|
+
if (!live)
|
|
113
|
+
return;
|
|
114
|
+
if (w.status !== 200) {
|
|
115
|
+
setResolved({ kind: 'missing', why: problemText(w) });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'article')
|
|
119
|
+
setResolved({ kind: 'article', thumb: w.body });
|
|
120
|
+
else
|
|
121
|
+
setResolved({ kind: kind, asset: w.body });
|
|
122
|
+
};
|
|
123
|
+
void read();
|
|
124
|
+
return () => { live = false; };
|
|
125
|
+
}, [token, kind, id, reloadKey]);
|
|
126
|
+
return resolved;
|
|
127
|
+
}
|
|
128
|
+
export function VideoPlayer({ asset, title }) {
|
|
129
|
+
const [started, setStarted] = useState(false);
|
|
130
|
+
const ratio = Number(asset.aspectRatioWidth) > 0 && Number(asset.aspectRatioHeight) > 0 ? `${asset.aspectRatioWidth} / ${asset.aspectRatioHeight}` : '16 / 9';
|
|
131
|
+
if (asset.status !== 'AVAILABLE' || typeof asset.downloadUrl !== 'string') {
|
|
132
|
+
return _jsx("div", { className: "media-pending", style: { aspectRatio: ratio }, children: "Your video is being processed" });
|
|
133
|
+
}
|
|
134
|
+
// linkedin.com's player before it plays: the poster (the uploaded thumbnail, or the system one) and
|
|
135
|
+
// a centred play button; once started, the player's own controls.
|
|
136
|
+
const start = (e) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
const el = e.currentTarget.closest('.media')?.querySelector('video');
|
|
139
|
+
setStarted(true);
|
|
140
|
+
void el?.play().catch(() => undefined);
|
|
141
|
+
};
|
|
142
|
+
return (_jsxs("div", { className: "media media-video-wrap", onClick: (e) => e.stopPropagation(), children: [_jsx("video", { className: "media-video", style: { aspectRatio: ratio }, src: asset.downloadUrl, poster: typeof asset.thumbnail === 'string' ? asset.thumbnail : undefined, controls: started, playsInline: true, preload: "metadata", "aria-label": title ?? 'Video', "data-video-urn": String(asset.id) }), !started ? (_jsx("button", { className: "media-play", "aria-label": "Play video", onClick: start, children: _jsx("svg", { viewBox: "0 0 24 24", width: "36", height: "36", "aria-hidden": "true", children: _jsx("path", { d: "M8 5v14l11-7z", fill: "currentColor" }) }) })) : null] }));
|
|
143
|
+
}
|
|
144
|
+
export function PostMedia({ post, resolved }) {
|
|
145
|
+
const kind = mediaKind(post);
|
|
146
|
+
if (!kind)
|
|
147
|
+
return null;
|
|
148
|
+
if (kind === 'article') {
|
|
149
|
+
const a = post.content.article;
|
|
150
|
+
const thumb = resolved && resolved.kind === 'article' ? resolved.thumb : undefined;
|
|
151
|
+
return (_jsxs("a", { className: "article", href: String(a.source), target: "_blank", rel: "noreferrer", onClick: (e) => e.stopPropagation(), children: [thumb && typeof thumb.downloadUrl === 'string' ? _jsx("img", { className: "article-thumb", src: thumb.downloadUrl, alt: String(a.thumbnailAltText ?? '') }) : null, _jsxs("div", { className: "article-meta", children: [_jsx("div", { className: "article-title", children: String(a.title ?? a.source) }), _jsx("div", { className: "article-source", children: sourceHost(a.source) })] })] }));
|
|
152
|
+
}
|
|
153
|
+
if (!resolved)
|
|
154
|
+
return _jsx("div", { className: "media-loading" });
|
|
155
|
+
if (resolved.kind === 'missing')
|
|
156
|
+
return _jsxs("div", { className: "media-pending", children: ["This media is unavailable (", resolved.why, ")"] });
|
|
157
|
+
if (resolved.kind === 'video')
|
|
158
|
+
return _jsx(VideoPlayer, { asset: resolved.asset, title: post.content?.media?.title });
|
|
159
|
+
if (resolved.kind === 'image' && typeof resolved.asset.downloadUrl === 'string') {
|
|
160
|
+
return _jsx("div", { className: "media", children: _jsx("img", { className: "media-img", src: resolved.asset.downloadUrl, alt: String(post.content?.media?.altText ?? 'Image') }) });
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
export function PostCard({ post, org, nowMs, resolved, full = false }) {
|
|
165
|
+
return (_jsxs("article", { className: "card post", "data-post-urn": String(post.id), onClick: () => { if (!full)
|
|
166
|
+
location.hash = postRoute(String(post.id)); }, children: [_jsxs("header", { className: "post-head", children: [_jsx(OrgLogo, { src: org.logoUrl, name: org.name, size: 48 }), _jsxs("div", { className: "post-actor", children: [_jsx("a", { className: "post-actor-name", href: `#/company/${encodeURIComponent(org.vanity)}`, onClick: (e) => e.stopPropagation(), children: org.name }), typeof org.followers === 'number' ? _jsx("div", { className: "post-actor-sub", children: followerLine(org.followers) }) : null, _jsxs("div", { className: "post-actor-sub", children: [_jsx("span", { children: relativeTime(post.publishedAt ?? post.createdAt, nowMs) }), _jsx("span", { className: "dot", "aria-hidden": "true", children: "\u2022" }), _jsx("span", { className: "globe", title: post.visibility === 'PUBLIC' ? 'Visible to anyone on or off LinkedIn' : 'Visible to members', children: _jsx(Icon, { d: I_GLOBE, size: 16 }) })] })] }), _jsx("button", { className: "follow-link", onClick: (e) => e.stopPropagation(), children: "+ Follow" })] }), _jsx(Commentary, { text: post.commentary, clamp: !full }), _jsx(PostMedia, { post: post, resolved: resolved }), _jsxs("div", { className: "social-bar", children: [_jsxs("button", { className: "social-action", children: [_jsx(Icon, { d: I_LIKE, size: 20 }), _jsx("span", { children: "Like" })] }), _jsxs("button", { className: "social-action", children: [_jsx(Icon, { d: I_COMMENT, size: 20 }), _jsx("span", { children: "Comment" })] }), _jsxs("button", { className: "social-action", children: [_jsx(Icon, { d: I_REPOST, size: 20 }), _jsx("span", { children: "Repost" })] }), _jsxs("button", { className: "social-action", children: [_jsx(Icon, { d: I_SEND, size: 20 }), _jsx("span", { children: "Send" })] })] })] }));
|
|
167
|
+
}
|
|
168
|
+
function LivePostCard({ session, post, org, nowMs, reloadKey, full }) {
|
|
169
|
+
const resolved = useAsset(session.token, post, reloadKey);
|
|
170
|
+
void nowMs;
|
|
171
|
+
return _jsx(PostCard, { post: post, org: org, nowMs: currentNow(), resolved: resolved, full: full });
|
|
172
|
+
}
|
|
173
|
+
export function CompanyHeader({ org }) {
|
|
174
|
+
return (_jsxs("section", { className: "card company", children: [_jsx("div", { className: "cover" }), _jsxs("div", { className: "company-body", children: [_jsx("div", { className: "company-logo", children: _jsx(OrgLogo, { src: org.logoUrl, name: org.name, size: 104 }) }), _jsx("h1", { className: "company-name", children: org.name }), org.description ? _jsx("div", { className: "company-tagline", children: org.description.split('\n')[0] }) : null, _jsxs("div", { className: "company-meta", children: [org.website ? _jsx("span", { children: sourceHost(org.website) || org.website }) : null, org.website && typeof org.followers === 'number' ? _jsx("span", { "aria-hidden": "true", children: " \u00B7 " }) : null, typeof org.followers === 'number' ? _jsx("span", { className: "company-followers", children: followerLine(org.followers) }) : null] }), _jsxs("div", { className: "company-actions", children: [_jsx("button", { className: "btn-primary", children: "+ Follow" }), org.website ? _jsx("a", { className: "btn-secondary", href: org.website, target: "_blank", rel: "noreferrer", children: "Visit website \u2197" }) : null, _jsx("button", { className: "btn-secondary", children: "Message" })] })] }), _jsxs("nav", { className: "company-tabs", children: [_jsx("span", { className: "company-tab", children: "Home" }), _jsx("span", { className: "company-tab", children: "About" }), _jsx("span", { className: "company-tab active", children: "Posts" }), _jsx("span", { className: "company-tab", children: "Jobs" }), _jsx("span", { className: "company-tab", children: "People" })] })] }));
|
|
175
|
+
}
|
|
176
|
+
/** A page by vanity name, with its logo, follower count and (for its admin) description. */
|
|
177
|
+
function useOrg(session, vanity, reloadKey) {
|
|
178
|
+
const [org, setOrg] = useState(null);
|
|
179
|
+
const [error, setError] = useState(null);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!vanity)
|
|
182
|
+
return;
|
|
183
|
+
let live = true;
|
|
184
|
+
setOrg(null);
|
|
185
|
+
setError(null);
|
|
186
|
+
void (async () => {
|
|
187
|
+
const found = await wire(session.token, `/rest/organizations?q=vanityName&vanityName=${encodeURIComponent(vanity)}`);
|
|
188
|
+
const pub = Array.isArray(found.body.elements) ? found.body.elements[0] : undefined;
|
|
189
|
+
if (!live)
|
|
190
|
+
return;
|
|
191
|
+
if (found.status !== 200 || !pub) {
|
|
192
|
+
setError(found.status === 200 ? 'This page doesn’t exist' : problemText(found));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const urn = `urn:li:organization:${pub.id}`;
|
|
196
|
+
const [admin, size, logo] = await Promise.all([
|
|
197
|
+
wire(session.token, `/rest/organizations/${pub.id}`),
|
|
198
|
+
wire(session.token, `/rest/networkSizes/${encodeURIComponent(urn)}?edgeType=COMPANY_FOLLOWED_BY_MEMBER`),
|
|
199
|
+
typeof pub.logoV2?.original === 'string' ? wire(session.token, `/rest/images/${encodeURIComponent(pub.logoV2.original)}`) : Promise.resolve(null),
|
|
200
|
+
]);
|
|
201
|
+
if (!live)
|
|
202
|
+
return;
|
|
203
|
+
setOrg(orgFromReads(pub, admin.status === 200 ? admin.body : null, size.status === 200 ? size.body : null, logo && logo.status === 200 ? logo.body : null));
|
|
204
|
+
})();
|
|
205
|
+
return () => { live = false; };
|
|
206
|
+
}, [session.token, vanity, reloadKey]);
|
|
207
|
+
return { org, error };
|
|
208
|
+
}
|
|
209
|
+
function CompanyScreen({ session, vanity, nowMs, reloadKey }) {
|
|
210
|
+
const { org, error } = useOrg(session, vanity, reloadKey);
|
|
211
|
+
const [posts, setPosts] = useState([]);
|
|
212
|
+
const [next, setNext] = useState(null);
|
|
213
|
+
const [feedError, setFeedError] = useState(null);
|
|
214
|
+
const load = useCallback(async (start, prev) => {
|
|
215
|
+
if (!org)
|
|
216
|
+
return;
|
|
217
|
+
const w = await wire(session.token, postsQuery(org.urn, start));
|
|
218
|
+
if (w.status !== 200) {
|
|
219
|
+
setFeedError(problemText(w));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(w.body.elements)) {
|
|
223
|
+
setFeedError('LinkedIn answered the feed without its posts.');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const rows = w.body.elements;
|
|
227
|
+
setPosts([...prev, ...rows]);
|
|
228
|
+
const more = Array.isArray(w.body.paging?.links) && w.body.paging.links.some((l) => l.rel === 'next');
|
|
229
|
+
setNext(more ? start + rows.length : null);
|
|
230
|
+
}, [org, session.token]);
|
|
231
|
+
useEffect(() => { setPosts([]); setNext(null); setFeedError(null); void load(0, []); }, [load, reloadKey]);
|
|
232
|
+
if (error)
|
|
233
|
+
return _jsx("div", { className: "card empty", children: _jsx("h2", { children: error }) });
|
|
234
|
+
if (!org)
|
|
235
|
+
return _jsx("div", { className: "card loading" });
|
|
236
|
+
// linkedin.com's Posts tab is the page's feed: posts distributed to the main feed.
|
|
237
|
+
const feed = posts.filter((p) => p.distribution?.feedDistribution !== 'NONE');
|
|
238
|
+
return (_jsxs(_Fragment, { children: [_jsx(CompanyHeader, { org: org }), _jsxs("div", { className: "feed-sort", children: ["Sort by: ", _jsx("strong", { children: "Recent" })] }), feedError ? _jsx("div", { className: "card empty", children: feedError }) : null, feed.length === 0 && !feedError ? _jsxs("div", { className: "card empty", children: [_jsx("h2", { children: "No posts yet" }), _jsxs("p", { children: ["When ", org.name, " shares a post, it will appear here."] })] }) : null, feed.map((p) => _jsx(LivePostCard, { session: session, post: p, org: org, nowMs: nowMs, reloadKey: reloadKey }, String(p.id))), next !== null ? _jsx("button", { className: "show-more", onClick: () => void load(next, posts), children: "Show more results" }) : null] }));
|
|
239
|
+
}
|
|
240
|
+
function PostScreen({ session, urn, nowMs, reloadKey }) {
|
|
241
|
+
const [w, setW] = useState(null);
|
|
242
|
+
const [vanity, setVanity] = useState(null);
|
|
243
|
+
const [orgError, setOrgError] = useState(null);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
setW(null);
|
|
246
|
+
setOrgError(null);
|
|
247
|
+
void wire(session.token, `/rest/posts/${encodeURIComponent(urn)}`).then(async (got) => {
|
|
248
|
+
setW(got);
|
|
249
|
+
const author = got.body.author;
|
|
250
|
+
if (got.status === 200 && typeof author === 'string') {
|
|
251
|
+
// organizationsLookup needs no admin role: any member who can read the post can see its page
|
|
252
|
+
const id = author.split(':').pop() ?? '';
|
|
253
|
+
const org = await wire(session.token, `/rest/organizationsLookup?ids=List(${id})`);
|
|
254
|
+
const found = org.status === 200 ? org.body.results?.[id] : undefined;
|
|
255
|
+
if (found && typeof found.vanityName === 'string')
|
|
256
|
+
setVanity(found.vanityName);
|
|
257
|
+
else
|
|
258
|
+
setOrgError(org.status === 200 ? String(org.body.errors?.[id]?.message ?? 'This page is unavailable') : problemText(org));
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}, [urn, session.token, reloadKey]);
|
|
262
|
+
const { org, error } = useOrg(session, vanity, reloadKey);
|
|
263
|
+
if (w === null)
|
|
264
|
+
return _jsx("div", { className: "card loading" });
|
|
265
|
+
if (w.status !== 200)
|
|
266
|
+
return _jsxs("div", { className: "card empty", children: [_jsx("h2", { children: "This post cannot be displayed" }), _jsx("p", { children: problemText(w) })] });
|
|
267
|
+
if (error)
|
|
268
|
+
return _jsxs("div", { className: "card empty", children: [_jsx("h2", { children: "This post cannot be displayed" }), _jsx("p", { children: error })] });
|
|
269
|
+
if (orgError)
|
|
270
|
+
return _jsxs("div", { className: "card empty", children: [_jsx("h2", { children: "This post cannot be displayed" }), _jsx("p", { children: orgError })] });
|
|
271
|
+
if (!org)
|
|
272
|
+
return _jsx("div", { className: "card loading" });
|
|
273
|
+
return (_jsxs(_Fragment, { children: [_jsxs("button", { className: "back-link", onClick: () => history.back(), children: [_jsx(Icon, { d: I_BACK, size: 16 }), " Back"] }), _jsx(LivePostCard, { session: session, post: w.body, org: org, nowMs: nowMs, reloadKey: reloadKey, full: true })] }));
|
|
274
|
+
}
|
|
275
|
+
/** Where a signed-in page member lands: the first page they hold an approved posting role on
|
|
276
|
+
* (an ADMINISTRATOR, a CONTENT_ADMINISTRATOR or a DIRECT_SPONSORED_CONTENT_POSTER). */
|
|
277
|
+
const POSTING_ROLES = ['ADMINISTRATOR', 'CONTENT_ADMINISTRATOR', 'DIRECT_SPONSORED_CONTENT_POSTER'];
|
|
278
|
+
function HomeScreen({ session }) {
|
|
279
|
+
const [msg, setMsg] = useState(null);
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
void (async () => {
|
|
282
|
+
const acls = await wire(session.token, '/rest/organizationAcls?q=roleAssignee&state=APPROVED');
|
|
283
|
+
if (acls.status !== 200) {
|
|
284
|
+
setMsg(problemText(acls));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const rows = Array.isArray(acls.body.elements) ? acls.body.elements : [];
|
|
288
|
+
const first = POSTING_ROLES.map((role) => rows.find((r) => r.role === role)).find((r) => r !== undefined);
|
|
289
|
+
if (!first) {
|
|
290
|
+
setMsg('You don’t manage any LinkedIn Page yet.');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// organizationsLookup needs no admin role, so a content admin finds their page as an admin does
|
|
294
|
+
const id = String(first.organization ?? first.organizationTarget).split(':').pop() ?? '';
|
|
295
|
+
const org = await wire(session.token, `/rest/organizationsLookup?ids=List(${id})`);
|
|
296
|
+
const found = org.status === 200 ? org.body.results?.[id] : undefined;
|
|
297
|
+
if (!found || typeof found.vanityName !== 'string') {
|
|
298
|
+
setMsg(org.status === 200 ? String(org.body.errors?.[id]?.message ?? 'This page is unavailable') : problemText(org));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
location.replace(`#/company/${encodeURIComponent(found.vanityName)}`);
|
|
302
|
+
})();
|
|
303
|
+
}, [session.token]);
|
|
304
|
+
return msg ? _jsx("div", { className: "card empty", children: _jsx("h2", { children: msg }) }) : _jsx("div", { className: "card loading" });
|
|
305
|
+
}
|
|
306
|
+
// ── sign-in: linkedin.com's two-step sign-in, the password leg being the twin-issued token ────────
|
|
307
|
+
function LoginScreen({ onSignedIn }) {
|
|
308
|
+
const [step, setStep] = useState('email');
|
|
309
|
+
const [email, setEmail] = useState('');
|
|
310
|
+
const [token, setToken] = useState('');
|
|
311
|
+
const [error, setError] = useState(null);
|
|
312
|
+
const [busy, setBusy] = useState(false);
|
|
313
|
+
const clean = email.trim();
|
|
314
|
+
const next = () => {
|
|
315
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) {
|
|
316
|
+
setError('Please enter a valid email address.');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
setError(null);
|
|
320
|
+
setStep('token');
|
|
321
|
+
};
|
|
322
|
+
const signIn = async () => {
|
|
323
|
+
setBusy(true);
|
|
324
|
+
const me = await wire(token.trim(), '/v2/userinfo');
|
|
325
|
+
setBusy(false);
|
|
326
|
+
if (me.status === 401) {
|
|
327
|
+
setError('That access token isn’t right. Try again.');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (me.status !== 200) {
|
|
331
|
+
setError(problemText(me));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// Is this token THIS member's? userinfo answers the token's own member; a token for someone else is refused here.
|
|
335
|
+
if (String(me.body.email ?? '').toLowerCase() !== clean.toLowerCase()) {
|
|
336
|
+
setError(`That token does not sign in as ${clean}.`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
onSignedIn({ email: clean, token: token.trim() });
|
|
340
|
+
};
|
|
341
|
+
return (_jsxs("div", { className: "login-page", children: [_jsx("div", { className: "login-top", children: _jsxs("span", { className: "wordmark", children: ["Linked", _jsx(InLogo, { size: 26 })] }) }), _jsxs("div", { className: "login-card", children: [_jsx("h1", { children: "Sign in" }), _jsx("p", { className: "login-sub", children: "Stay updated on your professional world." }), step === 'email' ? (_jsxs("form", { onSubmit: (e) => { e.preventDefault(); next(); }, children: [_jsxs("label", { className: "field", children: [_jsx("input", { autoFocus: true, name: "session_key", placeholder: " ", value: email, onChange: (e) => setEmail(e.target.value), autoComplete: "username" }), _jsx("span", { children: "Email or phone" })] }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-signin", type: "submit", disabled: clean === '', children: "Continue" })] })) : (_jsxs("form", { onSubmit: (e) => { e.preventDefault(); void signIn(); }, children: [_jsxs("div", { className: "login-who", children: [clean, " ", _jsx("button", { type: "button", className: "btn-text", onClick: () => { setStep('email'); setError(null); }, children: "Change" })] }), _jsxs("label", { className: "field", children: [_jsx("input", { autoFocus: true, name: "session_password", type: "password", placeholder: " ", value: token, onChange: (e) => setToken(e.target.value), autoComplete: "current-password" }), _jsx("span", { children: "Access token" })] }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-signin", type: "submit", disabled: token.trim() === '' || busy, children: "Sign in" })] })), _jsxs("p", { className: "login-note", children: ["This twin signs you in with the access token it issued for your account (", _jsx("code", { children: "/_twin/tokens" }), "), in place of a password."] })] })] }));
|
|
342
|
+
}
|
|
343
|
+
function TopNav({ session, onSignOut }) {
|
|
344
|
+
const initial = session.email[0]?.toUpperCase() ?? '?';
|
|
345
|
+
const item = (d, label, active = false) => (_jsxs("span", { className: active ? 'nav-item active' : 'nav-item', children: [_jsx(Icon, { d: d, size: 24 }), _jsx("span", { children: label })] }));
|
|
346
|
+
return (_jsx("header", { className: "topnav", children: _jsxs("div", { className: "topnav-inner", children: [_jsx("a", { href: "#/", "aria-label": "LinkedIn home", children: _jsx(InLogo, {}) }), _jsxs("div", { className: "search", children: [_jsx(Icon, { d: I_SEARCH, size: 16 }), _jsx("input", { placeholder: "Search", "aria-label": "Search" })] }), _jsxs("nav", { className: "nav-items", children: [item(I_HOME, 'Home'), item(I_NETWORK, 'My Network'), item(I_JOBS, 'Jobs'), item(I_MSG, 'Messaging'), item(I_BELL, 'Notifications'), _jsxs("button", { className: "nav-item nav-me", title: `Sign out ${session.email}`, onClick: onSignOut, children: [_jsx("span", { className: "me-avatar", children: initial }), _jsx("span", { children: "Me \u25BE" })] })] })] }) }));
|
|
347
|
+
}
|
|
348
|
+
function App() {
|
|
349
|
+
const [session, setSession] = useState(readSession);
|
|
350
|
+
const [route, setRoute] = useState(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
|
|
351
|
+
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
352
|
+
const [reloadKey] = useState(0);
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
const onHash = () => { setRoute(parseRoute(location.hash)); setNowMs(Date.now()); window.scrollTo(0, 0); };
|
|
355
|
+
// a `#/…` link is one of the mirror's own routes; under a World's <base> the browser would resolve it
|
|
356
|
+
// against the base and leave the shell, so it moves the hash here
|
|
357
|
+
const onClick = (e) => {
|
|
358
|
+
const a = e.target?.closest?.('a[href^="#/"]');
|
|
359
|
+
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
360
|
+
return;
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
location.hash = a.getAttribute('href');
|
|
363
|
+
};
|
|
364
|
+
window.addEventListener('hashchange', onHash);
|
|
365
|
+
document.addEventListener('click', onClick, true);
|
|
366
|
+
return () => { window.removeEventListener('hashchange', onHash); document.removeEventListener('click', onClick, true); };
|
|
367
|
+
}, []);
|
|
368
|
+
const signIn = (s) => {
|
|
369
|
+
sessionStorage.setItem(SESSION_EMAIL, s.email);
|
|
370
|
+
sessionStorage.setItem(SESSION_TOKEN, s.token);
|
|
371
|
+
setSession(s);
|
|
372
|
+
go('');
|
|
373
|
+
};
|
|
374
|
+
const signOut = () => {
|
|
375
|
+
sessionStorage.removeItem(SESSION_EMAIL);
|
|
376
|
+
sessionStorage.removeItem(SESSION_TOKEN);
|
|
377
|
+
setSession(null);
|
|
378
|
+
go('login');
|
|
379
|
+
};
|
|
380
|
+
if (!session || route.page === 'login')
|
|
381
|
+
return _jsx(LoginScreen, { onSignedIn: signIn });
|
|
382
|
+
let screen;
|
|
383
|
+
if (route.page === 'company')
|
|
384
|
+
screen = _jsx(CompanyScreen, { session: session, vanity: route.vanity, nowMs: nowMs, reloadKey: reloadKey });
|
|
385
|
+
else if (route.page === 'post')
|
|
386
|
+
screen = _jsx(PostScreen, { session: session, urn: route.urn, nowMs: nowMs, reloadKey: reloadKey });
|
|
387
|
+
else
|
|
388
|
+
screen = _jsx(HomeScreen, { session: session });
|
|
389
|
+
return (_jsxs("div", { className: "app", children: [_jsx(TopNav, { session: session, onSignOut: signOut }), _jsx("main", { className: route.page === 'post' ? 'main main-narrow' : 'main', children: screen })] }));
|
|
390
|
+
}
|
|
391
|
+
const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
|
|
392
|
+
if (el)
|
|
393
|
+
createRoot(el).render(_jsx(App, {}));
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
4
|
+
import { createLinkedinTwinServer } from "./linkedin-server.js";
|
|
5
|
+
import { createLinkedinMirrorServer } from "./linkedin-mirror-ui.js";
|
|
6
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
7
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
8
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
9
|
+
const readOnly = hasFlag(rest, '--read-only');
|
|
10
|
+
if (cmd === 'serve') {
|
|
11
|
+
const server = await createLinkedinTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
12
|
+
process.stdout.write(`linkedin twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${server.port}\n`);
|
|
13
|
+
await keepProcessAlive();
|
|
14
|
+
}
|
|
15
|
+
else if (cmd === 'mirror') {
|
|
16
|
+
const s = await createLinkedinMirrorServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
17
|
+
process.stdout.write(`linkedin mirror UI (linkedin.com: sign in, company page, posts, single post) at ${s.url}\n`);
|
|
18
|
+
await keepProcessAlive();
|
|
19
|
+
}
|
|
20
|
+
else if (cmd === 'conformance') {
|
|
21
|
+
// Lazy — the conformance module is DEV-ONLY and must not be reachable from the runtime entrypoints.
|
|
22
|
+
const { checkLinkedinConformance } = await import("./linkedin-conformance.js");
|
|
23
|
+
const report = await checkLinkedinConformance({ ...(root ? { root } : {}) });
|
|
24
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
25
|
+
if (!report.ok)
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
process.stdout.write('Usage: world-linkedin serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ACTIVE_VERSIONS, handleLinkedinTwinRequest, projectPost, templateHashtags } from './linkedin-twin.js';
|
|
2
|
+
export type { LinkedinRequest } from './linkedin-twin.js';
|
|
3
|
+
export type { LinkedinResponse } from './linkedin-errors.js';
|
|
4
|
+
export { createLinkedinTwinFetch, createLinkedinTwinServer, type LinkedinTwinFetchOptions } from './linkedin-server.js';
|
|
5
|
+
export { budgetedLinkedinExecute, LINKEDIN_VERSION, MAX_PROCESSING_WAIT_MS, performLinkedinAction, performLinkedinActionWithin, postBodyForVendor, syncLinkedinFromReal, syncLinkedinFromRemote, untemplateHashtags, uploadVideoToVendor, } from './linkedin-connector.js';
|
|
6
|
+
export { buildLinkedinMirrorClient, commentarySegments, createLinkedinMirrorServer, followerLine, linkedinMirrorHtml, linkedinMirrorStyles, mediaKind, MIRROR_VERSION, needsFold, postRoute, postsQuery, relativeTime, sourceHost, } from './linkedin-mirror-ui.js';
|
|
7
|
+
export type { LiRow, Segment } from './linkedin-mirror-ui.js';
|
|
8
|
+
export { LINKEDIN_BUDGET_BURST_CEILING, LINKEDIN_BUDGET_CEILING, LINKEDIN_BUDGET_MAX_RETRY_AFTER_S, LINKEDIN_BUDGET_WINDOW_MS, LINKEDIN_CALL_WEIGHTS, LINKEDIN_RATE_BUDGET, LinkedinBudget, LinkedinBudgetError, linkedinBudgetPath, linkedinCallWeight, } from './linkedin-budget.js';
|
|
9
|
+
import { type TwinPack } from '@volter/world-core';
|
|
10
|
+
export declare const pack: TwinPack;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export { ACTIVE_VERSIONS, handleLinkedinTwinRequest, projectPost, templateHashtags } from "./linkedin-twin.js";
|
|
2
|
+
export { createLinkedinTwinFetch, createLinkedinTwinServer } from "./linkedin-server.js";
|
|
3
|
+
export { budgetedLinkedinExecute, LINKEDIN_VERSION, MAX_PROCESSING_WAIT_MS, performLinkedinAction, performLinkedinActionWithin, postBodyForVendor, syncLinkedinFromReal, syncLinkedinFromRemote, untemplateHashtags, uploadVideoToVendor, } from "./linkedin-connector.js";
|
|
4
|
+
export { buildLinkedinMirrorClient, commentarySegments, createLinkedinMirrorServer, followerLine, linkedinMirrorHtml, linkedinMirrorStyles, mediaKind, MIRROR_VERSION, needsFold, postRoute, postsQuery, relativeTime, sourceHost, } from "./linkedin-mirror-ui.js";
|
|
5
|
+
// The client-side rate budget the perform and refresh adapters charge (linkedin-budget.ts says how the
|
|
6
|
+
// numbers were chosen). The mechanism is the kernel's; these are this vendor's numbers.
|
|
7
|
+
export { LINKEDIN_BUDGET_BURST_CEILING, LINKEDIN_BUDGET_CEILING, LINKEDIN_BUDGET_MAX_RETRY_AFTER_S, LINKEDIN_BUDGET_WINDOW_MS, LINKEDIN_CALL_WEIGHTS, LINKEDIN_RATE_BUDGET, LinkedinBudget, LinkedinBudgetError, linkedinBudgetPath, linkedinCallWeight, } from "./linkedin-budget.js";
|
|
8
|
+
import { registerPack } from '@volter/world-core';
|
|
9
|
+
import { performLinkedinAction, syncLinkedinFromRemote } from "./linkedin-connector.js";
|
|
10
|
+
import { LINKEDIN_RATE_BUDGET as RATE_BUDGET } from "./linkedin-budget.js";
|
|
11
|
+
/**
|
|
12
|
+
* The api.linkedin.com paths THIS pack serves, as a RegExp SOURCE for the descriptor's `hosts` path
|
|
13
|
+
* rule: the versioned `/rest` resources an organization posts through, the OpenID userinfo, and the
|
|
14
|
+
* twin-only control prefix. ANCHORED, so `/rest/postsomething` stays unclaimed; everything else on
|
|
15
|
+
* api.linkedin.com (ads, analytics, comments, reactions, the legacy /v2 resources) refuses loudly at
|
|
16
|
+
* the real vendor rather than landing in a twin that cannot serve it.
|
|
17
|
+
*/
|
|
18
|
+
const API_PATHS = '^(?:/rest/(?:posts|images|videos|organizationAcls|organizations|organizationsLookup|networkSizes)(?:/[^/]+)?/?|/v2/userinfo/?)$|^/_twin/';
|
|
19
|
+
export const pack = {
|
|
20
|
+
vendor: 'linkedin',
|
|
21
|
+
// The SAME object linkedin-budget.ts declares at module load — one source of truth.
|
|
22
|
+
rateBudget: RATE_BUDGET,
|
|
23
|
+
transport: 'rest',
|
|
24
|
+
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a
|
|
25
|
+
// plugin — its wire, its tree, and its half of the real state system. Born on it.
|
|
26
|
+
protocol: '2',
|
|
27
|
+
archetype: 'crud',
|
|
28
|
+
bin: 'world-linkedin',
|
|
29
|
+
resources: ['organization', 'member', 'organization_acl', 'token', 'post'],
|
|
30
|
+
specSource: 'LinkedIn Marketing API docs (learn.microsoft.com/linkedin, li-lms-2026-09), read 2026-09-26: Posts API + Post Schema, Images API, Videos API, Organization Access Control by Role, Organization Lookup (incl. networkSizes), Versioning, Error Responses, Rate Limiting; and Sign In with LinkedIn using OpenID Connect (/v2/userinfo). LinkedIn publishes no machine-readable spec for this surface.',
|
|
31
|
+
description: "LinkedIn twin — an organization's posts (commentary with one image, one video or an article) through the versioned Posts API, the Images and Videos upload protocols with signed upload URLs, the page's admin roles and follower count, and a linkedin.com company-page mirror; everything else refuses by name.",
|
|
32
|
+
// A page's posts barely move and LinkedIn's limits are daily: hourly, and on demand.
|
|
33
|
+
refresh: { every: '1h', webhook: false, onDemand: { atMost: '60s' } },
|
|
34
|
+
stateSystem: { perform: performLinkedinAction, refresh: syncLinkedinFromRemote },
|
|
35
|
+
// The round trip: the page, its admin, the admin's role and the token the OAuth leg would have
|
|
36
|
+
// minted (the vendor has no create-from-nothing for any of them), then a post — minted fresh from
|
|
37
|
+
// the tree on every send.
|
|
38
|
+
roundTrip: [
|
|
39
|
+
{ method: 'POST', path: '/_twin/organizations', body: { id: '2414183', localizedName: 'Round Trip Co', vanityName: 'round-trip-co' } },
|
|
40
|
+
{ method: 'POST', path: '/_twin/members', body: { id: 'roundTrip', localizedFirstName: 'Round', localizedLastName: 'Trip', email: 'round.trip@example.com' } },
|
|
41
|
+
{ method: 'POST', path: '/_twin/organizationAcls', body: { organization: 'urn:li:organization:2414183', roleAssignee: 'urn:li:person:roundTrip', role: 'ADMINISTRATOR', state: 'APPROVED' } },
|
|
42
|
+
{ method: 'POST', path: '/_twin/tokens', body: { token: 'round-trip-token', member: 'urn:li:person:roundTrip', scopes: ['w_organization_social', 'r_organization_social', 'rw_organization_admin'] } },
|
|
43
|
+
{
|
|
44
|
+
method: 'POST', path: '/rest/posts',
|
|
45
|
+
headers: { authorization: 'Bearer round-trip-token', 'linkedin-version': '202609', 'x-restli-protocol-version': '2.0.0' },
|
|
46
|
+
body: { author: 'urn:li:organization:2414183', commentary: 'round trip', visibility: 'PUBLIC', distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] }, lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
parityOrigin: 'http://twin',
|
|
50
|
+
// the write handler and the refresh adapter store the same shape (scripts/shape-parity.test.ts)
|
|
51
|
+
shapeParity: 'held',
|
|
52
|
+
// ADOPTION. `linkedin-api-client` is LinkedIn's own JavaScript client ("Official JavaScript client
|
|
53
|
+
// library for LinkedIn APIs", github.com/linkedin-developers/linkedin-api-js-client), and the
|
|
54
|
+
// Python client of the same repo family is `linkedin-api-client` on PyPI. LINKEDIN is the stem of
|
|
55
|
+
// LINKEDIN_ACCESS_TOKEN / LINKEDIN_CLIENT_ID et al.
|
|
56
|
+
adoption: {
|
|
57
|
+
sdks: ['linkedin-api-client'],
|
|
58
|
+
pypi: ['linkedin-api-client'],
|
|
59
|
+
envStems: ['LINKEDIN'],
|
|
60
|
+
},
|
|
61
|
+
// INTERCEPTION. api.linkedin.com for the API (path-scoped: this pack models one slice of it);
|
|
62
|
+
// www.linkedin.com only for the signed upload URLs (`/dms-uploads/`) the Images and Videos APIs
|
|
63
|
+
// return — never the site itself; media.licdn.com and dms.licdn.com for the image and video bytes
|
|
64
|
+
// an asset's downloadUrl names.
|
|
65
|
+
hosts: [
|
|
66
|
+
{ host: 'api.linkedin.com', pathPattern: API_PATHS },
|
|
67
|
+
{ host: 'www.linkedin.com', pathPattern: '^/dms-uploads/' },
|
|
68
|
+
{ host: 'media.licdn.com', pathPattern: '^/dms/image/' },
|
|
69
|
+
{ host: 'dms.licdn.com', pathPattern: '^/playlist/' },
|
|
70
|
+
],
|
|
71
|
+
endpointEnvNone: "linkedin-api-client takes no base-URL environment variable (its RestliClient builds https://api.linkedin.com/rest URLs from a constant), so a World reaches it through the hosts above; inventing a LINKEDIN_BASE_URL the app never reads would report coverage the app does not have.",
|
|
72
|
+
browserRouting: { apiPathPrefix: '/rest/', loaderHost: 'https://api.linkedin.com' },
|
|
73
|
+
};
|
|
74
|
+
registerPack(pack);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
|
|
2
|
+
/** Rolling window, in ms — the longest the kernel's ledger keeps (LinkedIn's own window is a day). */
|
|
3
|
+
export declare const LINKEDIN_BUDGET_WINDOW_MS: number;
|
|
4
|
+
/** Weighted units allowed inside one window: conservative, not transcribed (no figure is published). */
|
|
5
|
+
export declare const LINKEDIN_BUDGET_CEILING = 600;
|
|
6
|
+
/** Weighted units in any 60 s: the kernel fallback's 30, so no burst anchor is required. */
|
|
7
|
+
export declare const LINKEDIN_BUDGET_BURST_CEILING = 30;
|
|
8
|
+
/** Seconds. A Retry-After above this means the member or app is out for the day — fail loudly. */
|
|
9
|
+
export declare const LINKEDIN_BUDGET_MAX_RETRY_AFTER_S = 3600;
|
|
10
|
+
export declare const LINKEDIN_CALL_WEIGHTS: {
|
|
11
|
+
/** A post or its deletion — the organization speaking, in public. */
|
|
12
|
+
readonly write: 5;
|
|
13
|
+
/** initializeUpload / finalizeUpload. */
|
|
14
|
+
readonly upload: 2;
|
|
15
|
+
/** A read: a video's status, a finder page, an organization, the ACLs. */
|
|
16
|
+
readonly read: 1;
|
|
17
|
+
/** Everything else on the vendor: priced above a read. */
|
|
18
|
+
readonly other: 2;
|
|
19
|
+
};
|
|
20
|
+
/** THE PACK'S DECLARATION — pure data, the only LinkedIn-specific thing in the whole budget. */
|
|
21
|
+
export declare const LINKEDIN_RATE_BUDGET: RateBudgetDeclaration;
|
|
22
|
+
/** Price one call, keyed `"<METHOD> <path>"` with the query split off and the method upper-cased. */
|
|
23
|
+
export declare function linkedinCallWeight(method: string, path: string): number;
|
|
24
|
+
/** Where this vendor's ledger lives: token-keyed and cwd-independent by default. */
|
|
25
|
+
export declare function linkedinBudgetPath(opts?: {
|
|
26
|
+
root?: string;
|
|
27
|
+
token?: string;
|
|
28
|
+
} | string): string;
|
|
29
|
+
export type LinkedinBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
30
|
+
/** This vendor's budget — the kernel guard bound to this declaration. */
|
|
31
|
+
export declare class LinkedinBudget extends RateBudget {
|
|
32
|
+
constructor(opts?: LinkedinBudgetOptions);
|
|
33
|
+
}
|
|
34
|
+
export { RateBudgetError as LinkedinBudgetError } from '@volter/world-core';
|
|
35
|
+
export type LinkedinBudgetReservation = RateBudgetReservation;
|
|
36
|
+
export type LinkedinBudgetSnapshot = RateBudgetSnapshot;
|