Skip to content

Commit 815db82

Browse files
committed
feat: add support for Sora service
1 parent 47d8ccb commit 815db82

File tree

6 files changed

+196
-0
lines changed

6 files changed

+196
-0
lines changed

api/src/processing/match-action.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export default function({
181181
case "ok":
182182
case "xiaohongshu":
183183
case "newgrounds":
184+
case "sora":
184185
params = { type: "proxy" };
185186
break;
186187

api/src/processing/match.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import facebook from "./services/facebook.js";
3030
import bluesky from "./services/bluesky.js";
3131
import xiaohongshu from "./services/xiaohongshu.js";
3232
import newgrounds from "./services/newgrounds.js";
33+
import sora from "./services/sora.js";
3334

3435
let freebind;
3536

@@ -276,6 +277,14 @@ export default async function({ host, patternMatch, params, authType }) {
276277
});
277278
break;
278279

280+
case "sora":
281+
r = await sora({
282+
postId: patternMatch.postId,
283+
quality: params.videoQuality,
284+
isAudioOnly,
285+
});
286+
break;
287+
279288
default:
280289
return createResponse("error", {
281290
code: "error.api.service.unsupported"

api/src/processing/service-config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ export const services = {
221221
"v/:id"
222222
],
223223
subdomains: ["music", "m"],
224+
},
225+
sora: {
226+
patterns: ["p/:postId"],
227+
altDomains: ["sora.chatgpt.com"]
224228
}
225229
}
226230

api/src/processing/service-patterns.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,7 @@ export const testers = {
8787

8888
"youtube": pattern =>
8989
pattern.id?.length <= 11,
90+
91+
"sora": pattern =>
92+
pattern.postId?.length <= 64,
9093
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { genericUserAgent } from "../../config.js";
2+
3+
// Helper function to add delay between requests
4+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5+
6+
// Helper function to check if response is a Cloudflare challenge
7+
const isCloudflareChallenge = (response) => {
8+
return (
9+
response.status === 403 ||
10+
response.status === 503 ||
11+
(response.status === 200 &&
12+
response.headers.get("server")?.includes("cloudflare"))
13+
);
14+
};
15+
16+
// Enhanced fetch with retry logic for Cloudflare challenges
17+
const fetchWithRetry = async (url, options, maxRetries = 3) => {
18+
let lastError;
19+
20+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
21+
try {
22+
const response = await fetch(url, options);
23+
24+
// If it's a Cloudflare challenge and not the last attempt, wait and retry
25+
if (isCloudflareChallenge(response) && attempt < maxRetries) {
26+
await delay(1000 * attempt); // Exponential backoff
27+
continue;
28+
}
29+
30+
return response;
31+
} catch (error) {
32+
lastError = error;
33+
if (attempt < maxRetries) {
34+
await delay(1000 * attempt);
35+
continue;
36+
}
37+
throw error;
38+
}
39+
}
40+
41+
throw lastError;
42+
};
43+
44+
export default async function (obj) {
45+
let videoId = obj.postId;
46+
if (!videoId) {
47+
return { error: "fetch.empty" };
48+
}
49+
50+
try {
51+
// For /p/ (post) URLs, use HTML parsing
52+
if (obj.postId) {
53+
return await handlePostUrl(obj.postId, obj);
54+
}
55+
56+
return { error: "fetch.empty" };
57+
} catch (error) {
58+
console.error("Sora service error:", error);
59+
return { error: "fetch.fail" };
60+
}
61+
}
62+
63+
async function handlePostUrl(postId, obj) {
64+
const targetUrl = `https://sora.com/p/${postId}`;
65+
66+
const res = await fetchWithRetry(targetUrl, {
67+
headers: {
68+
"user-agent": genericUserAgent,
69+
accept:
70+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
71+
"accept-language": "en-US,en;q=0.9",
72+
"accept-encoding": "gzip, deflate, br",
73+
"sec-ch-ua":
74+
'"Google Chrome";v="138", "Chromium";v="138", "Not=A?Brand";v="99"',
75+
"sec-ch-ua-mobile": "?0",
76+
"sec-ch-ua-platform": '"Windows"',
77+
"sec-fetch-dest": "document",
78+
"sec-fetch-mode": "navigate",
79+
"sec-fetch-site": "none",
80+
"sec-fetch-user": "?1",
81+
"upgrade-insecure-requests": "1",
82+
"cache-control": "max-age=0",
83+
dnt: "1",
84+
},
85+
});
86+
87+
if (!res.ok) {
88+
return { error: "fetch.fail" };
89+
}
90+
91+
const html = await res.text();
92+
93+
// Extract video URL from HTML and script tags
94+
let videoUrl;
95+
let title;
96+
97+
// Look for video URLs in various patterns within the HTML and script content
98+
const videoPatterns = [
99+
/https:\/\/videos\.openai\.com\/vg-assets\/[^"'>\s]+\.mp4[^"'>\s]*/g,
100+
/"(https:\/\/videos\.openai\.com\/vg-assets\/[^"]+\.mp4[^"]*)"/g,
101+
/'(https:\/\/videos\.openai\.com\/vg-assets\/[^']+\.mp4[^']*)'/g,
102+
/\\u[\da-f]{4}(https:\/\/videos\.openai\.com\/vg-assets\/[^\\]+\.mp4)/gi,
103+
/(https:\/\/videos\.openai\.com\/[^"'>\s\\]+\.mp4)/gi,
104+
];
105+
106+
// First try to find video URL in the main HTML
107+
for (const pattern of videoPatterns) {
108+
const match = html.match(pattern);
109+
if (match) {
110+
videoUrl = match[0].replace(/^["']|["']$/g, ""); // Remove quotes
111+
break;
112+
}
113+
}
114+
115+
// If not found, search through script tags more thoroughly
116+
if (!videoUrl) {
117+
const scriptMatches = html.match(/<script[^>]*>(.*?)<\/script>/gs);
118+
if (scriptMatches) {
119+
for (const script of scriptMatches) {
120+
// Try each pattern on script content
121+
for (const pattern of videoPatterns) {
122+
const matches = script.match(pattern);
123+
if (matches) {
124+
for (const match of matches) {
125+
let candidate = match.replace(/^["']|["']$/g, "");
126+
// Handle escaped characters
127+
candidate = candidate.replace(/\\u[\da-f]{4}/gi, "");
128+
candidate = candidate.replace(/\\\//g, "/");
129+
130+
if (
131+
candidate.includes("videos.openai.com") &&
132+
candidate.includes(".mp4")
133+
) {
134+
videoUrl = candidate;
135+
break;
136+
}
137+
}
138+
if (videoUrl) break;
139+
}
140+
}
141+
if (videoUrl) break;
142+
}
143+
}
144+
}
145+
146+
// Extract title from HTML title tag
147+
const titleMatch = html.match(/<title>([^<]+)<\/title>/);
148+
if (titleMatch) {
149+
title = titleMatch[1].replace(" - Sora", "").replace(" | Sora", "").trim();
150+
}
151+
152+
// Decode HTML entities if present
153+
if (videoUrl) {
154+
videoUrl = videoUrl.replace(/&amp;/g, "&");
155+
}
156+
157+
if (!videoUrl) {
158+
return { error: "fetch.empty" };
159+
}
160+
161+
// Generate filename
162+
const cleanId = postId.replace(/[^a-zA-Z0-9_-]/g, "");
163+
const videoFilename = `sora_${cleanId}.mp4`;
164+
165+
return {
166+
type: "proxy",
167+
urls: videoUrl,
168+
filename: videoFilename,
169+
fileMetadata: {
170+
title: title || `Sora Video ${cleanId}`,
171+
},
172+
};
173+
}

api/src/processing/url.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ function aliasURL(url) {
110110
}
111111
break;
112112

113+
case "chatgpt":
114+
if (url.hostname === 'sora.chatgpt.com') {
115+
url.hostname = 'sora.com';
116+
}
117+
break;
118+
113119
case "redd":
114120
/* reddit short video links can be treated by changing https://v.redd.it/<id>
115121
to https://reddit.com/video/<id>.*/

0 commit comments

Comments
 (0)