diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 3e38c4db..805e9f06 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 facebook from "./services/facebook.js"; let freebind; @@ -192,6 +193,8 @@ export default async function(host, patternMatch, lang, obj) { r = await loom({ id: patternMatch.id }); + case "facebook": + r = await facebook(patternMatch); break; default: return createResponse("error", { diff --git a/src/modules/processing/services/facebook.js b/src/modules/processing/services/facebook.js new file mode 100644 index 00000000..d8459ee6 --- /dev/null +++ b/src/modules/processing/services/facebook.js @@ -0,0 +1,71 @@ +import { genericUserAgent } from "../../config.js"; + +const headers = { + 'User-Agent': genericUserAgent, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', +} + +async function resolveUrl(url) { + return await fetch(url, { headers }) + .then(r => { + if (r.headers.get('location')) { + return decodeURIComponent(r.headers.get('location')) + } + if (r.headers.get('link')) { + const linkMatch = r.headers.get('link').match(/<(.*?)\/>/) + return decodeURIComponent(linkMatch[1]) + } + return false + }) + .catch(() => false) +} + +export default async function ({ shortLink, username, id }) { + const isShortLink = !!shortLink?.length + + let url = isShortLink + ? `https://fb.watch/${shortLink}` + : `https://web.facebook.com/${username}/videos/${id}` + + if (isShortLink) { + url = await resolveUrl(url) + } + + const html = await fetch(url, { headers }) + .then(r => r.text()) + .catch(() => false) + + if (!html) return { error: 'ErrorCouldntFetch' }; + + const urls = [] + const hd = html.match('"browser_native_hd_url":"(.*?)"') + const sd = html.match('"browser_native_sd_url":"(.*?)"') + + if (hd?.length) { + urls.push(JSON.parse(`["${hd[1]}"]`)[0]) + } + if (sd?.length) { + urls.push(JSON.parse(`["${sd[1]}"]`)[0]) + } + + if (!urls.length) { + return { error: 'ErrorEmptyDownload' }; + } + + let filename = `facebook_${id}.mp4` + if (isShortLink) { + filename = `facebook_${shortLink}.mp4` + } else if (username?.length && username !== 'user') { + filename = `facebook_${username}_${id}.mp4` + } + + return { + urls: urls[0], + filename, + audioFilename: `${filename.slice(0, -4)}_audio`, + }; +} \ No newline at end of file diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index d727b9a5..9978347b 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -118,6 +118,18 @@ "alias": "loom videos", "patterns": ["share/:id"], "enabled": true + }, + "facebook": { + "alias": "facebook videos", + "altDomains": ["fb.watch"], + "subdomains": ["web"], + "patterns": [ + "_shortLink/:shortLink", + ":username/videos/:caption/:id", + ":username/videos/:id", + "reel/:id" + ], + "enabled": true } } } diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index ddeea31f..0fed8723 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -1,5 +1,5 @@ export const testers = { - "bilibili": (patternMatch) => + "bilibili": (patternMatch) => patternMatch.comId?.length <= 12 || patternMatch.comShortLink?.length <= 16 || patternMatch.tvId?.length <= 24, @@ -27,7 +27,7 @@ export const testers = { patternMatch.id?.length === 32 || patternMatch.yappyId?.length === 32, "soundcloud": (patternMatch) => - (patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255) + (patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255) || patternMatch.shortLink?.length <= 32, "streamable": (patternMatch) => @@ -58,4 +58,10 @@ export const testers = { "youtube": (patternMatch) => patternMatch.id?.length <= 11, + + "facebook": (patternMatch) => + patternMatch.shortLink?.length <= 11 + || patternMatch.username?.length <= 30 + || patternMatch.caption?.length <= 255 + || patternMatch.id?.length <= 20, } diff --git a/src/modules/processing/url.js b/src/modules/processing/url.js index 111f1f6f..a17ee0bd 100644 --- a/src/modules/processing/url.js +++ b/src/modules/processing/url.js @@ -23,7 +23,7 @@ function aliasURL(url) { ** but we only care about the 1st segment of the path */ url = new URL(`https://youtube.com/watch?v=${ encodeURIComponent(parts[1]) - }`) + }`) } break; @@ -31,7 +31,7 @@ function aliasURL(url) { if (url.hostname === 'pin.it' && parts.length === 2) { url = new URL(`https://pinterest.com/url_shortener/${ encodeURIComponent(parts[1]) - }`) + }`) } break; @@ -64,6 +64,16 @@ function aliasURL(url) { if (url.hostname === 'dai.ly' && parts.length === 2) { url = new URL(`https://dailymotion.com/video/${parts[1]}`) } + + case "facebook": + case "fb": + if (url.searchParams.get('v')) { + url = new URL(`https://web.facebook.com/user/videos/${url.searchParams.get('v')}`) + } + if (url.hostname === 'fb.watch') { + url = new URL(`https://web.facebook.com/_shortLink/${parts[1]}`) + } + break; break; case "ddinstagram": if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) { diff --git a/src/util/tests.json b/src/util/tests.json index 982d99cb..278d0b33 100644 --- a/src/util/tests.json +++ b/src/util/tests.json @@ -1160,5 +1160,54 @@ "code": 200, "status": "stream" } + }], + "facebook": [{ + "name": "direct video with username and id", + "url": "https://web.facebook.com/100048111287134/videos/1157798148685638/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "direct video with id as query param", + "url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "direct video with caption", + "url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "shortlink video", + "url": "https://fb.watch/r1K6XHMfGT/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "reel video", + "url": "https://web.facebook.com/reel/730293269054758", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "shared video link", + "url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } }] }