diff --git a/README.md b/README.md index d2bb064c8..5fd1dd7d1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ this list is not final and keeps expanding over time. if support for a service y | bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ | | dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ | | instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ | +| linkedin | ✅ | ✅ | ✅ | ❌ | ❌ | | loom | ✅ | ❌ | ✅ | ✅ | ➖ | | ok video | ✅ | ❌ | ✅ | ✅ | ✅ | | pinterest | ✅ | ✅ | ✅ | ➖ | ➖ | @@ -45,6 +46,7 @@ this list is not final and keeps expanding over time. if support for a service y | service | notes or features | | :-------- | :----- | | instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. | +| linkedin | supports post and feed links. | | pinterest | supports photos, gifs, videos and stories. | | reddit | supports gifs and videos. | | rutube | supports yappy & private links. | diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 3e38c4db2..118c4e695 100644 --- a/src/modules/processing/match.js +++ b/src/modules/processing/match.js @@ -25,6 +25,7 @@ import twitch from "./services/twitch.js"; import rutube from "./services/rutube.js"; import dailymotion from "./services/dailymotion.js"; import loom from "./services/loom.js"; +import linkedin from "./services/linkedin.js"; let freebind; @@ -193,6 +194,12 @@ export default async function(host, patternMatch, lang, obj) { id: patternMatch.id }); break; + case "linkedin": + r = await linkedin({ + postId: patternMatch.id, + quality: obj.vQuality + }); + break; default: return createResponse("error", { t: loc(lang, 'ErrorUnsupported') diff --git a/src/modules/processing/services/linkedin.js b/src/modules/processing/services/linkedin.js new file mode 100644 index 000000000..71acefd8a --- /dev/null +++ b/src/modules/processing/services/linkedin.js @@ -0,0 +1,62 @@ +import { genericUserAgent } from "../../config.js"; + +const qualityMatch = { + "mp4-640p-30fp-crf28": 640, + "mp4-720p-30fp-crf28": 720 +}; + +export default async function (obj) { + const html = await fetch( + `https://www.linkedin.com/feed/update/urn:li:activity:${obj.postId}`, + { headers: { "user-agent": genericUserAgent } } + ) + .then((res) => res.text()) + .catch(() => {}); + + if (!html) { + return { error: "ErrorCouldntFetch" }; + } + + let data; + try { + const json = html + .split('data-sources="')[1] + .split('" data-poster-url="')[0] + .replaceAll(""", '"') + .replaceAll("&", "&"); + data = JSON.parse(json); + } catch (error) { + return { error: "ErrorCouldntFetch" }; + } + + let fallbackUrl; + const quality = obj.quality === "max" || obj.quality >= 720 ? 720 : 640; + const filenameBase = `linkedin_${obj.postId}`; + + for (const source of data) { + const videoQuality = qualityMatch[source.src.split("/")[6]]; + + if (videoQuality === quality) { + return { + urls: source.src, + filename: `${filenameBase}.mp4`, + audioFilename: `${filenameBase}_audio` + }; + // will prioritize using known quality over unknown quality if no matching quality + } else if (!videoQuality && !fallbackUrl) { + fallbackUrl = source.src; + } else { + fallbackUrl = source.src; + } + } + + if (fallbackUrl) { + return { + urls: fallbackUrl, + filename: `${filenameBase}.mp4`, + audioFilename: `${filenameBase}_audio` + }; + } + + return { error: "ErrorEmptyDownload" }; +} diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index d727b9a5e..de6fd2659 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -33,7 +33,6 @@ "vk": { "alias": "vk video & clips", "patterns": ["video:userId_:videoId", "clip:userId_:videoId", "clips:duplicate?z=clip:userId_:videoId"], - "subdomains": ["m"], "enabled": true }, "ok": { @@ -118,6 +117,11 @@ "alias": "loom videos", "patterns": ["share/:id"], "enabled": true + }, + "linkedin": { + "alias": "linkedin videos", + "patterns": ["feed/update/urn\\:li\\:activity\\:(:id)"], + "enabled": true } } } diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index ddeea31fb..6cc386759 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -9,6 +9,9 @@ export const testers = { patternMatch.postId?.length <= 12 || (patternMatch.username?.length <= 30 && patternMatch.storyId?.length <= 24), + "linkedin": (patternMatch) => + patternMatch.id?.length === 19, + "loom": (patternMatch) => patternMatch.id?.length <= 32, diff --git a/src/modules/processing/url.js b/src/modules/processing/url.js index 111f1f6fd..c2f861b12 100644 --- a/src/modules/processing/url.js +++ b/src/modules/processing/url.js @@ -70,6 +70,12 @@ function aliasURL(url) { url.hostname = 'instagram.com'; } break; + case "linkedin": + if (parts[1] === "posts") { + const postId = parts.pop().split("-").at(-2) + url = new URL(`https://linkedin.com/feed/update/urn:li:activity:${postId}`) + } + break; } return url diff --git a/src/util/tests.json b/src/util/tests.json index 501ac2c03..97687a1df 100644 --- a/src/util/tests.json +++ b/src/util/tests.json @@ -1160,5 +1160,42 @@ "code": 200, "status": "stream" } + }], + "linkedin": [{ + "name": "regular video (share link)", + "url": "https://www.linkedin.com/posts/jasonyoong_in-the-early-days-of-a-startup-sam-altman-activity-7211912701411827712-oR9m?utm_source=share", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "regular video (feed link)", + "url": "https://www.linkedin.com/feed/update/urn:li:activity:7211912701411827712/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "regular video (isAudioMuted)", + "url": "https://www.linkedin.com/feed/update/urn:li:activity:7211912701411827712/", + "params": { + "isAudioMuted": true + }, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "regular video (isAudioOnly)", + "url": "https://www.linkedin.com/feed/update/urn:li:activity:7211912701411827712/", + "params": { + "isAudioOnly": true + }, + "expected": { + "code": 200, + "status": "stream" + } }] }