added zoechip source provider

This commit is contained in:
Jonathan Barrow 2023-09-28 18:14:34 -04:00
parent adf9c2b09a
commit 610aee16df
No known key found for this signature in database
GPG Key ID: E86E9FE9049C741F
7 changed files with 359 additions and 1 deletions

View File

@ -9,10 +9,11 @@ import { goMoviesScraper } from '@/providers/sources/gomovies/index';
import { kissAsianScraper } from '@/providers/sources/kissasian/index';
import { remotestreamScraper } from '@/providers/sources/remotestream';
import { superStreamScraper } from '@/providers/sources/superstream/index';
import { zoechipScraper } from '@/providers/sources/zoechip';
export function gatherAllSources(): Array<Sourcerer> {
// all sources are gathered here
return [flixhqScraper, remotestreamScraper, kissAsianScraper, superStreamScraper, goMoviesScraper];
return [flixhqScraper, remotestreamScraper, kissAsianScraper, superStreamScraper, goMoviesScraper, zoechipScraper];
}
export function gatherAllEmbeds(): Array<Embed> {

View File

@ -0,0 +1,71 @@
import { MovieMedia, ShowMedia } from '@/main/media';
import { mixdropScraper } from '@/providers/embeds/mixdrop';
import { upcloudScraper } from '@/providers/embeds/upcloud';
import { upstreamScraper } from '@/providers/embeds/upstream';
import { getZoeChipSourceURL, getZoeChipSources } from '@/providers/sources/zoechip/scrape';
import { ScrapeContext } from '@/utils/context';
export const zoeBase = 'https://zoechip.cc';
export type MovieContext = ScrapeContext & {
media: MovieMedia;
};
export type ShowContext = ScrapeContext & {
media: ShowMedia;
};
export type ZoeChipSourceDetails = {
type: string; // Only seen "iframe" so far
link: string;
sources: string[]; // Never seen this populated, assuming it's a string array
tracks: string[]; // Never seen this populated, assuming it's a string array
title: string;
};
export async function formatSource(ctx: MovieContext | ShowContext, source: { embed: string; episodeId: string }) {
const link = await getZoeChipSourceURL(ctx, source.episodeId);
if (link) {
const embed = {
embedId: '',
url: link,
};
const parsedUrl = new URL(link);
switch (parsedUrl.host) {
case 'rabbitstream.net':
embed.embedId = upcloudScraper.id;
break;
case 'upstream.to':
embed.embedId = upstreamScraper.id;
break;
case 'mixdrop.co':
embed.embedId = mixdropScraper.id;
break;
default:
throw new Error(`Failed to find ZoeChip embed source for ${link}`);
}
return embed;
}
}
export async function createZoeChipStreamData(ctx: MovieContext | ShowContext, id: string) {
const sources = await getZoeChipSources(ctx, id);
const embeds: {
embedId: string;
url: string;
}[] = [];
for (const source of sources) {
const formatted = await formatSource(ctx, source);
if (formatted) {
embeds.push(formatted);
}
}
return {
embeds,
};
}

View File

@ -0,0 +1,13 @@
import { flags } from '@/main/targets';
import { makeSourcerer } from '@/providers/base';
import { scrapeMovie } from '@/providers/sources/zoechip/scrape-movie';
import { scrapeShow } from '@/providers/sources/zoechip/scrape-show';
export const zoechipScraper = makeSourcerer({
id: 'zoechip',
name: 'ZoeChip',
rank: 110,
flags: [flags.NO_CORS],
scrapeMovie,
scrapeShow,
});

View File

@ -0,0 +1,12 @@
import { MovieContext, createZoeChipStreamData } from '@/providers/sources/zoechip/common';
import { getZoeChipMovieID } from '@/providers/sources/zoechip/search';
import { NotFoundError } from '@/utils/errors';
export async function scrapeMovie(ctx: MovieContext) {
const movieID = await getZoeChipMovieID(ctx, ctx.media);
if (!movieID) {
throw new NotFoundError('no search results match');
}
return createZoeChipStreamData(ctx, movieID);
}

View File

@ -0,0 +1,23 @@
import { ShowContext, createZoeChipStreamData } from '@/providers/sources/zoechip/common';
import { getZoeChipEpisodeID, getZoeChipSeasonID } from '@/providers/sources/zoechip/scrape';
import { getZoeChipShowID } from '@/providers/sources/zoechip/search';
import { NotFoundError } from '@/utils/errors';
export async function scrapeShow(ctx: ShowContext) {
const showID = await getZoeChipShowID(ctx, ctx.media);
if (!showID) {
throw new NotFoundError('no search results match');
}
const seasonID = await getZoeChipSeasonID(ctx, ctx.media, showID);
if (!seasonID) {
throw new NotFoundError('no season found');
}
const episodeID = await getZoeChipEpisodeID(ctx, ctx.media, seasonID);
if (!episodeID) {
throw new NotFoundError('no episode found');
}
return createZoeChipStreamData(ctx, episodeID);
}

View File

@ -0,0 +1,126 @@
import { load } from 'cheerio';
import { ShowMedia } from '@/main/media';
import { MovieContext, ShowContext, ZoeChipSourceDetails, zoeBase } from '@/providers/sources/zoechip/common';
import { ScrapeContext } from '@/utils/context';
export async function getZoeChipSources(ctx: MovieContext | ShowContext, id: string) {
// Movies use /ajax/episode/list/ID
// Shows use /ajax/episode/servers/ID
const endpoint = ctx.media.type === 'movie' ? 'list' : 'servers';
const html = await ctx.proxiedFetcher<string>(`/ajax/episode/${endpoint}/${id}`, {
baseUrl: zoeBase,
});
const $ = load(html);
return $('.nav-item a')
.toArray()
.map((el) => {
// Movies use data-linkid
// Shows use data-id
const idAttribute = ctx.media.type === 'movie' ? 'data-linkid' : 'data-id';
const element = $(el);
const embedTitle = element.attr('title');
const linkId = element.attr(idAttribute);
if (!embedTitle || !linkId) {
throw new Error('invalid sources');
}
return {
embed: embedTitle,
episodeId: linkId,
};
});
}
export async function getZoeChipSourceURL(ctx: ScrapeContext, sourceID: string): Promise<string | null> {
const details = await ctx.proxiedFetcher<ZoeChipSourceDetails>(`/ajax/sources/${sourceID}`, {
baseUrl: zoeBase,
});
// TODO - Support non-iframe sources
if (details.type !== 'iframe') {
return null;
}
// TODO - Extract the other data from the source
return details.link;
}
export async function getZoeChipSeasonID(ctx: ScrapeContext, media: ShowMedia, showID: string): Promise<string | null> {
const html = await ctx.proxiedFetcher<string>(`/ajax/season/list/${showID}`, {
baseUrl: zoeBase,
});
const $ = load(html);
const seasons = $('.dropdown-menu a')
.toArray()
.map((el) => {
const element = $(el);
const seasonID = element.attr('data-id');
const seasonNumber = element.html()?.split(' ')[1];
if (!seasonID || !seasonNumber || Number.isNaN(Number(seasonNumber))) {
throw new Error('invalid season');
}
return {
id: seasonID,
season: Number(seasonNumber),
};
});
for (const season of seasons) {
if (season.season === media.season.number) {
return season.id;
}
}
return null;
}
export async function getZoeChipEpisodeID(
ctx: ScrapeContext,
media: ShowMedia,
seasonID: string,
): Promise<string | null> {
const episodeNumberRegex = /Eps (\d*):/;
const html = await ctx.proxiedFetcher<string>(`/ajax/season/episodes/${seasonID}`, {
baseUrl: zoeBase,
});
const $ = load(html);
const episodes = $('.eps-item')
.toArray()
.map((el) => {
const element = $(el);
const episodeID = element.attr('data-id');
const title = element.attr('title');
if (!episodeID || !title) {
throw new Error('invalid episode');
}
const regexResult = title.match(episodeNumberRegex);
if (!regexResult || Number.isNaN(Number(regexResult[1]))) {
throw new Error('invalid episode');
}
return {
id: episodeID,
episode: Number(regexResult[1]),
};
});
for (const episode of episodes) {
if (episode.episode === media.episode.number) {
return episode.id;
}
}
return null;
}

View File

@ -0,0 +1,112 @@
import { load } from 'cheerio';
import { MovieMedia, ShowMedia } from '@/main/media';
import { zoeBase } from '@/providers/sources/zoechip/common';
import { compareMedia } from '@/utils/compare';
import { ScrapeContext } from '@/utils/context';
export async function getZoeChipSearchResults(ctx: ScrapeContext, media: MovieMedia | ShowMedia) {
const titleCleaned = media.title.toLocaleLowerCase().replace(/ /g, '-');
const html = await ctx.proxiedFetcher<string>(`/search/${titleCleaned}`, {
baseUrl: zoeBase,
});
const $ = load(html);
return $('.film_list-wrap .flw-item .film-detail')
.toArray()
.map((element) => {
const movie = $(element);
const anchor = movie.find('.film-name a');
const info = movie.find('.fd-infor');
const title = anchor.attr('title');
const href = anchor.attr('href');
const type = info.find('.fdi-type').html();
let year = info.find('.fdi-item').html();
const id = href?.split('-').pop();
if (!title) {
return null;
}
if (!href) {
return null;
}
if (!type) {
return null;
}
// TV shows on ZoeChip do not have a year in their search results
// Allow TV shows to pass this failure
if (!year || Number.isNaN(Number(year))) {
if (type === 'TV') {
year = '0';
} else {
return null;
}
}
if (!id) {
return null;
}
return {
title,
year: Number(year),
id,
type,
href,
};
});
}
export async function getZoeChipMovieID(ctx: ScrapeContext, media: MovieMedia): Promise<string | null> {
const searchResults = await getZoeChipSearchResults(ctx, media);
const matchingItem = searchResults.find((v) => v && v.type === 'Movie' && compareMedia(media, v.title, v.year));
if (!matchingItem) {
return null;
}
return matchingItem.id;
}
export async function getZoeChipShowID(ctx: ScrapeContext, media: ShowMedia): Promise<string | null> {
// ZoeChip TV shows don't have a year on their search results
// This makes it hard to filter between shows with the same name
// To find the year, we must query each shows details page
// This is slower, but more reliable
const releasedRegex = /<\/strong><\/span> (\d.*)-\d.*-\d.*/;
const searchResults = await getZoeChipSearchResults(ctx, media);
// Since we don't have a year here, force them to be the same. Only compare titles
const filtered = searchResults.filter((v) => v && v.type === 'TV' && compareMedia(media, v.title, media.releaseYear));
for (const result of filtered) {
// This gets filtered above but the linter Gods don't think so
if (!result) {
continue;
}
const html = await ctx.proxiedFetcher<string>(result.href, {
baseUrl: zoeBase,
});
// The HTML is not structured in a way that makes using Cheerio clean
// There are no unique IDs or classes to query, resulting in long ugly queries
// Regex is faster and cleaner in this case
const regexResult = html.match(releasedRegex);
if (regexResult) {
const year = Number(regexResult[1]);
if (!Number.isNaN(year) && year === media.releaseYear) {
return result.id;
}
}
}
return null;
}