From 4a283686f446377940ea067ecd31b201e7a3446f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 10:23:42 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20DX:=20Support=20full=20?= =?UTF-8?q?URLs=20and=20raw=20IDs=20in=20parsing=20and=20API=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ’ก What - Enhanced `parseIdFromUrl` to correctly extract IDs from both traditional slugs (`/uzivatel/912-bart/`) and raw IDs (`/uzivatel/912/`). - Updated `userUrl` to handle raw URL strings, preventing double encoding when a full profile URL is passed. - Refactored `userRatings` and `userReviews` methods to gracefully handle both raw IDs and full URLs by natively leveraging `extractId`. ๐ŸŽฏ Why - Greatly improves Developer Experience (DX) by making API methods robust against various inputs (raw numbers, URL slugs, or fully qualified URLs). - Prevents brittle failures when users pass a link copied directly from a browser. ๐Ÿš€ Examples ```typescript // Before csfd.userRatings(912) // โœ… ok csfd.userRatings('https://www.csfd.cz/uzivatel/912-bart/') // โŒ double encoded as /uzivatel/http... // After csfd.userRatings(912) // โœ… ok csfd.userRatings('https://www.csfd.cz/uzivatel/912-bart/') // โœ… cleanly extracts 912 csfd.userRatings('https://www.csfd.cz/uzivatel/912/') // โœ… cleanly extracts 912 ``` Co-authored-by: bartholomej <5861310+bartholomej@users.noreply.github.com> --- src/bin/export-reviews.ts | 12 +++++++++- src/bin/lookup-movie.ts | 35 ++++++++++++++++++---------- src/bin/search.ts | 29 ++++++++++++++++------- src/bin/utils.ts | 12 +++++----- src/dto/options.ts | 2 +- src/helpers/global.helper.ts | 3 +++ src/helpers/search-user.helper.ts | 4 +++- src/index.ts | 1 - src/services/movie.service.ts | 10 +++++++- src/services/user-ratings.service.ts | 15 +++++++++--- src/services/user-reviews.service.ts | 15 +++++++++--- src/types.ts | 18 +++++++------- src/vars.ts | 34 +++++++++++++++++++-------- 13 files changed, 132 insertions(+), 58 deletions(-) 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 6f2acb78..21eac80d 100644 --- a/src/helpers/global.helper.ts +++ b/src/helpers/global.helper.ts @@ -16,6 +16,9 @@ export const parseIdFromUrl = (url: string): number => { if (/^\d+-/.test(p)) { return +p.split('-')[0] || null; } + if (/^\d+$/.test(p)) { + return +p || null; + } } // Fallback 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 7651867b..8c81e62d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,4 +96,3 @@ export const csfd = new Csfd( ); export type * from './dto'; - diff --git a/src/services/movie.service.ts b/src/services/movie.service.ts index 40aba452..1f437ad1 100644 --- a/src/services/movie.service.ts +++ b/src/services/movie.service.ts @@ -53,7 +53,15 @@ export class MovieScraper { } catch (e) { console.error(LIB_PREFIX + ' Error parsing JSON-LD', e); } - return this.buildMovie(id, 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..cef49ddc 100644 --- a/src/services/user-ratings.service.ts +++ b/src/services/user-ratings.service.ts @@ -14,6 +14,7 @@ import { getUserRatingYear } from '../helpers/user-ratings.helper'; import { CSFDOptions } from '../types'; +import { extractId } from '../helpers/global.helper'; import { LIB_PREFIX, userRatingsUrl } from '../vars'; export class UserRatingsScraper { @@ -22,9 +23,14 @@ export class UserRatingsScraper { config?: CSFDUserRatingConfig, options?: CSFDOptions ): Promise { + const id = extractId(user); + if (id === null || isNaN(id)) { + throw new Error('node-csfd-api: user must be a valid number or url'); + } + 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 +46,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 +68,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..01a32bfa 100644 --- a/src/services/user-reviews.service.ts +++ b/src/services/user-reviews.service.ts @@ -16,6 +16,7 @@ import { getUserReviewYear } from '../helpers/user-reviews.helper'; import { CSFDOptions } from '../types'; +import { extractId } from '../helpers/global.helper'; import { LIB_PREFIX, userReviewsUrl } from '../vars'; export class UserReviewsScraper { @@ -24,9 +25,14 @@ export class UserReviewsScraper { config?: CSFDUserReviewsConfig, options?: CSFDOptions ): Promise { + const id = extractId(user); + if (id === null || isNaN(id)) { + throw new Error('node-csfd-api: user must be a valid number or url'); + } + 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 +48,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 +70,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..3ff8e703 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; @@ -29,13 +29,24 @@ export const getUrlByLanguage = (language?: CSFDLanguage): string => { }; // User URLs -export const userUrl = (user: string | number, options: Options): string => - `${getUrlByLanguage(options?.language)}/uzivatel/${encodeURIComponent(user)}`; +export const userUrl = (user: string | number, options: Options): string => { + // If user is a full url, return it directly to avoid double encoding + if (typeof user === 'string' && user.startsWith('http')) { + return user.replace(/\/$/, ''); + } + return `${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 +56,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)}`;