Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/bin/export-reviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
35 changes: 23 additions & 12 deletions src/bin/lookup-movie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ export async function runMovieLookup(movieId: number, json: boolean): Promise<vo

function printMovie(movie: CSFDMovie) {
const ratingColor =
movie.colorRating === 'good' ? c.green :
movie.colorRating === 'average' ? c.yellow :
movie.colorRating === 'bad' ? c.red : c.dim;
movie.colorRating === 'good'
? c.green
: movie.colorRating === 'average'
? c.yellow
: movie.colorRating === 'bad'
? c.red
: c.dim;

const row = (label: string, value: string) =>
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
Expand All @@ -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)`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against "undefined ratings" in CLI output.

At Line 49, movie.ratingCount?.toLocaleString() can print undefined when movie.rating exists but movie.ratingCount is missing. Add a fallback before interpolation.

💡 Suggested fix
-            c.dim(`  (${movie.ratingCount?.toLocaleString()} ratings)`)
+            c.dim(`  (${(movie.ratingCount ?? 0).toLocaleString()} ratings)`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bin/lookup-movie.ts` at line 49, The CLI can print "undefined ratings"
because movie.ratingCount may be missing; update the interpolation in
src/bin/lookup-movie.ts where you call c.dim(` 
(${movie.ratingCount?.toLocaleString()} ratings)`) to guard / provide a fallback
value (e.g. use movie.ratingCount ?? 0 and call toLocaleString on that, or
render "N/A" when undefined) so the string never contains "undefined" — locate
the expression referencing movie.ratingCount in the lookup logic and replace it
with a nullish-coalesced or conditional fallback.

