Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: snapchat support #429

Merged
merged 19 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ this list is not final and keeps expanding over time. if support for a service y
| pinterest | ✅ | ✅ | ✅ | ➖ | ➖ |
| reddit | ✅ | ✅ | ✅ | ❌ | ❌ |
| rutube | ✅ | ✅ | ✅ | ✅ | ✅ |
| snapchat stories & spotlights | ✅ | ✅ | ✅ | ➖ | ➖ |
| soundcloud | ➖ | ✅ | ➖ | ✅ | ✅ |
| streamable | ✅ | ✅ | ✅ | ➖ | ➖ |
| tiktok | ✅ | ✅ | ✅ | ❌ | ❌ |
Expand All @@ -44,6 +45,7 @@ this list is not final and keeps expanding over time. if support for a service y
| instagram | supports photos, videos, and stories. lets you pick what to save from multi-media posts. |
| pinterest | supports videos and stories. |
| reddit | supports gifs and videos. |
| snapchat | supports spotlights and stories. lets you pick what to save from stories. |
| soundcloud | supports private links. |
| tiktok | supports videos with or without watermark, images from slideshow without watermark, and full (original) audios. |
| twitter/x | lets you pick what to save from multi-media posts. may not be 100% reliable due to current management. |
Expand Down
10 changes: 10 additions & 0 deletions src/modules/processing/match.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
import rutube from "./services/rutube.js";
import dailymotion from "./services/dailymotion.js";
import snapchat from "./services/snapchat.js";

export default async function(host, patternMatch, url, lang, obj) {
assert(url instanceof URL);
Expand Down Expand Up @@ -158,6 +159,15 @@ export default async function(host, patternMatch, url, lang, obj) {
case "dailymotion":
r = await dailymotion(patternMatch);
break;
case "snapchat":
r = await snapchat({
url,
username: patternMatch.username,
storyId: patternMatch.storyId,
spotlightId: patternMatch.spotlightId,
shortLink: patternMatch.shortLink || false
});
break;
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/processing/matchActionDecider.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
switch (host) {
case "instagram":
case "twitter":
case "snapchat":
params = { picker: r.picker };
break;
case "douyin":
Expand Down Expand Up @@ -120,6 +121,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
case "tumblr":
case "pinterest":
case "streamable":
case "snapchat":
responseType = 1;
break;
}
Expand Down
82 changes: 82 additions & 0 deletions src/modules/processing/services/snapchat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { genericUserAgent } from "../../config.js";

const SPOTLIGHT_VIDEO_REGEX = /<link data-react-helmet="true" rel="preload" href="(https:\/\/cf-st\.sc-cdn\.net\/d\/[\w.?=]+&amp;uc=\d+)" as="video"\/>/;
const NEXT_DATA_REGEX = /<script id="__NEXT_DATA__" type="application\/json">({.+})<\/script><\/body><\/html>$/;

export default async function(obj) {
let link;
if (obj.url.hostname === 't.snapchat.com' && obj.shortLink) {
link = await fetch(`https://t.snapchat.com/${obj.shortLink}`, { redirect: "manual" }).then((r) => {
if (r.status === 303 && r.headers.get("location").startsWith("https://www.snapchat.com/")) {
return r.headers.get("location").split('?', 1)[0]
Snazzah marked this conversation as resolved.
Show resolved Hide resolved
}
}).catch(() => {});
}
Snazzah marked this conversation as resolved.
Show resolved Hide resolved

if (!link && obj.username && obj.storyId) {
link = `https://www.snapchat.com/add/${obj.username}/${obj.storyId}`
} if (!link && obj.username) {
link = `https://www.snapchat.com/add/${obj.username}`
} else if (!link && obj.spotlightId) {
link = `https://www.snapchat.com/spotlight/${obj.spotlightId}`
}
Snazzah marked this conversation as resolved.
Show resolved Hide resolved

const path = new URL(link).pathname;
Snazzah marked this conversation as resolved.
Show resolved Hide resolved

if (path.startsWith('/spotlight/')) {
Snazzah marked this conversation as resolved.
Show resolved Hide resolved
const html = await fetch(link, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };

const id = path.split('/')[2];
const videoURL = html.match(SPOTLIGHT_VIDEO_REGEX)?.[1];
if (videoURL) return {
urls: videoURL,
filename: `snapchat_${id}.mp4`,
audioFilename: `snapchat_${id}_audio`
}
} else if (path.startsWith('/add/')) {
const html = await fetch(link, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };


const nextDataString = html.match(NEXT_DATA_REGEX)?.[1];
if (nextDataString) {
const data = JSON.parse(nextDataString);
const storyId = data.query.profileParams[1];

if (storyId && data.props.pageProps.story) {
const story = data.props.pageProps.story.snapList.find((snap) => snap.snapId.value === storyId);
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
}))
}
}
}


return { error: 'ErrorCouldntFetch' };
}
6 changes: 6 additions & 0 deletions src/modules/processing/servicesConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@
"alias": "dailymotion videos",
"patterns": ["video/:id"],
"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
}
}
}
5 changes: 5 additions & 0 deletions src/modules/processing/servicesPatternTesters.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const testers = {
(patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255)
|| 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) =>
patternMatch.id?.length === 6,

Expand Down
19 changes: 18 additions & 1 deletion src/test/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -1115,5 +1115,22 @@
"code": 200,
"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"
}
}]
}
}