From 359edaad1f36b3c2bb413698de9539db28e9a88f Mon Sep 17 00:00:00 2001 From: Stefan Cruz <17972991+stefancruz@users.noreply.github.com> Date: Sun, 14 Jul 2024 17:15:44 +0100 Subject: [PATCH] fix: improve extracting the api credentias from the page from the page --- DailymotionConfig.json | 3 +- build/DailymotionConfig.json | 3 +- build/DailymotionScript.js | 200 ++++++++++++++++++++++++----------- src/DailymotionScript.ts | 125 ++++++---------------- src/Mappers.ts | 37 ++++--- src/Pagers.ts | 5 +- src/constants.ts | 24 +++-- src/extraction.ts | 182 +++++++++++++++++++++++++++++++ types/plugin.d.ts | 8 +- types/types.d.ts | 6 ++ 10 files changed, 410 insertions(+), 183 deletions(-) create mode 100644 src/extraction.ts diff --git a/DailymotionConfig.json b/DailymotionConfig.json index 9a06c09..cc34c3c 100644 --- a/DailymotionConfig.json +++ b/DailymotionConfig.json @@ -13,7 +13,8 @@ "scriptSignature": "", "scriptPublicKey": "", "packages": [ - "Http" + "Http", + "DOMParser" ], "allowEval": false, "allowAllHttpHeaderAccess": false, diff --git a/build/DailymotionConfig.json b/build/DailymotionConfig.json index 9a06c09..cc34c3c 100644 --- a/build/DailymotionConfig.json +++ b/build/DailymotionConfig.json @@ -13,7 +13,8 @@ "scriptSignature": "", "scriptPublicKey": "", "packages": [ - "Http" + "Http", + "DOMParser" ], "allowEval": false, "allowAllHttpHeaderAccess": false, diff --git a/build/DailymotionScript.js b/build/DailymotionScript.js index ba84748..8a7a571 100644 --- a/build/DailymotionScript.js +++ b/build/DailymotionScript.js @@ -14,7 +14,8 @@ const REGEX_VIDEO_URL_1 = /^https:\/\/dai\.ly\/[a-zA-Z0-9]+$/i; const REGEX_VIDEO_URL_EMBED = /^https:\/\/(?:www\.)?dailymotion\.com\/embed\/video\/[a-zA-Z0-9]+(\?.*)?$/i; const REGEX_VIDEO_CHANNEL_URL = /^https:\/\/(?:www\.)?dailymotion\.com\/[a-zA-Z0-9-]+$/i; const REGEX_VIDEO_PLAYLIST_URL = /^https:\/\/(?:www\.)?dailymotion\.com\/playlist\/[a-zA-Z0-9]+$/i; -const REGEX_INITIAL_DATA_API_AUTH = /(?<=window\.__LOADABLE_LOADED_CHUNKS__=.*)\b[a-f0-9]{20}\b|\b[a-f0-9]{40}\b/g; +const REGEX_INITIAL_DATA_API_AUTH_1 = /(?<=window\.__LOADABLE_LOADED_CHUNKS__=.*)\b[a-f0-9]{20}\b|\b[a-f0-9]{40}\b/g; +const createAuthRegexByTextLength = (length) => new RegExp(`\\b\\w+\\s*=\\s*"([a-zA-Z0-9]{${length}})"`); const USER_AGENT = 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36'; // Those are used even for not logged users to make requests on the graphql api. const FALLBACK_CLIENT_ID = 'f1a362d288c1b98099c7'; @@ -1094,10 +1095,10 @@ class SearchChannelPager extends ChannelPager { this.cb = cb; } nextPage() { - const page = this.context.page += 1; + const page = (this.context.page += 1); const opts = { q: this.context.params.query, - page + page, }; return this.cb(opts); } @@ -1151,7 +1152,8 @@ const SourceChannelToGrayjayChannel = (pluginId, sourceChannel) => { return acc; }, {}); let description = ''; - if (sourceChannel?.tagline && sourceChannel?.tagline != sourceChannel?.description) { + if (sourceChannel?.tagline && + sourceChannel?.tagline != sourceChannel?.description) { description = `${sourceChannel?.tagline}\n\n`; } description += `${sourceChannel?.description ?? ''}`; @@ -1160,7 +1162,8 @@ const SourceChannelToGrayjayChannel = (pluginId, sourceChannel) => { name: sourceChannel?.displayName ?? '', thumbnail: sourceChannel?.avatar?.url ?? '', banner: sourceChannel.banner?.url ?? '', - subscribers: sourceChannel?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? 0, + subscribers: sourceChannel?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? + 0, description, url: `${BASE_URL}/${sourceChannel.name}`, links, @@ -1257,7 +1260,7 @@ const SourceVideoToPlatformVideoDetailsDef = (pluginId, sourceVideo, player_meta } const isLive = getIsLive(sourceVideo); const viewCount = getViewCount(sourceVideo); - const duration = isLive ? 0 : sourceVideo?.duration ?? 0; + const duration = isLive ? 0 : (sourceVideo?.duration ?? 0); const source = new HLSSource({ name: 'HLS', duration, @@ -1348,6 +1351,120 @@ const convertSRTtoVTT = (srt) => { return vtt.join(''); }; +function oauthClientCredentialsRequest(httpClient, url, clientId, secret, throwOnInvalid = false) { + if (!httpClient || !url || !clientId || !secret) { + throw new ScriptException('Invalid parameters provided to oauthClientCredentialsRequest'); + } + const body = objectToUrlEncodedString({ + client_id: clientId, + client_secret: secret, + grant_type: 'client_credentials', + }); + try { + return httpClient.POST(url, body, { + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: BASE_URL, + DNT: '1', + 'Sec-GPC': '1', + Connection: 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + Priority: 'u=4', + Pragma: 'no-cache', + 'Cache-Control': 'no-cache', + }, false); + } + catch (error) { + console.error('Error making OAuth client credentials request:', error); + if (throwOnInvalid) { + throw new ScriptException('Failed to obtain OAuth client credentials'); + } + return null; + } +} +function extractClientCredentials(httpClient) { + const detailsRequestHtml = httpClient.GET(BASE_URL, {}, false); + if (!detailsRequestHtml.isOk) { + throw new ScriptException('Failed to fetch page to extract auth details'); + } + const result = [ + { + clientId: FALLBACK_CLIENT_ID, + secret: FALLBACK_CLIENT_SECRET, + }, + ]; + const match = detailsRequestHtml.body.match(REGEX_INITIAL_DATA_API_AUTH_1); + if (match?.length === 2 && match[0] && match[1]) { + result.unshift({ + clientId: match[0], + secret: match[1], + }); + console.log('Successfully extracted API credentials from page:', match[1]); + } + else { + console.log('Failed to extract API credentials from page using regex. Using DOM parsing.'); + const htmlElement = domParser.parseFromString(detailsRequestHtml.body, 'text/html'); + const extractedId = getScriptVariableByTextLength(htmlElement, 20); + const extractedSecret = getScriptVariableByTextLength(htmlElement, 40); + if (extractedId && extractedSecret) { + result.unshift({ + clientId: extractedId, + secret: extractedSecret, + }); + console.log('Successfully extracted API credentials from page using DOM parsing:', extractedId); + } + else { + console.log('Failed to extract API credentials using DOM parsing with exact text length.'); + } + } + return result; +} +function getScriptVariableByTextLength(htmlElement, length) { + const scriptTags = htmlElement.querySelectorAll('script[type="text/javascript"]'); + if (!scriptTags.length) { + console.error('No script tags found.'); + return null; // or throw an error, depending on your use case + } + let pageContent = ''; + scriptTags.forEach((tag) => { + pageContent += tag.outerHTML; + }); + let matches = createAuthRegexByTextLength(length).exec(pageContent); + if (matches?.length == 2) { + return matches[1]; + } +} +function getTokenFromClientCredentials(httpClient, credentials, throwOnInvalid = false) { + let result = { + isValid: false, + }; + for (const credential of credentials) { + const res = oauthClientCredentialsRequest(httpClient, BASE_URL_API_AUTH, credential.clientId, credential.secret); + if (res?.isOk) { + const anonymousTokenResponse = JSON.parse(res.body); + if (!anonymousTokenResponse.token_type || + !anonymousTokenResponse.access_token) { + console.error('Invalid token response', res); + if (throwOnInvalid) { + throw new ScriptException('', 'Invalid token response: ' + res.body); + } + } + result = { + anonymousUserAuthorizationToken: `${anonymousTokenResponse.token_type} ${anonymousTokenResponse.access_token}`, + anonymousUserAuthorizationTokenExpirationDate: Date.now() + anonymousTokenResponse.expires_in * 1000, + isValid: true, + }; + break; + } + else { + console.error('Failed to get token', res); + } + } + return result; +} + let config; let _settings; const state = { @@ -1425,43 +1542,19 @@ source.enable = function (conf, settings, saveStateStr) { } if (!didSaveState) { log('Getting a new tokens'); - const detailsRequestHtml = http.GET(BASE_URL, {}, false); - if (!detailsRequestHtml.isOk) { - log("Failed to get page to extract auth details"); + const clientCredentials = extractClientCredentials(http); + const { anonymousUserAuthorizationToken, anonymousUserAuthorizationTokenExpirationDate, isValid, } = getTokenFromClientCredentials(http, clientCredentials); + if (!isValid) { + console.error('Failed to get token'); + throw new ScriptException('Failed to get authentication token'); } - let clientId = FALLBACK_CLIENT_ID; - let secret = FALLBACK_CLIENT_SECRET; - const match = detailsRequestHtml.body.match(REGEX_INITIAL_DATA_API_AUTH); - if (match?.length === 2 && match[0] && match[1]) { - clientId = match[0]; - secret = match[1]; - log('Successfully extracted API credentials from page.'); - } - else { - log('Failed to extract api credentials from page.'); - } - const body = objectToUrlEncodedString({ - client_id: clientId, - client_secret: secret, - grant_type: 'client_credentials', - }); - let batchRequests = http.batch().POST(BASE_URL_API_AUTH, body, { - 'User-Agent': USER_AGENT, - 'Content-Type': 'application/x-www-form-urlencoded', - Origin: BASE_URL, - DNT: '1', - 'Sec-GPC': '1', - Connection: 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - Priority: 'u=4', - Pragma: 'no-cache', - 'Cache-Control': 'no-cache', - }, false); + state.anonymousUserAuthorizationToken = + anonymousUserAuthorizationToken ?? ''; + state.anonymousUserAuthorizationTokenExpirationDate = + anonymousUserAuthorizationTokenExpirationDate ?? 0; if (config.allowAllHttpHeaderAccess) { // get token for message service api-2-0.spot.im - batchRequests = batchRequests.POST(BASE_URL_COMMENTS_AUTH, '', { + const authenticateIm = http.POST(BASE_URL_COMMENTS_AUTH, '', { 'User-Agent': USER_AGENT, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.5', @@ -1477,23 +1570,6 @@ source.enable = function (conf, settings, saveStateStr) { Priority: 'u=6', 'Content-Length': '0', }, false); - } - const responses = batchRequests.execute(); - const res = responses[0]; - if (res.code !== 200) { - console.error('Failed to get token', res); - throw new ScriptException('', 'Failed to get token: ' + res.code + ' - ' + res.body); - } - const anonymousTokenResponse = JSON.parse(res.body); - if (!anonymousTokenResponse.token_type || !anonymousTokenResponse.access_token) { - console.error('Invalid token response', res); - throw new ScriptException('', 'Invalid token response: ' + res.body); - } - state.anonymousUserAuthorizationToken = `${anonymousTokenResponse.token_type} ${anonymousTokenResponse.access_token}`; - state.anonymousUserAuthorizationTokenExpirationDate = - Date.now() + anonymousTokenResponse.expires_in * 1000; - if (config.allowAllHttpHeaderAccess) { - const authenticateIm = responses[1]; if (!authenticateIm.isOk) { log('Failed to authenticate to comments service'); } @@ -1565,11 +1641,7 @@ source.getChannelCapabilities = () => { }; //Video source.isContentDetailsUrl = function (url) { - return [ - REGEX_VIDEO_URL, - REGEX_VIDEO_URL_1, - REGEX_VIDEO_URL_EMBED - ].some(r => r.test(url)); + return [REGEX_VIDEO_URL, REGEX_VIDEO_URL_1, REGEX_VIDEO_URL_EMBED].some((r) => r.test(url)); }; source.getContentDetails = function (url) { return getSavedVideo(url, false); @@ -1721,7 +1793,7 @@ source.getUserSubscriptions = () => { operationName: 'SUBSCRIPTIONS_QUERY', variables: { first: first, - page: page + page: page, }, headers, query: GET_USER_SUBSCRIPTIONS, @@ -2139,7 +2211,7 @@ function getChannelPlaylists(url, page = 1) { }); const channel = gqlResponse.data.channel; const content = (channel?.collections?.edges ?? []) - .filter(e => e?.node?.metrics?.engagement?.videos?.edges?.[0]?.node?.total) //exclude empty playlists. could be empty doe to geographic restrictions + .filter((e) => e?.node?.metrics?.engagement?.videos?.edges?.[0]?.node?.total) //exclude empty playlists. could be empty doe to geographic restrictions .map((edge) => { return SourceCollectionToGrayjayPlaylist(config.id, edge?.node); }); diff --git a/src/DailymotionScript.ts b/src/DailymotionScript.ts index 3419329..48a4e6f 100644 --- a/src/DailymotionScript.ts +++ b/src/DailymotionScript.ts @@ -21,9 +21,6 @@ import { BASE_URL_METADATA, ERROR_TYPES, LikedMediaSort, - FALLBACK_CLIENT_ID, - FALLBACK_CLIENT_SECRET, - BASE_URL_API_AUTH, PLATFORM, BASE_URL_COMMENTS, BASE_URL_COMMENTS_AUTH, @@ -36,7 +33,6 @@ import { REGEX_VIDEO_URL, REGEX_VIDEO_URL_1, REGEX_VIDEO_URL_EMBED, - REGEX_INITIAL_DATA_API_AUTH, } from './constants'; import { @@ -57,12 +53,7 @@ import { USER_WATCH_LATER_VIDEOS_QUERY, } from './gqlQueries'; -import { - getChannelNameFromUrl, - getQuery, - objectToUrlEncodedString, - generateUUIDv4, -} from './util'; +import { getChannelNameFromUrl, getQuery, generateUUIDv4 } from './util'; import { Channel, @@ -95,7 +86,15 @@ import { SourceVideoToPlatformVideoDetailsDef, } from './Mappers'; -import { IDailymotionPluginSettings, IDictionary, IPlatformSystemPlaylist } from '../types/types'; +import { + IDailymotionPluginSettings, + IDictionary, + IPlatformSystemPlaylist, +} from '../types/types'; +import { + extractClientCredentials, + getTokenFromClientCredentials, +} from './extraction'; // Will be used to store private playlists that require authentication const authenticatedPlaylistCollection: string[] = []; @@ -183,54 +182,27 @@ source.enable = function (conf, settings, saveStateStr) { if (!didSaveState) { log('Getting a new tokens'); - const detailsRequestHtml = http.GET(BASE_URL, {}, false); + const clientCredentials = extractClientCredentials(http); - if (!detailsRequestHtml.isOk) { - log("Failed to get page to extract auth details"); - } + const { + anonymousUserAuthorizationToken, + anonymousUserAuthorizationTokenExpirationDate, + isValid, + } = getTokenFromClientCredentials(http, clientCredentials); - let clientId = FALLBACK_CLIENT_ID; - let secret = FALLBACK_CLIENT_SECRET; - - const match = detailsRequestHtml.body.match(REGEX_INITIAL_DATA_API_AUTH); - - if(match?.length === 2 && match[0] && match[1]) { - clientId = match[0]; - secret = match[1]; - log('Successfully extracted API credentials from page.') - } else { - log('Failed to extract api credentials from page.') + if (!isValid) { + console.error('Failed to get token'); + throw new ScriptException('Failed to get authentication token'); } - const body = objectToUrlEncodedString({ - client_id: clientId, - client_secret: secret, - grant_type: 'client_credentials', - }); - - let batchRequests = http.batch().POST( - BASE_URL_API_AUTH, - body, - { - 'User-Agent': USER_AGENT, - 'Content-Type': 'application/x-www-form-urlencoded', - Origin: BASE_URL, - DNT: '1', - 'Sec-GPC': '1', - Connection: 'keep-alive', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-site', - Priority: 'u=4', - Pragma: 'no-cache', - 'Cache-Control': 'no-cache', - }, - false, - ); + state.anonymousUserAuthorizationToken = + anonymousUserAuthorizationToken ?? ''; + state.anonymousUserAuthorizationTokenExpirationDate = + anonymousUserAuthorizationTokenExpirationDate ?? 0; if (config.allowAllHttpHeaderAccess) { // get token for message service api-2-0.spot.im - batchRequests = batchRequests.POST( + const authenticateIm = http.POST( BASE_URL_COMMENTS_AUTH, '', { @@ -251,33 +223,6 @@ source.enable = function (conf, settings, saveStateStr) { }, false, ); - } - - const responses = batchRequests.execute(); - - const res = responses[0]; - - if (res.code !== 200) { - console.error('Failed to get token', res); - throw new ScriptException( - '', - 'Failed to get token: ' + res.code + ' - ' + res.body, - ); - } - - const anonymousTokenResponse = JSON.parse(res.body); - - if (!anonymousTokenResponse.token_type || !anonymousTokenResponse.access_token) { - console.error('Invalid token response', res); - throw new ScriptException('', 'Invalid token response: ' + res.body); - } - - state.anonymousUserAuthorizationToken = `${anonymousTokenResponse.token_type} ${anonymousTokenResponse.access_token}`; - state.anonymousUserAuthorizationTokenExpirationDate = - Date.now() + anonymousTokenResponse.expires_in * 1000; - - if (config.allowAllHttpHeaderAccess) { - const authenticateIm = responses[1]; if (!authenticateIm.isOk) { log('Failed to authenticate to comments service'); @@ -370,11 +315,9 @@ source.getChannelCapabilities = (): ResultCapabilities => { //Video source.isContentDetailsUrl = function (url) { - return [ - REGEX_VIDEO_URL, - REGEX_VIDEO_URL_1, - REGEX_VIDEO_URL_EMBED - ].some(r => r.test(url)) + return [REGEX_VIDEO_URL, REGEX_VIDEO_URL_1, REGEX_VIDEO_URL_EMBED].some((r) => + r.test(url), + ); }; source.getContentDetails = function (url) { @@ -603,7 +546,7 @@ source.getUserSubscriptions = (): string[] => { operationName: 'SUBSCRIPTIONS_QUERY', variables: { first: first, - page: page + page: page, }, headers, query: GET_USER_SUBSCRIPTIONS, @@ -847,7 +790,7 @@ function getHomePager(params, page) { const hasMore = obj?.data?.home?.neon?.sections?.edges?.[0]?.node?.components?.pageInfo ?.hasNextPage ?? false; - + return new SearchPagerAll(results, hasMore, params, page, getHomePager); } @@ -1172,12 +1115,12 @@ function getChannelPlaylists( const channel = gqlResponse.data.channel as Channel; const content: PlatformPlaylist[] = (channel?.collections?.edges ?? []) - .filter(e => e?.node?.metrics?.engagement?.videos?.edges?.[0]?.node?.total)//exclude empty playlists. could be empty doe to geographic restrictions - .map( - (edge) => { + .filter( + (e) => e?.node?.metrics?.engagement?.videos?.edges?.[0]?.node?.total, + ) //exclude empty playlists. could be empty doe to geographic restrictions + .map((edge) => { return SourceCollectionToGrayjayPlaylist(config.id, edge?.node); - }, - ); + }); if (content?.length === 0) { return new ChannelPlaylistPager([]); diff --git a/src/Mappers.ts b/src/Mappers.ts index ccc3fe9..7759fac 100644 --- a/src/Mappers.ts +++ b/src/Mappers.ts @@ -6,7 +6,10 @@ import { Video, } from '../types/CodeGenDailymotion'; -import { DailymotionStreamingContent, IDailymotionSubtitle } from '../types/types'; +import { + DailymotionStreamingContent, + IDailymotionSubtitle, +} from '../types/types'; import { BASE_URL, @@ -34,13 +37,16 @@ export const SourceChannelToGrayjayChannel = ( {} as Record<string, string>, ); - let description = '' + let description = ''; - if(sourceChannel?.tagline && sourceChannel?.tagline != sourceChannel?.description){ + if ( + sourceChannel?.tagline && + sourceChannel?.tagline != sourceChannel?.description + ) { description = `${sourceChannel?.tagline}\n\n`; } - description += `${sourceChannel?.description ?? ''}` + description += `${sourceChannel?.description ?? ''}`; return new PlatformChannel({ id: new PlatformID( @@ -53,7 +59,8 @@ export const SourceChannelToGrayjayChannel = ( thumbnail: sourceChannel?.avatar?.url ?? '', banner: sourceChannel.banner?.url ?? '', subscribers: - sourceChannel?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? 0, + sourceChannel?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? + 0, description, url: `${BASE_URL}/${sourceChannel.name}`, links, @@ -70,8 +77,8 @@ export const SourceAuthorToGrayjayPlatformAuthorLink = ( creator?.name ? `${BASE_URL}/${creator?.name}` : '', creator?.avatar?.url ?? '', creator?.followers?.totalCount ?? - creator?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? - 0, + creator?.metrics?.engagement?.followers?.edges?.[0]?.node?.total ?? + 0, ); }; @@ -171,20 +178,18 @@ const getViewCount = (sourceVideo?: DailymotionStreamingContent): number => { let viewCount = 0; if (getIsLive(sourceVideo)) { - const live = sourceVideo as Live; //TODO: live?.audienceCount and live.stats.views.total are deprecated //live?.metrics?.engagement?.audience?.edges?.[0]?.node?.total is still empty - viewCount = - live?.metrics?.engagement?.audience?.edges?.[0]?.node?.total ?? - live?.audienceCount ?? - live?.stats?.views?.total ?? - 0 + viewCount = + live?.metrics?.engagement?.audience?.edges?.[0]?.node?.total ?? + live?.audienceCount ?? + live?.stats?.views?.total ?? + 0; } else { - const video = sourceVideo as Video; - + // TODO: both fields are deprecated. // video?.stats?.views?.total replaced video?.viewCount // now video?.viewCount is deprecated too but there replacement is not accessible yet @@ -218,7 +223,7 @@ export const SourceVideoToPlatformVideoDetailsDef = ( const isLive = getIsLive(sourceVideo); const viewCount = getViewCount(sourceVideo); - const duration = isLive ? 0 : (sourceVideo as Video)?.duration ?? 0; + const duration = isLive ? 0 : ((sourceVideo as Video)?.duration ?? 0); const source = new HLSSource({ name: 'HLS', diff --git a/src/Pagers.ts b/src/Pagers.ts index ea0c0a5..037b4c1 100644 --- a/src/Pagers.ts +++ b/src/Pagers.ts @@ -34,12 +34,11 @@ export class SearchChannelPager extends ChannelPager { } nextPage() { - - const page = this.context.page += 1; + const page = (this.context.page += 1); const opts = { q: this.context.params.query, - page + page, }; return this.cb(opts); } diff --git a/src/constants.ts b/src/constants.ts index 5e48122..9506801 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,21 +19,33 @@ export const BASE_URL_PLAYLIST = `${BASE_URL}/playlist`; export const BASE_URL_METADATA = `${BASE_URL}/player/metadata/video`; -export const REGEX_VIDEO_URL = /^https:\/\/(?:www\.)?dailymotion\.com\/video\/[a-zA-Z0-9]+$/i; +export const REGEX_VIDEO_URL = + /^https:\/\/(?:www\.)?dailymotion\.com\/video\/[a-zA-Z0-9]+$/i; + export const REGEX_VIDEO_URL_1 = /^https:\/\/dai\.ly\/[a-zA-Z0-9]+$/i; -export const REGEX_VIDEO_URL_EMBED = /^https:\/\/(?:www\.)?dailymotion\.com\/embed\/video\/[a-zA-Z0-9]+(\?.*)?$/i; -export const REGEX_VIDEO_CHANNEL_URL = /^https:\/\/(?:www\.)?dailymotion\.com\/[a-zA-Z0-9-]+$/i; -export const REGEX_VIDEO_PLAYLIST_URL = /^https:\/\/(?:www\.)?dailymotion\.com\/playlist\/[a-zA-Z0-9]+$/i; +export const REGEX_VIDEO_URL_EMBED = + /^https:\/\/(?:www\.)?dailymotion\.com\/embed\/video\/[a-zA-Z0-9]+(\?.*)?$/i; + +export const REGEX_VIDEO_CHANNEL_URL = + /^https:\/\/(?:www\.)?dailymotion\.com\/[a-zA-Z0-9-]+$/i; + +export const REGEX_VIDEO_PLAYLIST_URL = + /^https:\/\/(?:www\.)?dailymotion\.com\/playlist\/[a-zA-Z0-9]+$/i; + +export const REGEX_INITIAL_DATA_API_AUTH_1 = + /(?<=window\.__LOADABLE_LOADED_CHUNKS__=.*)\b[a-f0-9]{20}\b|\b[a-f0-9]{40}\b/g; -export const REGEX_INITIAL_DATA_API_AUTH = /(?<=window\.__LOADABLE_LOADED_CHUNKS__=.*)\b[a-f0-9]{20}\b|\b[a-f0-9]{40}\b/g; +export const createAuthRegexByTextLength = (length: number) => + new RegExp(`\\b\\w+\\s*=\\s*"([a-zA-Z0-9]{${length}})"`); export const USER_AGENT = 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36'; // Those are used even for not logged users to make requests on the graphql api. export const FALLBACK_CLIENT_ID = 'f1a362d288c1b98099c7'; -export const FALLBACK_CLIENT_SECRET = 'eea605b96e01c796ff369935357eca920c5da4c5'; +export const FALLBACK_CLIENT_SECRET = + 'eea605b96e01c796ff369935357eca920c5da4c5'; export const X_DM_AppInfo_Id = 'com.dailymotion.neon'; export const X_DM_AppInfo_Type = 'website'; diff --git a/src/extraction.ts b/src/extraction.ts new file mode 100644 index 0000000..d459749 --- /dev/null +++ b/src/extraction.ts @@ -0,0 +1,182 @@ +import { AnonymousUserAuthorization } from '../types/types'; +import { + BASE_URL, + BASE_URL_API_AUTH, + createAuthRegexByTextLength, + FALLBACK_CLIENT_ID, + FALLBACK_CLIENT_SECRET, + REGEX_INITIAL_DATA_API_AUTH_1, + USER_AGENT, +} from './constants'; +import { objectToUrlEncodedString } from './util'; + +export function oauthClientCredentialsRequest( + httpClient: IHttp, + url: string, + clientId: string, + secret: string, + throwOnInvalid = false, +): HttpResponse { + if (!httpClient || !url || !clientId || !secret) { + throw new ScriptException( + 'Invalid parameters provided to oauthClientCredentialsRequest', + ); + } + + const body = objectToUrlEncodedString({ + client_id: clientId, + client_secret: secret, + grant_type: 'client_credentials', + }); + + try { + return httpClient.POST( + url, + body, + { + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: BASE_URL, + DNT: '1', + 'Sec-GPC': '1', + Connection: 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site', + Priority: 'u=4', + Pragma: 'no-cache', + 'Cache-Control': 'no-cache', + }, + false, + ); + } catch (error) { + console.error('Error making OAuth client credentials request:', error); + if (throwOnInvalid) { + throw new ScriptException('Failed to obtain OAuth client credentials'); + } + return null; + } +} + +export function extractClientCredentials(httpClient: IHttp) { + const detailsRequestHtml = httpClient.GET(BASE_URL, {}, false); + + if (!detailsRequestHtml.isOk) { + throw new ScriptException('Failed to fetch page to extract auth details'); + } + + const result = [ + { + clientId: FALLBACK_CLIENT_ID, + secret: FALLBACK_CLIENT_SECRET, + }, + ]; + + const match = detailsRequestHtml.body.match(REGEX_INITIAL_DATA_API_AUTH_1); + + if (match?.length === 2 && match[0] && match[1]) { + result.unshift({ + clientId: match[0], + secret: match[1], + }); + console.log('Successfully extracted API credentials from page:', match[1]); + } else { + console.log( + 'Failed to extract API credentials from page using regex. Using DOM parsing.', + ); + + const htmlElement = domParser.parseFromString( + detailsRequestHtml.body, + 'text/html', + ); + const extractedId = getScriptVariableByTextLength(htmlElement, 20); + const extractedSecret = getScriptVariableByTextLength(htmlElement, 40); + + if (extractedId && extractedSecret) { + result.unshift({ + clientId: extractedId, + secret: extractedSecret, + }); + + console.log( + 'Successfully extracted API credentials from page using DOM parsing:', + extractedId, + ); + } else { + console.log( + 'Failed to extract API credentials using DOM parsing with exact text length.', + ); + } + } + + return result; +} + +export function getScriptVariableByTextLength(htmlElement, length: number) { + const scriptTags = htmlElement.querySelectorAll( + 'script[type="text/javascript"]', + ); + + if (!scriptTags.length) { + console.error('No script tags found.'); + return null; // or throw an error, depending on your use case + } + + let pageContent = ''; + + scriptTags.forEach((tag) => { + pageContent += tag.outerHTML; + }); + + let matches = createAuthRegexByTextLength(length).exec(pageContent); + + if (matches?.length == 2) { + return matches[1]; + } +} + +export function getTokenFromClientCredentials( + httpClient: IHttp, + credentials, + throwOnInvalid = false, +) { + let result: AnonymousUserAuthorization = { + isValid: false, + }; + + for (const credential of credentials) { + const res = oauthClientCredentialsRequest( + httpClient, + BASE_URL_API_AUTH, + credential.clientId, + credential.secret, + ); + + if (res?.isOk) { + const anonymousTokenResponse = JSON.parse(res.body); + + if ( + !anonymousTokenResponse.token_type || + !anonymousTokenResponse.access_token + ) { + console.error('Invalid token response', res); + if (throwOnInvalid) { + throw new ScriptException('', 'Invalid token response: ' + res.body); + } + } + + result = { + anonymousUserAuthorizationToken: `${anonymousTokenResponse.token_type} ${anonymousTokenResponse.access_token}`, + anonymousUserAuthorizationTokenExpirationDate: + Date.now() + anonymousTokenResponse.expires_in * 1000, + isValid: true, + }; + + break; + } else { + console.error('Failed to get token', res); + } + } + + return result; +} diff --git a/types/plugin.d.ts b/types/plugin.d.ts index 2e6b975..74a026f 100644 --- a/types/plugin.d.ts +++ b/types/plugin.d.ts @@ -1361,11 +1361,17 @@ let Language = { }; interface HttpResponse { - isOk(): boolean; + isOk: boolean; body: string; code: number; } +domParser.parseFromString(detailsRequestHtml.body, "text/html") + +let domParser = { + parseFromString: function (elementText: string, contentType: string): Unit {}, +} + //Package Bridge (variable: bridge) let bridge = { /** diff --git a/types/types.d.ts b/types/types.d.ts index ef6e55a..490eae8 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -30,3 +30,9 @@ interface IPlatformSystemPlaylist { usePlatformAuth: boolean; thumbnailResolutionIndex: number; } + +type AnonymousUserAuthorization = { + anonymousUserAuthorizationToken?: string, + anonymousUserAuthorizationTokenExpirationDate?: number, + isValid: boolean +} \ No newline at end of file -- GitLab