diff --git a/src/bin/export-reviews.ts b/src/bin/export-reviews.ts index c7b44955..e860a4c6 100644 --- a/src/bin/export-reviews.ts +++ b/src/bin/export-reviews.ts @@ -30,7 +30,17 @@ export async function runReviewsExport(userId: number, options: ExportReviewsOpt content = JSON.stringify(reviews, null, 2); fileName = `${userId}-reviews.json`; } else { - const headers = ['id', 'title', 'year', 'type', 'colorRating', 'userRating', 'date', 'url', 'text']; + const headers = [ + 'id', + 'title', + 'year', + 'type', + 'colorRating', + 'userRating', + 'date', + 'url', + 'text' + ]; content = [ headers.join(','), ...reviews.map((r) => diff --git a/src/bin/lookup-movie.ts b/src/bin/lookup-movie.ts index 3aed468b..eb50b9e3 100644 --- a/src/bin/lookup-movie.ts +++ b/src/bin/lookup-movie.ts @@ -13,15 +13,22 @@ export async function runMovieLookup(movieId: number, json: boolean): Promise value ? ` ${c.dim(label.padEnd(11))} ${value}` : ''; const names = (arr: { name: string }[], max = 5) => - arr.slice(0, max).map((x) => x.name).join(', '); + arr + .slice(0, max) + .map((x) => x.name) + .join(', '); const description = movie.descriptions?.[0] ? movie.descriptions[0].length > 160 @@ -35,18 +42,22 @@ function printMovie(movie: CSFDMovie) { '', c.bold(movie.title) + c.dim(` (${movie.year ?? '?'})`) + ' · ' + c.dim(movie.type ?? ''), c.dim('─'.repeat(52)), - row('Rating', movie.rating != null - ? ratingColor(c.bold(movie.rating + '%')) + c.dim(` (${movie.ratingCount?.toLocaleString()} ratings)`) - : c.dim('no rating')), - row('Genres', movie.genres?.join(', ') ?? ''), - row('Origins', movie.origins?.join(', ') ?? ''), - row('Duration', movie.duration ? movie.duration + ' min' : ''), + row( + 'Rating', + movie.rating != null + ? ratingColor(c.bold(movie.rating + '%')) + + c.dim(` (${movie.ratingCount?.toLocaleString()} ratings)`) + : c.dim('no rating') + ), + row('Genres', movie.genres?.join(', ') ?? ''), + row('Origins', movie.origins?.join(', ') ?? ''), + row('Duration', movie.duration ? movie.duration + ' min' : ''), row('Directors', names(movie.creators?.directors ?? [])), - row('Cast', names(movie.creators?.actors ?? [])), + row('Cast', names(movie.creators?.actors ?? [])), description ? '\n ' + c.dim(description) : '', vod ? '\n' + row('VOD', vod) : '', row('URL', c.dim(movie.url ?? '')), - '', + '' ].filter(Boolean); console.log(lines.join('\n')); diff --git a/src/bin/search.ts b/src/bin/search.ts index 9d1322bd..6d7abeb0 100644 --- a/src/bin/search.ts +++ b/src/bin/search.ts @@ -13,17 +13,27 @@ export async function runSearch(query: string, json: boolean): Promise { function printSearch(query: string, results: CSFDSearch) { const ratingDot = (colorRating: string | null) => - colorRating === 'good' ? c.green('●') : - colorRating === 'average' ? c.yellow('●') : - colorRating === 'bad' ? c.red('●') : c.dim('●'); + colorRating === 'good' + ? c.green('●') + : colorRating === 'average' + ? c.yellow('●') + : colorRating === 'bad' + ? c.red('●') + : c.dim('●'); const section = (label: string, count: number) => count > 0 ? `\n${c.bold(label)} ${c.dim(`(${count})`)}` : null; - const total = results.movies.length + results.tvSeries.length + results.creators.length + results.users.length; + const total = + results.movies.length + + results.tvSeries.length + + results.creators.length + + results.users.length; console.log(''); - console.log(`${c.bold('Search results for')} ${c.cyan(`"${query}"`)} ${c.dim(`— ${total} found`)}`); + console.log( + `${c.bold('Search results for')} ${c.cyan(`"${query}"`)} ${c.dim(`— ${total} found`)}` + ); console.log(c.dim('─'.repeat(52))); const movieLine = (r: CSFDSearch['movies'][0]) => @@ -43,15 +53,16 @@ function printSearch(query: string, results: CSFDSearch) { if (results.creators.length > 0) { console.log(section('Creators', results.creators.length)); - results.creators.forEach((r) => - console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.name}`) - ); + results.creators.forEach((r) => console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.name}`)); } if (results.users.length > 0) { console.log(section('Users', results.users.length)); results.users.forEach((r) => - console.log(` ${c.dim(String(r.id).padEnd(8))} ${r.user}` + (r.userRealName ? c.dim(` (${r.userRealName})`) : '')) + console.log( + ` ${c.dim(String(r.id).padEnd(8))} ${r.user}` + + (r.userRealName ? c.dim(` (${r.userRealName})`) : '') + ) ); } diff --git a/src/bin/utils.ts b/src/bin/utils.ts index bf25b895..033d9a53 100644 --- a/src/bin/utils.ts +++ b/src/bin/utils.ts @@ -3,12 +3,12 @@ export const useColor = process.stdout.isTTY && !process.env['NO_COLOR']; export const c = { - bold: (s: string) => useColor ? `\x1b[1m${s}\x1b[22m` : s, - dim: (s: string) => useColor ? `\x1b[2m${s}\x1b[22m` : s, - cyan: (s: string) => useColor ? `\x1b[36m${s}\x1b[39m` : s, - green: (s: string) => useColor ? `\x1b[32m${s}\x1b[39m` : s, - yellow: (s: string) => useColor ? `\x1b[33m${s}\x1b[39m` : s, - red: (s: string) => useColor ? `\x1b[31m${s}\x1b[39m` : s, + bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s), + dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s), + cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[39m` : s), + green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s), + yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s), + red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[39m` : s) }; export const err = (msg: string) => c.red(c.bold('✖ Error:')) + ' ' + msg; diff --git a/src/dto/options.ts b/src/dto/options.ts index 4b8ea9fd..e350bb79 100644 --- a/src/dto/options.ts +++ b/src/dto/options.ts @@ -3,4 +3,4 @@ export interface CSFDOptions { request?: RequestInit; } -export type CSFDLanguage = 'cs' | 'en' | 'sk'; \ No newline at end of file +export type CSFDLanguage = 'cs' | 'en' | 'sk'; diff --git a/src/helpers/global.helper.ts b/src/helpers/global.helper.ts index bfb27f8e..a65bccfe 100644 --- a/src/helpers/global.helper.ts +++ b/src/helpers/global.helper.ts @@ -35,6 +35,44 @@ export const parseLastIdFromUrl = (url: string): number => { } }; +export const extractId = (value: number | string): number | null => { + if (value === null || value === undefined) return null; + + if (typeof value === 'number') { + return Number.isInteger(value) ? value : null; + } + + const str = value.toString().trim(); + + // If it's a URL + if (str.includes('/')) { + const parts = str.split('/'); + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; + if (/^\d+-/.test(p)) { + return +p.split('-')[0] || null; + } + } + // Fallback logic for URL + const hasLangPrefix = LANG_PREFIX_REGEX.test(parts[1]); + const idSlug = parts[hasLangPrefix ? 3 : 2]; + const id = idSlug?.split('-')[0]; + return +id || null; + } + + // If it's just a number string + if (/^\d+$/.test(str)) { + return Number(str); + } + + // If it's a slug like "912-bart" + if (/^\d+-/.test(str)) { + return +str.split('-')[0]; + } + + return null; +}; + const PAGE_COLORS: Record = { 'page-lightgrey': 'unknown', 'page-red': 'good', diff --git a/src/helpers/search-user.helper.ts b/src/helpers/search-user.helper.ts index 66ecd143..1d8f5be1 100644 --- a/src/helpers/search-user.helper.ts +++ b/src/helpers/search-user.helper.ts @@ -9,7 +9,9 @@ export const getUserRealName = (el: HTMLElement): string => { const p = el.querySelector('.article-content p'); if (!p) return null; - const textNodes = p.childNodes.filter(n => n.nodeType === NodeType.TEXT_NODE && n.rawText.trim() !== ''); + const textNodes = p.childNodes.filter( + (n) => n.nodeType === NodeType.TEXT_NODE && n.rawText.trim() !== '' + ); const name = textNodes.length ? textNodes[0].rawText.trim() : null; return name; diff --git a/src/index.ts b/src/index.ts index b672d235..8c81e62d 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 { @@ -96,4 +96,3 @@ export const csfd = new Csfd( ); export type * from './dto'; - diff --git a/src/services/creator.service.ts b/src/services/creator.service.ts index b33be6ea..b7ec87b2 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,10 +13,10 @@ 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)) { - throw new Error('node-csfd-api: creatorId must be a valid number'); + public async creator(creatorId: number | string, options?: CSFDOptions): Promise { + const id = extractId(creatorId); + if (!id) { + throw new Error('node-csfd-api: creatorId must be a valid number, slug, or URL'); } const url = creatorUrl(id, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); @@ -24,7 +25,7 @@ export class CreatorScraper { const asideNode = creatorHtml.querySelector('.creator-about'); const filmsNode = creatorHtml.querySelector('.creator-filmography'); - return this.buildCreator(+creatorId, asideNode, filmsNode); + return this.buildCreator(id, asideNode, filmsNode); } private buildCreator(id: number, asideEl: HTMLElement, filmsNode: HTMLElement): CSFDCreator { diff --git a/src/services/movie.service.ts b/src/services/movie.service.ts index bb145106..ea3085d4 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,10 +33,10 @@ 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)) { - throw new Error('node-csfd-api: movieId must be a valid number'); + public async movie(movieId: number | string, options?: CSFDOptions): Promise { + const id = extractId(movieId); + if (!id) { + throw new Error('node-csfd-api: movieId must be a valid number, slug, or URL'); } const url = movieUrl(id, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); @@ -52,7 +53,15 @@ 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/src/services/user-ratings.service.ts b/src/services/user-ratings.service.ts index 09244fe8..16eb4e5e 100644 --- a/src/services/user-ratings.service.ts +++ b/src/services/user-ratings.service.ts @@ -2,7 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser'; import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global'; import { CSFDUserRatingConfig, CSFDUserRatings } from '../dto/user-ratings'; import { fetchPage } from '../fetchers'; -import { sleep } from '../helpers/global.helper'; +import { extractId, sleep } from '../helpers/global.helper'; import { getUserRating, getUserRatingColorRating, @@ -22,9 +22,10 @@ export class UserRatingsScraper { config?: CSFDUserRatingConfig, options?: CSFDOptions ): Promise { + const id = extractId(user) ?? user; let allMovies: CSFDUserRatings[] = []; const pageToFetch = config?.page || 1; - const url = userRatingsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, { + const url = userRatingsUrl(id, pageToFetch > 1 ? pageToFetch : undefined, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); @@ -40,7 +41,7 @@ export class UserRatingsScraper { if (config?.allPages) { for (let i = 2; i <= pages; i++) { config.onProgress?.(i, pages); - const url = userRatingsUrl(user, i, { language: options?.language }); + const url = userRatingsUrl(id, i, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); const items = parse(response); @@ -62,7 +63,10 @@ export class UserRatingsScraper { const films: CSFDUserRatings[] = []; if (config) { if (config.includesOnly?.length && config.excludes?.length) { - console.warn(`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, config.includesOnly); + console.warn( + `${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, + config.includesOnly + ); } } diff --git a/src/services/user-reviews.service.ts b/src/services/user-reviews.service.ts index 88f5f498..7fa5771f 100644 --- a/src/services/user-reviews.service.ts +++ b/src/services/user-reviews.service.ts @@ -2,7 +2,7 @@ import { HTMLElement, parse } from 'node-html-parser'; import { CSFDColorRating, CSFDFilmTypes, CSFDStars } from '../dto/global'; import { CSFDUserReviews, CSFDUserReviewsConfig } from '../dto/user-reviews'; import { fetchPage } from '../fetchers'; -import { sleep } from '../helpers/global.helper'; +import { extractId, sleep } from '../helpers/global.helper'; import { getUserReviewColorRating, getUserReviewDate, @@ -24,9 +24,10 @@ export class UserReviewsScraper { config?: CSFDUserReviewsConfig, options?: CSFDOptions ): Promise { + const id = extractId(user) ?? user; let allReviews: CSFDUserReviews[] = []; const pageToFetch = config?.page || 1; - const url = userReviewsUrl(user, pageToFetch > 1 ? pageToFetch : undefined, { + const url = userReviewsUrl(id, pageToFetch > 1 ? pageToFetch : undefined, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); @@ -42,7 +43,7 @@ export class UserReviewsScraper { if (config?.allPages) { for (let i = 2; i <= pages; i++) { config.onProgress?.(i, pages); - const url = userReviewsUrl(user, i, { language: options?.language }); + const url = userReviewsUrl(id, i, { language: options?.language }); const response = await fetchPage(url, { ...options?.request }); const items = parse(response); @@ -64,7 +65,10 @@ export class UserReviewsScraper { const films: CSFDUserReviews[] = []; if (config) { if (config.includesOnly?.length && config.excludes?.length) { - console.warn(`${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, config.includesOnly); + console.warn( + `${LIB_PREFIX} Both 'includesOnly' and 'excludes' were provided. 'includesOnly' takes precedence:`, + config.includesOnly + ); } } diff --git a/src/types.ts b/src/types.ts index 0cfcef34..c5fdc0c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,8 @@ -export * from "./dto/cinema"; -export * from "./dto/creator"; -export * from "./dto/global"; -export * from "./dto/movie"; -export * from "./dto/options"; -export * from "./dto/search"; -export * from "./dto/user-ratings"; -export * from "./dto/user-reviews"; - - +export * from './dto/cinema'; +export * from './dto/creator'; +export * from './dto/global'; +export * from './dto/movie'; +export * from './dto/options'; +export * from './dto/search'; +export * from './dto/user-ratings'; +export * from './dto/user-reviews'; diff --git a/src/vars.ts b/src/vars.ts index 41d54058..f1db3109 100644 --- a/src/vars.ts +++ b/src/vars.ts @@ -11,7 +11,7 @@ type Options = { const LANGUAGE_DOMAIN_MAP: Record = { cs: 'https://www.csfd.cz', en: 'https://www.csfd.cz/en', - sk: 'https://www.csfd.cz/sk', + sk: 'https://www.csfd.cz/sk' }; let BASE_URL = LANGUAGE_DOMAIN_MAP.cs; @@ -32,10 +32,16 @@ export const getUrlByLanguage = (language?: CSFDLanguage): string => { export const userUrl = (user: string | number, options: Options): string => `${getUrlByLanguage(options?.language)}/uzivatel/${encodeURIComponent(user)}`; -export const userRatingsUrl = (user: string | number, page?: number, options: Options = {}): string => - `${userUrl(user, options)}/hodnoceni/${page ? '?page=' + page : ''}`; -export const userReviewsUrl = (user: string | number, page?: number, options: Options = {}): string => - `${userUrl(user, options)}/recenze/${page ? '?page=' + page : ''}`; +export const userRatingsUrl = ( + user: string | number, + page?: number, + options: Options = {} +): string => `${userUrl(user, options)}/hodnoceni/${page ? '?page=' + page : ''}`; +export const userReviewsUrl = ( + user: string | number, + page?: number, + options: Options = {} +): string => `${userUrl(user, options)}/recenze/${page ? '?page=' + page : ''}`; // Movie URLs export const movieUrl = (movie: number, options: Options): string => @@ -45,9 +51,12 @@ export const creatorUrl = (creator: number | string, options: Options): string = `${getUrlByLanguage(options?.language)}/tvurce/${encodeURIComponent(creator)}`; // Cinema URLs -export const cinemasUrl = (district: number | string, period: CSFDCinemaPeriod, options: Options): string => - `${getUrlByLanguage(options?.language)}/kino/?period=${period}&district=${district}`; +export const cinemasUrl = ( + district: number | string, + period: CSFDCinemaPeriod, + options: Options +): string => `${getUrlByLanguage(options?.language)}/kino/?period=${period}&district=${district}`; // Search URLs export const searchUrl = (text: string, options: Options): string => - `${getUrlByLanguage(options?.language)}/hledat/?q=${encodeURIComponent(text)}`; \ No newline at end of file + `${getUrlByLanguage(options?.language)}/hledat/?q=${encodeURIComponent(text)}`; diff --git a/test-dx-replace.ts b/test-dx-replace.ts new file mode 100644 index 00000000..1b609c8a --- /dev/null +++ b/test-dx-replace.ts @@ -0,0 +1 @@ +import { extractId } from './src/helpers/global.helper.ts'; // Doesn't exist diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 12a5d959..bd7365b0 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -1,5 +1,27 @@ 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('Extract ID', () => { + test('Handle number', () => { + expect(extractId(906693)).toBe(906693); + }); + test('Handle numeric string', () => { + expect(extractId('906693')).toBe(906693); + }); + test('Handle slug', () => { + expect(extractId('906693-projekt-adam')).toBe(906693); + }); + test('Handle URL', () => { + expect(extractId('https://www.csfd.cz/film/906693-projekt-adam/recenze/')).toBe(906693); + }); + test('Handle null/undefined', () => { + expect(extractId(null as any)).toBe(null); + expect(extractId(undefined as any)).toBe(null); + }); + test('Handle invalid string', () => { + expect(extractId('blade-runner')).toBe(null); + }); +}); describe('Add protocol', () => { test('Handle without protocol', () => {