mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-18 19:28:29 +00:00
Merge branch 'current' of https://github.com/imputnet/cobalt into imputnet-current
This commit is contained in:
commit
b5ff796153
2
.github/test.sh
vendored
2
.github/test.sh
vendored
@ -18,7 +18,7 @@ test_api() {
|
|||||||
-X POST \
|
-X POST \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}')
|
-d '{"url":"https://vine.co/v/huwVJIEJW50", "isAudioOnly": true}')
|
||||||
|
|
||||||
echo "$API_RESPONSE"
|
echo "$API_RESPONSE"
|
||||||
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
|
||||||
|
24
.github/workflows/test.yml
vendored
24
.github/workflows/test.yml
vendored
@ -36,3 +36,27 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Run test script
|
- name: Run test script
|
||||||
run: .github/test.sh api
|
run: .github/test.sh api
|
||||||
|
|
||||||
|
check-services:
|
||||||
|
name: test service functionality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
services: ${{ steps.checkServices.outputs.service_list }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- id: checkServices
|
||||||
|
run: npm ci && echo "service_list=$(node src/util/test-ci get-services)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
test-services:
|
||||||
|
needs: check-services
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
service: ${{ fromJson(needs.check-services.outputs.services) }}
|
||||||
|
name: "test service: ${{ matrix.service }}"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: npm ci && node src/util/test-ci run-tests-for ${{ matrix.service }}
|
@ -19,11 +19,13 @@ this list is not final and keeps expanding over time. if support for a service y
|
|||||||
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| instagram posts & reels | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
|
| facebook videos | ✅ | ❌ | ❌ | ➖ | ➖ |
|
||||||
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
| loom | ✅ | ❌ | ✅ | ✅ | ➖ |
|
||||||
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
|
| ok video | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||||
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| snapchat stories & spotlights | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
|
||||||
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
|
||||||
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
@ -45,8 +47,10 @@ this list is not final and keeps expanding over time. if support for a service y
|
|||||||
| service | notes or features |
|
| service | notes or features |
|
||||||
| :-------- | :----- |
|
| :-------- | :----- |
|
||||||
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
| instagram | supports reels, photos, and videos. lets you pick what to save from multi-media posts. |
|
||||||
|
| facebook | supports public accessible videos content only. |
|
||||||
| pinterest | supports photos, gifs, videos and stories. |
|
| pinterest | supports photos, gifs, videos and stories. |
|
||||||
| reddit | supports gifs and videos. |
|
| reddit | supports gifs and videos. |
|
||||||
|
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
|
||||||
| rutube | supports yappy & private links. |
|
| rutube | supports yappy & private links. |
|
||||||
| soundcloud | supports private links. |
|
| soundcloud | supports private links. |
|
||||||
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
|
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
|
||||||
|
@ -56,8 +56,9 @@ sudo service nscd start
|
|||||||
| `API_LISTEN_ADDRESS` | `0.0.0.0` | `127.0.0.1` | changes address from which api server is accessible. **if you are using docker, you usually don't need to configure this.** |
|
| `API_LISTEN_ADDRESS` | `0.0.0.0` | `127.0.0.1` | changes address from which api server is accessible. **if you are using docker, you usually don't need to configure this.** |
|
||||||
| `API_URL` | ➖ | `https://api.cobalt.tools/` | changes url from which api server is accessible. <br> ***REQUIRED TO RUN THE API***. |
|
| `API_URL` | ➖ | `https://api.cobalt.tools/` | changes url from which api server is accessible. <br> ***REQUIRED TO RUN THE API***. |
|
||||||
| `API_NAME` | `unknown` | `ams-1` | api server name that is shown in `/api/serverInfo`. |
|
| `API_NAME` | `unknown` | `ams-1` | api server name that is shown in `/api/serverInfo`. |
|
||||||
|
| `API_EXTERNAL_PROXY` | ➖ | `http://user:password@127.0.0.1:8080`| url of the proxy that will be passed to [`ProxyAgent`](https://undici.nodejs.org/#/docs/api/ProxyAgent) and used for all external requests. HTTP(S) only. |
|
||||||
| `CORS_WILDCARD` | `1` | `0` | toggles cross-origin resource sharing. <br> `0`: disabled. `1`: enabled. |
|
| `CORS_WILDCARD` | `1` | `0` | toggles cross-origin resource sharing. <br> `0`: disabled. `1`: enabled. |
|
||||||
| `CORS_URL` | not used | `https://cobalt.tools/` | cross-origin resource sharing url. api will be available only from this url if `CORS_WILDCARD` is set to `0`. |
|
| `CORS_URL` | not used | `https://cobalt.tools` | cross-origin resource sharing url. api will be available only from this url if `CORS_WILDCARD` is set to `0`. |
|
||||||
| `COOKIE_PATH` | not used | `/cookies.json` | path for cookie file relative to main folder. |
|
| `COOKIE_PATH` | not used | `/cookies.json` | path for cookie file relative to main folder. |
|
||||||
| `PROCESSING_PRIORITY` | not used | `10` | changes `nice` value* for ffmpeg subprocess. available only on unix systems. |
|
| `PROCESSING_PRIORITY` | not used | `10` | changes `nice` value* for ffmpeg subprocess. available only on unix systems. |
|
||||||
| `FREEBIND_CIDR` | ➖ | `2001:db8::/32` | IPv6 prefix used for randomly assigning addresses to cobalt requests. only supported on linux systems. see below for more info. |
|
| `FREEBIND_CIDR` | ➖ | `2001:db8::/32` | IPv6 prefix used for randomly assigning addresses to cobalt requests. only supported on linux systems. see below for more info. |
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { setGlobalDispatcher, ProxyAgent } from "undici";
|
||||||
|
|
||||||
import { env, version } from "../modules/config.js";
|
import { env, version } from "../modules/config.js";
|
||||||
|
|
||||||
@ -87,32 +88,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
|||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
|
|
||||||
app.use('/api/json', express.json({
|
app.use('/api/json', express.json({ limit: 1024 }));
|
||||||
verify: (req, res, buf) => {
|
app.use('/api/json', (err, _, res, next) => {
|
||||||
if (String(req.header('Accept')) === "application/json") {
|
if (err) {
|
||||||
if (buf.length > 720) throw new Error();
|
|
||||||
JSON.parse(buf);
|
|
||||||
} else {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// handle express.json errors properly (https://github.com/expressjs/express/issues/4065)
|
|
||||||
app.use('/api/json', (err, req, res, next) => {
|
|
||||||
let errorText = "invalid json body";
|
|
||||||
const acceptHeader = String(req.header('Accept')) !== "application/json";
|
|
||||||
|
|
||||||
if (err || acceptHeader) {
|
|
||||||
if (acceptHeader) errorText = "invalid accept header";
|
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
status: "error",
|
status: "error",
|
||||||
text: errorText
|
text: "invalid json body"
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/json', async (req, res) => {
|
app.post('/api/json', async (req, res) => {
|
||||||
const request = req.body;
|
const request = req.body;
|
||||||
@ -123,6 +109,10 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
|||||||
res.status(status).json(body);
|
res.status(status).json(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!acceptRegex.test(req.header('Accept'))) {
|
||||||
|
return fail('ErrorInvalidAcceptHeader');
|
||||||
|
}
|
||||||
|
|
||||||
if (!acceptRegex.test(req.header('Content-Type'))) {
|
if (!acceptRegex.test(req.header('Content-Type'))) {
|
||||||
return fail('ErrorInvalidContentType');
|
return fail('ErrorInvalidContentType');
|
||||||
}
|
}
|
||||||
@ -219,6 +209,14 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
|
|||||||
randomizeCiphers();
|
randomizeCiphers();
|
||||||
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
|
setInterval(randomizeCiphers, 1000 * 60 * 30); // shuffle ciphers every 30 minutes
|
||||||
|
|
||||||
|
if (env.externalProxy) {
|
||||||
|
if (env.freebindCIDR) {
|
||||||
|
throw new Error('Freebind is not available when external proxy is enabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
setGlobalDispatcher(new ProxyAgent(env.externalProxy))
|
||||||
|
}
|
||||||
|
|
||||||
app.listen(env.apiPort, env.listenAddress, () => {
|
app.listen(env.apiPort, env.listenAddress, () => {
|
||||||
console.log(`\n` +
|
console.log(`\n` +
|
||||||
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
|
||||||
|
@ -159,6 +159,7 @@
|
|||||||
"UpdateOneMillion": "1 million users and blazing speed",
|
"UpdateOneMillion": "1 million users and blazing speed",
|
||||||
"ErrorYTAgeRestrict": "this youtube video is age-restricted, so i can't see it. try another one!",
|
"ErrorYTAgeRestrict": "this youtube video is age-restricted, so i can't see it. try another one!",
|
||||||
"ErrorYTLogin": "couldn't get this youtube video because it requires an account to view.\n\nthis limitation is done by google to seemingly stop scraping, affecting all 3rd party tools and even their own clients.\n\ntry again, but if issue persists, {ContactLink}.",
|
"ErrorYTLogin": "couldn't get this youtube video because it requires an account to view.\n\nthis limitation is done by google to seemingly stop scraping, affecting all 3rd party tools and even their own clients.\n\ntry again, but if issue persists, {ContactLink}.",
|
||||||
"ErrorYTRateLimit": "i got rate limited by youtube. try again in a few seconds, but if issue persists, {ContactLink}."
|
"ErrorYTRateLimit": "i got rate limited by youtube. try again in a few seconds, but if issue persists, {ContactLink}.",
|
||||||
|
"ErrorInvalidAcceptHeader": "invalid accept header"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,7 +48,9 @@ const
|
|||||||
|
|
||||||
processingPriority: process.platform !== 'win32'
|
processingPriority: process.platform !== 'win32'
|
||||||
&& process.env.PROCESSING_PRIORITY
|
&& process.env.PROCESSING_PRIORITY
|
||||||
&& parseInt(process.env.PROCESSING_PRIORITY)
|
&& parseInt(process.env.PROCESSING_PRIORITY),
|
||||||
|
|
||||||
|
externalProxy: process.env.API_EXTERNAL_PROXY,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const
|
export const
|
||||||
|
@ -24,8 +24,11 @@ import streamable from "./services/streamable.js";
|
|||||||
import twitch from "./services/twitch.js";
|
import twitch from "./services/twitch.js";
|
||||||
import rutube from "./services/rutube.js";
|
import rutube from "./services/rutube.js";
|
||||||
import dailymotion from "./services/dailymotion.js";
|
import dailymotion from "./services/dailymotion.js";
|
||||||
|
import snapchat from "./services/snapchat.js";
|
||||||
import loom from "./services/loom.js";
|
import loom from "./services/loom.js";
|
||||||
|
import facebook from "./services/facebook.js";
|
||||||
import newgrounds from "./services/newgrounds.js";
|
import newgrounds from "./services/newgrounds.js";
|
||||||
|
|
||||||
let freebind;
|
let freebind;
|
||||||
|
|
||||||
export default async function(host, patternMatch, lang, obj) {
|
export default async function(host, patternMatch, lang, obj) {
|
||||||
@ -188,18 +191,29 @@ export default async function(host, patternMatch, lang, obj) {
|
|||||||
case "dailymotion":
|
case "dailymotion":
|
||||||
r = await dailymotion(patternMatch);
|
r = await dailymotion(patternMatch);
|
||||||
break;
|
break;
|
||||||
|
case "snapchat":
|
||||||
|
r = await snapchat({
|
||||||
|
url,
|
||||||
|
...patternMatch
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "loom":
|
case "loom":
|
||||||
r = await loom({
|
r = await loom({
|
||||||
id: patternMatch.id
|
id: patternMatch.id
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "facebook":
|
||||||
|
r = await facebook({
|
||||||
|
...patternMatch,
|
||||||
|
sourceUrl: url.href
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "newgrounds":
|
case "newgrounds":
|
||||||
r = await newgrounds({
|
r = await newgrounds({
|
||||||
type: patternMatch.type,
|
type: patternMatch.type,
|
||||||
method: patternMatch.method,
|
method: patternMatch.method,
|
||||||
id: patternMatch.id,
|
id: patternMatch.id,
|
||||||
});
|
});
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return createResponse("error", {
|
return createResponse("error", {
|
||||||
t: loc(lang, 'ErrorUnsupported')
|
t: loc(lang, 'ErrorUnsupported')
|
||||||
|
@ -73,6 +73,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
|||||||
switch (host) {
|
switch (host) {
|
||||||
case "instagram":
|
case "instagram":
|
||||||
case "twitter":
|
case "twitter":
|
||||||
|
case "snapchat":
|
||||||
params = { picker: r.picker };
|
params = { picker: r.picker };
|
||||||
break;
|
break;
|
||||||
case "tiktok":
|
case "tiktok":
|
||||||
@ -130,11 +131,13 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
|
|||||||
params = { type: "bridge" };
|
params = { type: "bridge" };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "facebook":
|
||||||
case "vine":
|
case "vine":
|
||||||
case "instagram":
|
case "instagram":
|
||||||
case "tumblr":
|
case "tumblr":
|
||||||
case "pinterest":
|
case "pinterest":
|
||||||
case "streamable":
|
case "streamable":
|
||||||
|
case "snapchat":
|
||||||
case "loom":
|
case "loom":
|
||||||
responseType = "redirect";
|
responseType = "redirect";
|
||||||
break;
|
break;
|
||||||
|
62
src/modules/processing/services/facebook.js
Normal file
62
src/modules/processing/services/facebook.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(url) {
|
||||||
|
return 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({ sourceUrl, shortLink, username, id }) {
|
||||||
|
const isShortLink = !!shortLink?.length
|
||||||
|
const isSharedLink = !!sourceUrl.match(/\/share\/\w\//)?.length
|
||||||
|
|
||||||
|
let url = isShortLink
|
||||||
|
? `https://fb.watch/${shortLink}`
|
||||||
|
: `https://web.facebook.com/${username}/videos/${id}`
|
||||||
|
|
||||||
|
if (isShortLink) url = await resolveUrl(url)
|
||||||
|
if (isSharedLink) url = sourceUrl
|
||||||
|
|
||||||
|
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?.[1]) urls.push(JSON.parse(hd[1]))
|
||||||
|
if (sd?.[1]) urls.push(JSON.parse(sd[1]))
|
||||||
|
|
||||||
|
if (!urls.length) {
|
||||||
|
return { error: 'ErrorEmptyDownload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = `facebook_${id || shortLink}.mp4`
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: urls[0],
|
||||||
|
filename,
|
||||||
|
audioFilename: `${filename.slice(0, -4)}_audio`,
|
||||||
|
};
|
||||||
|
}
|
@ -45,7 +45,7 @@ export default async function(o) {
|
|||||||
|
|
||||||
let fileMetadata = {
|
let fileMetadata = {
|
||||||
title: cleanString(videoData.movie.title.trim()),
|
title: cleanString(videoData.movie.title.trim()),
|
||||||
author: cleanString(videoData.author.name.trim()),
|
author: cleanString((videoData.author?.name || videoData.compilationTitle).trim()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestVideo) return {
|
if (bestVideo) return {
|
||||||
|
96
src/modules/processing/services/snapchat.js
Normal file
96
src/modules/processing/services/snapchat.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { genericUserAgent } from "../../config.js";
|
||||||
|
import { getRedirectingURL } from "../../sub/utils.js";
|
||||||
|
import { extract, normalizeURL } from "../url.js";
|
||||||
|
|
||||||
|
const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="(https:\/\/cf-st\.sc-cdn\.net\/d\/[\w.?=]+&uc=\d+)" as="video"\/>/;
|
||||||
|
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;
|
||||||
|
|
||||||
|
async function getSpotlight(id) {
|
||||||
|
const html = await fetch(`https://www.snapchat.com/spotlight/${id}`, {
|
||||||
|
headers: { 'User-Agent': genericUserAgent }
|
||||||
|
}).then((r) => r.text()).catch(() => null);
|
||||||
|
if (!html) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
|
||||||
|
if (videoURL) {
|
||||||
|
return {
|
||||||
|
urls: videoURL,
|
||||||
|
filename: `snapchat_${id}.mp4`,
|
||||||
|
audioFilename: `snapchat_${id}_audio`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStory(username, storyId) {
|
||||||
|
const html = await fetch(`https://www.snapchat.com/add/${username}${storyId ? `/${storyId}` : ''}`, {
|
||||||
|
headers: { 'User-Agent': genericUserAgent }
|
||||||
|
}).then((r) => r.text()).catch(() => null);
|
||||||
|
if (!html) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
|
||||||
|
if (nextDataString) {
|
||||||
|
const data = JSON.parse(nextDataString);
|
||||||
|
const storyIdParam = data.query.profileParams[1];
|
||||||
|
|
||||||
|
if (storyIdParam && data.props.pageProps.story) {
|
||||||
|
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyIdParam);
|
||||||
|
if (story) {
|
||||||
|
if (story.snapMediaType === 0) {
|
||||||
|
return {
|
||||||
|
urls: story.snapUrls.mediaUrl,
|
||||||
|
isPhoto: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
urls: story.snapUrls.mediaUrl,
|
||||||
|
filename: `snapchat_${storyId}.mp4`,
|
||||||
|
audioFilename: `snapchat_${storyId}_audio`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStory = data.props.pageProps.curatedHighlights[0];
|
||||||
|
if (defaultStory) {
|
||||||
|
return {
|
||||||
|
picker: defaultStory.snapList.map((snap) => ({
|
||||||
|
type: snap.snapMediaType === 0 ? 'photo' : 'video',
|
||||||
|
url: snap.snapUrls.mediaUrl,
|
||||||
|
thumb: snap.snapUrls.mediaPreviewUrl.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function(obj) {
|
||||||
|
let params = obj;
|
||||||
|
if (obj.url.hostname === 't.snapchat.com' && obj.shortLink) {
|
||||||
|
const link = await getRedirectingURL(`https://t.snapchat.com/${obj.shortLink}`);
|
||||||
|
|
||||||
|
if (!link?.startsWith('https://www.snapchat.com/')) {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractResult = extract(normalizeURL(link));
|
||||||
|
if (extractResult?.host !== 'snapchat') {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
||||||
|
|
||||||
|
params = extractResult.patternMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.spotlightId) {
|
||||||
|
const result = await getSpotlight(params.spotlightId);
|
||||||
|
if (result) return result;
|
||||||
|
} else if (params.username) {
|
||||||
|
const result = await getStory(params.username, params.storyId);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
}
|
@ -114,13 +114,32 @@
|
|||||||
"patterns": ["video/:id"],
|
"patterns": ["video/:id"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"snapchat": {
|
||||||
|
"alias": "snapchat stories & spotlights",
|
||||||
|
"subdomains": ["t", "story"],
|
||||||
|
"patterns": [":shortLink", "spotlight/:spotlightId", "add/:username/:storyId", "u/:username/:storyId", "add/:username", "u/:username"],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"loom": {
|
"loom": {
|
||||||
"alias": "loom videos",
|
"alias": "loom videos",
|
||||||
"patterns": ["share/:id"],
|
"patterns": ["share/:id"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"facebook": {
|
||||||
|
"alias": "facebook videos",
|
||||||
|
"altDomains": ["fb.watch"],
|
||||||
|
"subdomains": ["web"],
|
||||||
|
"patterns": [
|
||||||
|
"_shortLink/:shortLink",
|
||||||
|
":username/videos/:caption/:id",
|
||||||
|
":username/videos/:id",
|
||||||
|
"reel/:id",
|
||||||
|
"share/:shortLink/:id"
|
||||||
|
],
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"newgrounds": {
|
"newgrounds": {
|
||||||
"alias": "newgrounds.com",
|
"alias": "newgrounds videos & music",
|
||||||
"patterns": [":type/:method/:id"],
|
"patterns": [":type/:method/:id"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,11 @@ export const testers = {
|
|||||||
(patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255)
|
(patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255)
|
||||||
|| patternMatch.shortLink?.length <= 32,
|
|| patternMatch.shortLink?.length <= 32,
|
||||||
|
|
||||||
|
"snapchat": (patternMatch) =>
|
||||||
|
(patternMatch.username?.length <= 32 && (!patternMatch.storyId || patternMatch.storyId?.length <= 255))
|
||||||
|
|| patternMatch.spotlightId?.length <= 255
|
||||||
|
|| patternMatch.shortLink?.length <= 16,
|
||||||
|
|
||||||
"streamable": (patternMatch) =>
|
"streamable": (patternMatch) =>
|
||||||
patternMatch.id?.length === 6,
|
patternMatch.id?.length === 6,
|
||||||
|
|
||||||
@ -59,6 +64,12 @@ export const testers = {
|
|||||||
"youtube": (patternMatch) =>
|
"youtube": (patternMatch) =>
|
||||||
patternMatch.id?.length <= 11,
|
patternMatch.id?.length <= 11,
|
||||||
|
|
||||||
|
"facebook": (patternMatch) =>
|
||||||
|
patternMatch.shortLink?.length <= 11
|
||||||
|
|| patternMatch.username?.length <= 30
|
||||||
|
|| patternMatch.caption?.length <= 255
|
||||||
|
|| patternMatch.id?.length <= 20,
|
||||||
|
|
||||||
"newgrounds": (patternMatch) =>
|
"newgrounds": (patternMatch) =>
|
||||||
patternMatch.id?.length <= 1,
|
patternMatch.id?.length <= 1,
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,17 @@ function aliasURL(url) {
|
|||||||
if (url.hostname === 'dai.ly' && parts.length === 2) {
|
if (url.hostname === 'dai.ly' && parts.length === 2) {
|
||||||
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
|
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":
|
case "ddinstagram":
|
||||||
if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {
|
if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) {
|
||||||
url.hostname = 'instagram.com';
|
url.hostname = 'instagram.com';
|
||||||
|
@ -45,6 +45,13 @@ export function cleanHTML(html) {
|
|||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRedirectingURL(url) {
|
||||||
|
return fetch(url, { redirect: 'manual' }).then((r) => {
|
||||||
|
if ([301, 302, 303].includes(r.status) && r.headers.has('location'))
|
||||||
|
return r.headers.get('location');
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
export function merge(a, b) {
|
export function merge(a, b) {
|
||||||
for (const k of Object.keys(b)) {
|
for (const k of Object.keys(b)) {
|
||||||
if (Array.isArray(b[k])) {
|
if (Array.isArray(b[k])) {
|
||||||
|
42
src/modules/test.js
Normal file
42
src/modules/test.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { normalizeRequest } from "../modules/processing/request.js";
|
||||||
|
import match from "./processing/match.js";
|
||||||
|
import { extract } from "./processing/url.js";
|
||||||
|
|
||||||
|
export async function runTest(url, params, expect) {
|
||||||
|
const normalized = normalizeRequest({ url, ...params });
|
||||||
|
if (!normalized) {
|
||||||
|
throw "invalid request";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = extract(normalized.url);
|
||||||
|
if (parsed === null) {
|
||||||
|
throw `invalid url: ${normalized.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await match(
|
||||||
|
parsed.host, parsed.patternMatch, "en", normalized
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = [];
|
||||||
|
if (expect.status !== result.body.status) {
|
||||||
|
const detail = `${expect.status} (expected) != ${result.body.status} (actual)`;
|
||||||
|
error.push(`status mismatch: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expect.code !== result.status) {
|
||||||
|
const detail = `${expect.code} (expected) != ${result.status} (actual)`;
|
||||||
|
error.push(`status code mismatch: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.length) {
|
||||||
|
if (result.body.text) {
|
||||||
|
error.push(`error message: ${result.body.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.body.status === 'stream') {
|
||||||
|
// TODO: stream testing
|
||||||
|
}
|
||||||
|
}
|
81
src/util/test-ci.js
Normal file
81
src/util/test-ci.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { env } from "../modules/config.js";
|
||||||
|
import { runTest } from "../modules/test.js";
|
||||||
|
import { loadLoc } from "../localization/manager.js";
|
||||||
|
import { loadJSON } from "../modules/sub/loadFromFs.js";
|
||||||
|
import { Red, Bright } from "../modules/sub/consoleText.js";
|
||||||
|
|
||||||
|
const tests = loadJSON('./src/util/tests.json');
|
||||||
|
const services = loadJSON('./src/modules/processing/servicesConfig.json');
|
||||||
|
|
||||||
|
// services that are known to frequently fail due to external
|
||||||
|
// factors (e.g. rate limiting)
|
||||||
|
const finnicky = new Set(['bilibili', 'instagram', 'youtube'])
|
||||||
|
|
||||||
|
const action = process.argv[2];
|
||||||
|
switch (action) {
|
||||||
|
case "get-services":
|
||||||
|
const fromConfig = Object.keys(services.config);
|
||||||
|
|
||||||
|
const missingTests = fromConfig.filter(
|
||||||
|
service => !tests[service] || tests[service].length === 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingTests.length) {
|
||||||
|
console.error('services have no tests:', missingTests);
|
||||||
|
console.log('[]');
|
||||||
|
process.exitCode = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(fromConfig));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "run-tests-for":
|
||||||
|
const service = process.argv[3];
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
if (!tests[service]) {
|
||||||
|
console.error('no such service:', service);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadLoc();
|
||||||
|
env.streamLifespan = 10000;
|
||||||
|
env.apiURL = 'http://x';
|
||||||
|
|
||||||
|
for (const test of tests[service]) {
|
||||||
|
const { name, url, params, expected } = test;
|
||||||
|
const canFail = test.canFail || finnicky.has(service);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runTest(url, params, expected);
|
||||||
|
console.log(`${service}/${name}: ok`);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
failed = !canFail;
|
||||||
|
|
||||||
|
let failText = canFail ? `${Red('FAIL')} (ignored)` : Bright(Red('FAIL'));
|
||||||
|
if (canFail && process.env.GITHUB_ACTION) {
|
||||||
|
console.log(`::warning title=${service}/${name.replace(/,/g, ';')}::failed and was ignored`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`${service}/${name}: ${failText}`);
|
||||||
|
const errorString = e.toString().split('\n');
|
||||||
|
let c = '┃';
|
||||||
|
errorString.forEach((line, index) => {
|
||||||
|
line = line.replace('!=', Red('!='));
|
||||||
|
|
||||||
|
if (index === errorString.length - 1) {
|
||||||
|
c = '┗';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(` ${c}`, line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exitCode = Number(failed);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error('invalid action:', action);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
@ -42,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "picker"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "picker: mixed media (3 videos)",
|
"name": "picker: mixed media (3 videos)",
|
||||||
@ -113,9 +113,10 @@
|
|||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
"name": "age-restricted video",
|
"name": "age restricted video",
|
||||||
"url": "https://twitter.com/FckyeahCharli/status/1650987582749065220",
|
"url": "https://twitter.com/FckyeahCharli/status/1650987582749065220",
|
||||||
"params": {},
|
"params": {},
|
||||||
|
"canFail": true,
|
||||||
"expected": {
|
"expected": {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
@ -676,6 +677,14 @@
|
|||||||
"code": 200,
|
"code": 200,
|
||||||
"status": "redirect"
|
"status": "redirect"
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
"name": "mature video",
|
||||||
|
"url": "https://vimeo.com/973212054",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
}],
|
}],
|
||||||
"reddit": [{
|
"reddit": [{
|
||||||
"name": "video with audio",
|
"name": "video with audio",
|
||||||
@ -1132,6 +1141,31 @@
|
|||||||
"status": "stream"
|
"status": "stream"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
"snapchat": [{
|
||||||
|
"name": "spotlight",
|
||||||
|
"url": "https://www.snapchat.com/spotlight/W7_EDlXWTBiXAEEniNoMPwAAYdWxucG9pZmNqAY46j0a5AY46j0YbAAAAAQ",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shortlinked spotlight",
|
||||||
|
"url": "https://t.snapchat.com/4ZsiBLDi",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "story",
|
||||||
|
"url": "https://www.snapchat.com/add/bazerkmakane",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "picker"
|
||||||
|
}
|
||||||
|
}],
|
||||||
"loom": [{
|
"loom": [{
|
||||||
"name": "1080p video",
|
"name": "1080p video",
|
||||||
"url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761",
|
"url": "https://www.loom.com/share/313bf71d20ca47b2a35b6634cefdb761",
|
||||||
@ -1161,6 +1195,64 @@
|
|||||||
"status": "error"
|
"status": "error"
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
"facebook": [{
|
||||||
|
"name": "direct video with username and id",
|
||||||
|
"url": "https://web.facebook.com/100048111287134/videos/1157798148685638/",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "direct video with id as query param",
|
||||||
|
"url": "https://web.facebook.com/watch/?v=883839773514682&ref=sharing",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "direct video with caption",
|
||||||
|
"url": "https://web.facebook.com/wood57/videos/𝐒𝐞𝐛𝐚𝐬𝐤𝐨𝐦-𝐟𝐮𝐥𝐥/883839773514682",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shortlink video",
|
||||||
|
"url": "https://fb.watch/r1K6XHMfGT/",
|
||||||
|
"canFail": true,
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "reel video",
|
||||||
|
"url": "https://web.facebook.com/reel/730293269054758",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shared video link",
|
||||||
|
"url": "https://www.facebook.com/share/v/NEf87jbPTvFE8LsL/",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": "shared video link v2",
|
||||||
|
"url": "https://web.facebook.com/share/r/JFZfPVgLkiJQmWrr/",
|
||||||
|
"params": {},
|
||||||
|
"expected": {
|
||||||
|
"code": 200,
|
||||||
|
"status": "redirect"
|
||||||
|
}
|
||||||
|
}],
|
||||||
"newgrounds": [{
|
"newgrounds": [{
|
||||||
"name": "regular video",
|
"name": "regular video",
|
||||||
"url": "https://www.newgrounds.com/portal/view/938050",
|
"url": "https://www.newgrounds.com/portal/view/938050",
|
||||||
|
Loading…
Reference in New Issue
Block a user