diff --git a/src/helpers/global.helper.ts b/src/helpers/global.helper.ts index bfb27f8e..6f2acb78 100644 --- a/src/helpers/global.helper.ts +++ b/src/helpers/global.helper.ts @@ -25,6 +25,31 @@ export const parseIdFromUrl = (url: string): number => { return +id || null; }; +/** + * Extracts a numeric ID from a number, string, slug, or full URL. + * Designed for Developer Experience (DX) to allow flexible inputs. + */ +export const extractId = (idOrUrl: number | string): number | null => { + if (typeof idOrUrl === 'number') { + return isNaN(idOrUrl) ? null : idOrUrl; + } + + if (typeof idOrUrl === 'string') { + // Pure number string + if (/^\d+$/.test(idOrUrl)) { + return Number(idOrUrl); + } + // Direct slug with ID prefix (e.g. "228329-avatar") + if (/^\d+-/.test(idOrUrl)) { + return +idOrUrl.split('-')[0] || null; + } + // Fallback to URL parsing + return parseIdFromUrl(idOrUrl); + } + + return null; +}; + export const parseLastIdFromUrl = (url: string): number => { if (url) { const idSlug = url?.split('/')[3]; diff --git a/src/index.ts b/src/index.ts index b672d235..7651867b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,14 +54,14 @@ export class Csfd { return this.userReviewsService.userReviews(user, config, opts); } - public async movie(movie: number, options?: CSFDOptions): Promise { + public async movie(movie: number | string, options?: CSFDOptions): Promise { const opts = options ?? this.defaultOptions; - return this.movieService.movie(+movie, opts); + return this.movieService.movie(movie, opts); } - public async creator(creator: number, options?: CSFDOptions): Promise { + public async creator(creator: number | string, options?: CSFDOptions): Promise { const opts = options ?? this.defaultOptions; - return this.creatorService.creator(+creator, opts); + return this.creatorService.creator(creator, opts); } public async search(text: string, options?: CSFDOptions): Promise { diff --git a/src/services/creator.service.ts b/src/services/creator.service.ts index b33be6ea..2f878a85 100644 --- a/src/services/creator.service.ts +++ b/src/services/creator.service.ts @@ -1,6 +1,7 @@ import { HTMLElement, parse } from 'node-html-parser'; import { CSFDCreator } from '../dto/creator'; import { fetchPage } from '../fetchers'; +import { extractId } from '../helpers/global.helper'; import { getCreatorBio, getCreatorBirthdayInfo, @@ -12,9 +13,9 @@ import { CSFDOptions } from '../types'; import { creatorUrl } from '../vars'; export class CreatorScraper { - public async creator(creatorId: number, options?: CSFDOptions): Promise { - const id = Number(creatorId); - if (isNaN(id)) { + public async creator(creatorId: number | string, options?: CSFDOptions): Promise { + const id = extractId(creatorId); + if (id === null || isNaN(id)) { throw new Error('node-csfd-api: creatorId must be a valid number'); } const url = creatorUrl(id, { language: options?.language }); diff --git a/src/services/movie.service.ts b/src/services/movie.service.ts index bb145106..40aba452 100644 --- a/src/services/movie.service.ts +++ b/src/services/movie.service.ts @@ -2,6 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser'; import { CSFDFilmTypes } from '../dto/global'; import { CSFDMovie, MovieJsonLd } from '../dto/movie'; import { fetchPage } from '../fetchers'; +import { extractId } from '../helpers/global.helper'; import { detectSeasonOrEpisodeListType, getEpisodeCode, @@ -32,9 +33,9 @@ import { CSFDOptions } from '../types'; import { LIB_PREFIX, movieUrl } from '../vars'; export class MovieScraper { - public async movie(movieId: number, options?: CSFDOptions): Promise { - const id = Number(movieId); - if (isNaN(id)) { + public async movie(movieId: number | string, options?: CSFDOptions): Promise { + const id = extractId(movieId); + if (id === null || isNaN(id)) { throw new Error('node-csfd-api: movieId must be a valid number'); } const url = movieUrl(id, { language: options?.language }); @@ -52,7 +53,7 @@ export class MovieScraper { } catch (e) { console.error(LIB_PREFIX + ' Error parsing JSON-LD', e); } - return this.buildMovie(+movieId, movieHtml, movieNode as HTMLElement, asideNode as HTMLElement, pageClasses, jsonLd, options); + return this.buildMovie(id, movieHtml, movieNode as HTMLElement, asideNode as HTMLElement, pageClasses, jsonLd, options); } private buildMovie( diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 12a5d959..a479925a 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { addProtocol, parseColor, parseIdFromUrl } from '../src/helpers/global.helper'; +import { addProtocol, extractId, parseColor, parseIdFromUrl } from '../src/helpers/global.helper'; describe('Add protocol', () => { test('Handle without protocol', () => { @@ -49,6 +49,24 @@ describe('Parse Id', () => { }); }); +describe('extractId', () => { + test('Handle numeric ID', () => { + expect(extractId(228329)).toBe(228329); + }); + test('Handle numeric string', () => { + expect(extractId('228329')).toBe(228329); + }); + test('Handle slug', () => { + expect(extractId('228329-avatar')).toBe(228329); + }); + test('Handle full URL', () => { + expect(extractId('https://www.csfd.cz/film/228329-avatar/')).toBe(228329); + }); + test('Handle invalid strings', () => { + expect(extractId('invalid-string')).toBe(null); + }); +}); + describe('Parse color', () => { test('Red', () => { const url = parseColor('red'); diff --git a/tests/movie.service.test.ts b/tests/movie.service.test.ts index 9d9c5248..318c38c1 100644 --- a/tests/movie.service.test.ts +++ b/tests/movie.service.test.ts @@ -10,6 +10,17 @@ describe('Movie Service coverage', () => { await expect(movieScraper.movie(Number.NaN)).rejects.toThrow('movieId must be a valid number'); }); + test('Fetch using slug format', async () => { + const movieScraper = new MovieScraper(); + const fetchSpy = vi.spyOn(fetchers, 'fetchPage').mockResolvedValue(movieMock); + + // Test slug capability + const movie = await movieScraper.movie('535121-projekt-adam'); + expect(movie.id).toBe(535121); + + fetchSpy.mockRestore(); + }); + test('JSON-LD parse fails gracefully', async () => { const movieScraper = new MovieScraper();