diff --git a/api/src/misc/utils.js b/api/src/misc/utils.js index fd497d188..9978ceed2 100644 --- a/api/src/misc/utils.js +++ b/api/src/misc/utils.js @@ -1,8 +1,16 @@ -export function getRedirectingURL(url) { - return fetch(url, { redirect: 'manual' }).then((r) => { - if ([301, 302, 303].includes(r.status) && r.headers.has('location')) +const redirectStatuses = new Set([301, 302, 303, 307, 308]); + +export async function getRedirectingURL(url, dispatcher) { + const location = await fetch(url, { + redirect: 'manual', + dispatcher, + }).then((r) => { + if (redirectStatuses.has(r.status) && r.headers.has('location')) { return r.headers.get('location'); + } }).catch(() => null); + + return location; } export function merge(a, b) { diff --git a/api/src/processing/match-action.js b/api/src/processing/match-action.js index 64f86836b..0d87b5a8c 100644 --- a/api/src/processing/match-action.js +++ b/api/src/processing/match-action.js @@ -47,7 +47,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab }); case "photo": - responseType = "redirect"; + params = { type: "proxy" }; break; case "gif": @@ -83,6 +83,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "twitter": case "snapchat": case "bsky": + case "xiaohongshu": params = { picker: r.picker }; break; @@ -143,6 +144,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab case "ok": case "vk": case "tiktok": + case "xiaohongshu": params = { type: "proxy" }; break; diff --git a/api/src/processing/match.js b/api/src/processing/match.js index 57f04b362..9fabf3793 100644 --- a/api/src/processing/match.js +++ b/api/src/processing/match.js @@ -28,6 +28,7 @@ import snapchat from "./services/snapchat.js"; import loom from "./services/loom.js"; import facebook from "./services/facebook.js"; import bluesky from "./services/bluesky.js"; +import xiaohongshu from "./services/xiaohongshu.js"; let freebind; @@ -239,6 +240,15 @@ export default async function({ host, patternMatch, params }) { }); break; + case "xiaohongshu": + r = await xiaohongshu({ + ...patternMatch, + h265: params.tiktokH265, + isAudioOnly, + dispatcher, + }); + break; + default: return createResponse("error", { code: "error.api.service.unsupported" diff --git a/api/src/processing/service-config.js b/api/src/processing/service-config.js index 81afaf39f..86352f9ae 100644 --- a/api/src/processing/service-config.js +++ b/api/src/processing/service-config.js @@ -166,6 +166,14 @@ export const services = { subdomains: ["m"], altDomains: ["vkvideo.ru", "vk.ru"], }, + xiaohongshu: { + patterns: [ + "explore/:id?xsec_token=:token", + "discovery/item/:id?xsec_token=:token", + "a/:shareId" + ], + altDomains: ["xhslink.com"], + }, youtube: { patterns: [ "watch?v=:id", diff --git a/api/src/processing/service-patterns.js b/api/src/processing/service-patterns.js index e8c466399..42f64d264 100644 --- a/api/src/processing/service-patterns.js +++ b/api/src/processing/service-patterns.js @@ -71,4 +71,8 @@ export const testers = { "bsky": pattern => pattern.user?.length <= 128 && pattern.post?.length <= 128, + + "xiaohongshu": pattern => + pattern.id?.length <= 24 && pattern.token?.length <= 64 + || pattern.shareId?.length <= 12, } diff --git a/api/src/processing/services/xiaohongshu.js b/api/src/processing/services/xiaohongshu.js new file mode 100644 index 000000000..bbb53ab19 --- /dev/null +++ b/api/src/processing/services/xiaohongshu.js @@ -0,0 +1,116 @@ +import { extract, normalizeURL } from "../url.js"; +import { genericUserAgent } from "../../config.js"; +import { createStream } from "../../stream/manage.js"; +import { getRedirectingURL } from "../../misc/utils.js"; + +const https = (url) => { + return url.replace(/^http:/i, 'https:'); +} + +export default async function ({ id, token, shareId, h265, isAudioOnly, dispatcher }) { + let noteId = id; + let xsecToken = token; + + if (!noteId) { + const extractedURL = await getRedirectingURL( + `https://xhslink.com/a/${shareId}`, + dispatcher + ); + + if (extractedURL) { + const { patternMatch } = extract(normalizeURL(extractedURL)); + + if (patternMatch) { + noteId = patternMatch.id; + xsecToken = patternMatch.token; + } + } + } + + if (!noteId || !xsecToken) return { error: "fetch.short_link" }; + + const res = await fetch(`https://www.xiaohongshu.com/explore/${noteId}?xsec_token=${xsecToken}`, { + headers: { + "user-agent": genericUserAgent, + }, + dispatcher, + }); + + const html = await res.text(); + + let note; + try { + const initialState = html + .split('')[0] + .replace(/:\s*undefined/g, ":null"); + + const data = JSON.parse(initialState); + + const noteInfo = data?.note?.noteDetailMap; + if (!noteInfo) throw "no note detail map"; + + const currentNote = noteInfo[noteId]; + if (!currentNote) throw "no current note in detail map"; + + note = currentNote.note; + } catch {} + + if (!note) return { error: "fetch.empty" }; + + const video = note.video; + const images = note.imageList; + + const filenameBase = `xiaohongshu_${noteId}`; + + if (video) { + const videoFilename = `${filenameBase}.mp4`; + const audioFilename = `${filenameBase}_audio`; + + let videoURL; + + if (h265 && !isAudioOnly && video.consumer?.originVideoKey) { + videoURL = `https://sns-video-bd.xhscdn.com/${video.consumer.originVideoKey}`; + } else { + const h264Streams = video.media?.stream?.h264; + + if (h264Streams?.length) { + videoURL = h264Streams.reduce((a, b) => Number(a?.videoBitrate) > Number(b?.videoBitrate) ? a : b).masterUrl; + } + } + + if (!videoURL) return { error: "fetch.empty" }; + + return { + urls: https(videoURL), + filename: videoFilename, + audioFilename: audioFilename, + } + } + + if (!images || images.length === 0) { + return { error: "fetch.empty" }; + } + + if (images.length === 1) { + return { + isPhoto: true, + urls: https(images[0].urlDefault), + filename: `${filenameBase}.jpg`, + } + } + + const picker = images.map((image, i) => { + return { + type: "photo", + url: createStream({ + service: "xiaohongshu", + type: "proxy", + url: https(image.urlDefault), + filename: `${filenameBase}_${i + 1}.jpg`, + }) + } + }); + + return { picker }; +} diff --git a/api/src/processing/url.js b/api/src/processing/url.js index 8f0e7dc26..cfbbecc09 100644 --- a/api/src/processing/url.js +++ b/api/src/processing/url.js @@ -92,9 +92,14 @@ function aliasURL(url) { url.hostname = 'vk.com'; } break; + + case "xhslink": + if (url.hostname === 'xhslink.com' && parts.length === 3) { + url = new URL(`https://www.xiaohongshu.com/a/${parts[2]}`); + } } - return url + return url; } function cleanURL(url) { @@ -114,36 +119,41 @@ function cleanURL(url) { break; case "vk": if (url.pathname.includes('/clip') && url.searchParams.get('z')) { - limitQuery('z') + limitQuery('z'); } break; case "youtube": if (url.searchParams.get('v')) { - limitQuery('v') + limitQuery('v'); } break; case "rutube": if (url.searchParams.get('p')) { - limitQuery('p') + limitQuery('p'); } break; case "twitter": if (url.searchParams.get('post_id')) { - limitQuery('post_id') + limitQuery('post_id'); + } + break; + case "xiaohongshu": + if (url.searchParams.get('xsec_token')) { + limitQuery('xsec_token'); } break; } if (stripQuery) { - url.search = '' + url.search = ''; } - url.username = url.password = url.port = url.hash = '' + url.username = url.password = url.port = url.hash = ''; if (url.pathname.endsWith('/')) url.pathname = url.pathname.slice(0, -1); - return url + return url; } function getHostIfValid(url) { diff --git a/api/src/util/tests/bsky.json b/api/src/util/tests/bsky.json index 840f1169b..5a03538d7 100644 --- a/api/src/util/tests/bsky.json +++ b/api/src/util/tests/bsky.json @@ -54,7 +54,7 @@ "params": {}, "expected": { "code": 200, - "status": "redirect" + "status": "tunnel" } }, { diff --git a/api/src/util/tests/pinterest.json b/api/src/util/tests/pinterest.json index 2f15fb0b3..6308adb43 100644 --- a/api/src/util/tests/pinterest.json +++ b/api/src/util/tests/pinterest.json @@ -54,7 +54,7 @@ "params": {}, "expected": { "code": 200, - "status": "redirect" + "status": "tunnel" } }, { @@ -63,7 +63,7 @@ "params": {}, "expected": { "code": 200, - "status": "redirect" + "status": "tunnel" } }, { @@ -72,7 +72,7 @@ "params": {}, "expected": { "code": 200, - "status": "redirect" + "status": "tunnel" } }, { @@ -81,7 +81,7 @@ "params": {}, "expected": { "code": 200, - "status": "redirect" + "status": "tunnel" } } ] \ No newline at end of file diff --git a/api/src/util/tests/xiaohongshu.json b/api/src/util/tests/xiaohongshu.json new file mode 100644 index 000000000..425a6a2ec --- /dev/null +++ b/api/src/util/tests/xiaohongshu.json @@ -0,0 +1,58 @@ +[ + { + "name": "long link video", + "url": "https://www.xiaohongshu.com/discovery/item/6789065900000000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "picker with multiple live photos", + "url": "https://www.xiaohongshu.com/explore/67847fa1000000000203e6ed?xsec_token=CBzyP7Y44PPpsM20lgxqrIIJMHqOLemusDsRcmsX0cTpk", + "params": {}, + "expected": { + "code": 200, + "status": "picker" + } + }, + { + "name": "one photo", + "url": "https://www.xiaohongshu.com/explore/6788b56200000000210008c8?xsec_token=CBSDiWU4N-DgirHrOVbIWrlKfUNFHKwm-Wsjqz7dIMc_k", + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "short link, might expire eventually", + "url": "https://xhslink.com/a/czn4z6c1tic4", + "canFail": true, + "params": {}, + "expected": { + "code": 200, + "status": "tunnel" + } + }, + { + "name": "wrong note id", + "url": "https://www.xiaohongshu.com/discovery/item/6789065911100000210035fc?source=webshare&xhsshare=pc_web&xsec_token=CBustnz_Twf1BSybpe5-D-BzUb-Bx28DPLb418TN9S9Kk&xsec_source=pc_share", + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + }, + { + "name": "short link, wrong id", + "url": "https://xhslink.com/a/aaaaaa", + "canFail": true, + "params": {}, + "expected": { + "code": 400, + "status": "error" + } + } +] diff --git a/docs/api.md b/docs/api.md index 6da93a44e..fb1a14509 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,7 +68,7 @@ Content-Type: application/json | `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. | | `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. | | `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. | -| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. | +| `tiktokH265` | `boolean` | `true / false` | `false` | allows h265 videos when enabled. applies to tiktok & xiaohongshu. | | `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif | | `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. | diff --git a/web/i18n/en/settings.json b/web/i18n/en/settings.json index c450b4b9e..418410bf1 100644 --- a/web/i18n/en/settings.json +++ b/web/i18n/en/settings.json @@ -40,9 +40,9 @@ "video.twitter.gif.title": "convert looping videos to GIF", "video.twitter.gif.description": "GIF conversion is inefficient, converted file may be obnoxiously big and low quality.", - "video.tiktok.h265": "tiktok", - "video.tiktok.h265.title": "prefer HEVC/H265 format", - "video.tiktok.h265.description": "allows downloading videos in 1080p at cost of compatibility.", + "video.h265": "high efficiency video codec", + "video.h265.title": "allow h265 for videos", + "video.h265.description": "allows downloading videos from platforms like tiktok and xiaohongshu in higher quality at cost of compatibility.", "audio.format": "audio format", "audio.format.best": "best", diff --git a/web/src/routes/settings/video/+page.svelte b/web/src/routes/settings/video/+page.svelte index 9897d7eb1..88f9a5cad 100644 --- a/web/src/routes/settings/video/+page.svelte +++ b/web/src/routes/settings/video/+page.svelte @@ -69,20 +69,21 @@ /> - + - + +