: 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'));
Expand Down
29 changes: 20 additions & 9 deletions src/bin/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@ export async function runSearch(query: string, json: boolean): Promise<void> {

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]) =>
Expand All @@ -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})`) : '')
)
);
}

Expand Down
12 changes: 6 additions & 6 deletions src/bin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/dto/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export interface CSFDOptions {
request?: RequestInit;
}

export type CSFDLanguage = 'cs' | 'en' | 'sk';
export type CSFDLanguage = 'cs' | 'en' | 'sk';
18 changes: 14 additions & 4 deletions src/helpers/movie.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ export const getMovieOrigins = (el: HTMLElement): string[] => {
const originNode = el.querySelector('.origin');
if (!originNode) return [];
const text = originNode.childNodes[0]?.text || '';
return text.split('/').map(x => x.trim()).filter(x => x);
return text
.split('/')
.map((x) => x.trim())
.filter((x) => x);
};

export const getMovieColorRating = (bodyClasses: string[]): CSFDColorRating => {
Expand Down Expand Up @@ -261,10 +264,13 @@ export const getMovieRandomPhoto = (el: HTMLElement | null): string => {
}
};

// Optimization: Extract regex to prevent redundant compilation in .map() iterations
const CLEAN_TEXT_REGEX = /(\r\n|\n|\r|\t)/gm;

export const getMovieTrivia = (el: HTMLElement | null): string[] => {
const triviaNodes = el.querySelectorAll('.article-trivia ul li');
if (triviaNodes?.length) {
return triviaNodes.map((node) => node.textContent.trim().replace(/(\r\n|\n|\r|\t)/gm, ''));
return triviaNodes.map((node) => node.textContent.trim().replace(CLEAN_TEXT_REGEX, ''));
Comment on lines 270 to +273
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard nullable input before querying trivia nodes.

el is typed as nullable, but querySelectorAll is called unguarded. This can throw at runtime when el is null.

🐛 Proposed fix
 export const getMovieTrivia = (el: HTMLElement | null): string[] => {
+  if (!el) return null;
   const triviaNodes = el.querySelectorAll('.article-trivia ul li');
   if (triviaNodes?.length) {
     return triviaNodes.map((node) => node.textContent.trim().replace(CLEAN_TEXT_REGEX, ''));
   } else {
     return null;
   }
 };

As per coding guidelines: "Never assume an element exists. CSFD changes layouts. Use optional chaining ?. or try/catch inside helpers for robust scraping."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getMovieTrivia = (el: HTMLElement | null): string[] => {
const triviaNodes = el.querySelectorAll('.article-trivia ul li');
if (triviaNodes?.length) {
return triviaNodes.map((node) => node.textContent.trim().replace(/(\r\n|\n|\r|\t)/gm, ''));
return triviaNodes.map((node) => node.textContent.trim().replace(CLEAN_TEXT_REGEX, ''));
export const getMovieTrivia = (el: HTMLElement | null): string[] => {
if (!el) return [];
const triviaNodes = el.querySelectorAll('.article-trivia ul li');
if (triviaNodes?.length) {
return triviaNodes.map((node) => node.textContent.trim().replace(CLEAN_TEXT_REGEX, ''));
} else {
return [];
}
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/helpers/movie.helper.ts` around lines 270 - 273, getMovieTrivia calls
el.querySelectorAll without guarding that el may be null; update getMovieTrivia
to return an empty array when el is null (or undefined) before attempting to
query. For example, check if (!el) return []; or use optional chaining to obtain
triviaNodes (const triviaNodes = el?.querySelectorAll('.article-trivia ul li')
?? []), then proceed with the existing length check and mapping using the same
CLEAN_TEXT_REGEX and return path.

} else {
return null;
}
Expand All @@ -273,7 +279,7 @@ export const getMovieTrivia = (el: HTMLElement | null): string[] => {
export const getMovieDescriptions = (el: HTMLElement): string[] => {
return el
.querySelectorAll('.body--plots .plot-full p, .body--plots .plots .plots-item p')
.map((movie) => movie.textContent?.trim().replace(/(\r\n|\n|\r|\t)/gm, ''));
.map((movie) => movie.textContent?.trim().replace(CLEAN_TEXT_REGEX, ''));
};

const parseMoviePeople = (el: HTMLElement): CSFDMovieCreator[] => {
Expand Down Expand Up @@ -434,7 +440,11 @@ export const getMovieGroup = (

export const getMovieType = (el: HTMLElement): CSFDFilmTypes => {
const type = el.querySelector('.film-header-name .type');
const text = type?.innerText?.replace(/[{()}]/g, '').split('\n')[0].trim() || 'film';
const text =
type?.innerText
?.replace(/[{()}]/g, '')
.split('\n')[0]
.trim() || 'film';
return parseFilmType(text);
};

Expand Down
4 changes: 3 additions & 1 deletion src/helpers/search-user.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 13 additions & 4 deletions src/helpers/search.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,21 @@ export const parseSearchPeople = (
if (type === 'directors') who = 'Režie:';
if (type === 'actors') who = 'Hrají:';

const peopleNode = Array.from(el && el.querySelectorAll('.article-content p')).find((el) =>
el.textContent.includes(who)
);
let peopleNode: HTMLElement | undefined = undefined;
if (el) {
// Optimization: Use early return for loop instead of Array.from(nodeList).find(...)
const nodes = el.querySelectorAll('.article-content p');
for (const node of nodes) {
if (node.textContent.includes(who)) {
peopleNode = node;
break;
}
}
}

if (peopleNode) {
const people = Array.from(peopleNode.querySelectorAll('a')) as unknown as HTMLElement[];
// Optimization: Remove unnecessary Array.from() since querySelectorAll returns HTMLElement[]
const people = peopleNode.querySelectorAll('a');

return people.map((person) => {
return {
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,3 @@ export const csfd = new Csfd(
);

export type * from './dto';

10 changes: 9 additions & 1 deletion src/services/movie.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,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(
+movieId,
movieHtml,
movieNode as HTMLElement,
asideNode as HTMLElement,
pageClasses,
jsonLd,
options
);
}

private buildMovie(
Expand Down
5 changes: 4 additions & 1 deletion src/services/user-ratings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,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
);
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/services/user-reviews.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,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
);
}
}

Expand Down
18 changes: 8 additions & 10 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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';
25 changes: 17 additions & 8 deletions src/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type Options = {
const LANGUAGE_DOMAIN_MAP: Record<CSFDLanguage, string> = {
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;
Expand All @@ -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 =>
Expand All @@ -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)}`;
`${getUrlByLanguage(options?.language)}/hledat/?q=${encodeURIComponent(text)}`;
Loading