Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Run `opencli list` for the live registry.
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | Browser |
| **google** | `news` `search` `suggest` `trends` | Public |
| **36kr** | `news` `hot` `search` `article` | Public / Browser |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | Public |
| **producthunt** | `posts` `today` `hot` `browse` | Public / Browser |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | Browser |
| **lobsters** | `hot` `newest` `active` `tag` | Public |
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ npm install -g @jackwener/opencli@latest
| **facebook** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | ζ΅θ§ˆε™¨ |
| **google** | `news` `search` `suggest` `trends` | ε…¬εΌ€ |
| **36kr** | `news` `hot` `search` `article` | ε…¬εΌ€ / ζ΅θ§ˆε™¨ |
| **imdb** | `search` `title` `top` `trending` `person` `reviews` | ε…¬εΌ€ |
| **producthunt** | `posts` `today` `hot` `browse` | ε…¬εΌ€ / ζ΅θ§ˆε™¨ |
| **instagram** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | ζ΅θ§ˆε™¨ |
| **lobsters** | `hot` `newest` `active` `tag` | ε…¬εΌ€ |
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default defineConfig({
{ text: 'Doubao', link: '/adapters/browser/doubao' },
{ text: 'Facebook', link: '/adapters/browser/facebook' },
{ text: 'Google', link: '/adapters/browser/google' },
{ text: 'IMDb', link: '/adapters/browser/imdb' },
{ text: 'Instagram', link: '/adapters/browser/instagram' },
{ text: 'JD.com', link: '/adapters/browser/jd' },
{ text: 'Medium', link: '/adapters/browser/medium' },
Expand Down
47 changes: 47 additions & 0 deletions docs/adapters/browser/imdb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# IMDb

**Mode**: 🌐 Public (Browser) · **Domain**: `www.imdb.com`

## Commands

| Command | Description |
|---------|-------------|
| `opencli imdb search` | Search movies, TV shows, and people |
| `opencli imdb title` | Get movie or TV show details |
| `opencli imdb top` | IMDb Top 250 Movies |
| `opencli imdb trending` | IMDb Most Popular Movies |
| `opencli imdb person` | Get actor or director info |
| `opencli imdb reviews` | Get user reviews for a title |

## Usage Examples

```bash
# Search for a movie
opencli imdb search "inception" --limit 10

# Get movie details
opencli imdb title tt1375666

# Get TV series details (also accepts full URL)
opencli imdb title "https://www.imdb.com/title/tt0903747/"

# Top 250 movies
opencli imdb top --limit 20

# Currently trending movies
opencli imdb trending --limit 10

# Actor/director info with filmography
opencli imdb person nm0634240 --limit 5

# User reviews
opencli imdb reviews tt1375666 --limit 5

# JSON output
opencli imdb top --limit 5 -f json
```

## Prerequisites

- Chrome with Browser Bridge extension installed
- No login required (all data is public)
1 change: 1 addition & 0 deletions docs/adapters/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Run `opencli list` for the live registry.
| **[weread](/adapters/browser/weread)** | `shelf` `search` `book` `ranking` `notebooks` `highlights` `notes` | πŸ” Browser |
| **[douban](/adapters/browser/douban)** | `search` `top250` `subject` `photos` `download` `marks` `reviews` `movie-hot` `book-hot` | πŸ” Browser |
| **[facebook](/adapters/browser/facebook)** | `feed` `profile` `search` `friends` `groups` `events` `notifications` `memories` `add-friend` `join-group` | πŸ” Browser |
| **[imdb](/adapters/browser/imdb)** | `search` `title` `top` `trending` `person` `reviews` | 🌐 / πŸ” |
| **[instagram](/adapters/browser/instagram)** | `explore` `profile` `search` `user` `followers` `following` `follow` `unfollow` `like` `unlike` `comment` `save` `unsave` `saved` | πŸ” Browser |
| **[medium](/adapters/browser/medium)** | `feed` `search` `user` | πŸ” Browser |
| **[sinablog](/adapters/browser/sinablog)** | `hot` `search` `article` `user` | πŸ” Browser |
Expand Down
54 changes: 27 additions & 27 deletions src/clis/douban/download.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import type { CliCommand } from '../../registry.js';
import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
Expand Down Expand Up @@ -39,6 +40,10 @@ beforeAll(() => {
expect(cmd?.func).toBeTypeOf('function');
});

function toPosixPath(value: string): string {
return value.replaceAll(path.sep, '/');
}

describe('douban download', () => {
beforeEach(() => {
mockHttpDownload.mockReset();
Expand Down Expand Up @@ -89,26 +94,22 @@ describe('douban download', () => {
type: 'Rb',
limit: 20,
});
expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/douban-test/30382501', { recursive: true });
expect(mockMkdirSync).toHaveBeenCalledTimes(1);
expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/douban-test/30382501');
expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
expect(mockHttpDownload).toHaveBeenCalledTimes(2);
expect(mockHttpDownload).toHaveBeenNthCalledWith(
1,
'https://img1.doubanio.com/view/photo/l/public/p2913450214.webp',
'/tmp/douban-test/30382501/30382501_001_2913450214_Main_poster.webp',
expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450214/' },
timeout: 60000,
}),
);
expect(mockHttpDownload).toHaveBeenNthCalledWith(
2,
'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
'/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg',
expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
timeout: 60000,
}),
);
expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450214.webp');
expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_001_2913450214_Main_poster.webp');
expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450214/' },
timeout: 60000,
}));
expect(mockHttpDownload.mock.calls[1]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
expect(toPosixPath(mockHttpDownload.mock.calls[1]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
expect(mockHttpDownload.mock.calls[1]?.[2]).toEqual(expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
timeout: 60000,
}));

expect(result).toEqual([
{
Expand Down Expand Up @@ -164,14 +165,13 @@ describe('douban download', () => {
type: 'Rb',
targetPhotoId: '2913450215',
});
expect(mockHttpDownload).toHaveBeenCalledWith(
'https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg',
'/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg',
expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
timeout: 60000,
}),
);
expect(mockHttpDownload).toHaveBeenCalledTimes(1);
expect(mockHttpDownload.mock.calls[0]?.[0]).toBe('https://img1.doubanio.com/view/photo/l/public/p2913450215.jpg');
expect(toPosixPath(mockHttpDownload.mock.calls[0]?.[1])).toBe('/tmp/douban-test/30382501/30382501_002_2913450215_Character_poster.jpg');
expect(mockHttpDownload.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
headers: { Referer: 'https://movie.douban.com/photos/photo/2913450215/' },
timeout: 60000,
}));

expect(result).toEqual([
{
Expand Down
232 changes: 232 additions & 0 deletions src/clis/imdb/person.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { CommandExecutionError } from '../../errors.js';
import { cli, Strategy } from '../../registry.js';
import {
forceEnglishUrl,
getCurrentImdbId,
isChallengePage,
normalizeImdbId,
waitForImdbPath,
} from './utils.js';

/**
* Read IMDb person details from public profile pages.
*/
cli({
site: 'imdb',
name: 'person',
description: 'Get actor or director info',
domain: 'www.imdb.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'id', positional: true, required: true, help: 'IMDb person ID (nm0634240) or URL' },
{ name: 'limit', type: 'int', default: 10, help: 'Max filmography entries' },
],
columns: ['field', 'value'],
func: async (page, args) => {
const id = normalizeImdbId(String(args.id), 'nm');
// Clamp to 30 to match the internal evaluate cap
const limit = Math.max(1, Math.min(Number(args.limit) || 10, 30));
const url = forceEnglishUrl(`https://www.imdb.com/name/${id}/`);

await page.goto(url);
const onPersonPage = await waitForImdbPath(page, `^/name/${id}/`);

if (await isChallengePage(page)) {
throw new CommandExecutionError(
'IMDb blocked this request',
'Try again with a normal browser session or extension mode',
);
}
if (!onPersonPage) {
throw new CommandExecutionError(
`Person page did not finish loading: ${id}`,
'Retry the command; if it persists, IMDb may have changed their navigation flow',
);
}

const currentId = await getCurrentImdbId(page, 'nm');
if (currentId && currentId !== id) {
throw new CommandExecutionError(
`IMDb redirected to a different person: ${currentId}`,
'Retry the command; if it persists, the person page may have changed',
);
}

const data = await page.evaluate(`
(function() {
var result = {
nameId: '',
name: '',
description: '',
birthDate: '',
filmography: []
};

var scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (var i = 0; i < scripts.length; i++) {
try {
var ld = JSON.parse(scripts[i].textContent || 'null');
if (ld && ld['@type'] === 'Person') {
if (typeof ld.url === 'string') {
var ldMatch = ld.url.match(/(nm\\d{7,8})/);
if (ldMatch) {
result.nameId = ldMatch[1];
}
}
result.name = result.name || ld.name || '';
result.description = result.description || ld.description || '';
break;
}
} catch (error) {
void error;
}
}

var nextDataEl = document.getElementById('__NEXT_DATA__');
if (!nextDataEl) {
return result;
}

try {
var nextData = JSON.parse(nextDataEl.textContent || 'null');
var pageProps = nextData && nextData.props && nextData.props.pageProps;
var above = pageProps && (pageProps.aboveTheFold || pageProps.aboveTheFoldData);
var main = pageProps && (pageProps.mainColumnData || pageProps.belowTheFold);

if (above) {
if (!result.nameId && above.id) {
result.nameId = String(above.id);
}
if (!result.name && above.nameText && above.nameText.text) {
result.name = above.nameText.text;
}

if (above.birthDate) {
if (above.birthDate.displayableProperty && above.birthDate.displayableProperty.value) {
result.birthDate = above.birthDate.displayableProperty.value.plainText || '';
}
if (!result.birthDate && above.birthDate.dateComponents) {
var dc = above.birthDate.dateComponents;
result.birthDate = [dc.year, dc.month, dc.day].filter(Boolean).join('-');
}
}

if (above.bio && above.bio.text && above.bio.text.plainText) {
result.description = above.bio.text.plainText.substring(0, 300);
}
}

var pushFilmography = function(title, year, role) {
if (!title) {
return;
}
result.filmography.push({
title: title,
year: year || '',
role: role || ''
});
};

var knownFor = main && main.knownForFeatureV2;
if (knownFor && Array.isArray(knownFor.credits)) {
for (var j = 0; j < knownFor.credits.length; j++) {
var knownNode = knownFor.credits[j];
if (!knownNode || !knownNode.title) {
continue;
}
var knownRole = '';
var knownRoleEdge = knownNode.creditedRoles && Array.isArray(knownNode.creditedRoles.edges)
? knownNode.creditedRoles.edges[0]
: null;
if (knownRoleEdge && knownRoleEdge.node) {
knownRole = knownRoleEdge.node.text
|| (knownRoleEdge.node.category ? knownRoleEdge.node.category.text || '' : '');
}
pushFilmography(
knownNode.title.titleText ? knownNode.title.titleText.text : '',
knownNode.title.releaseYear ? String(knownNode.title.releaseYear.year || '') : '',
knownRole
);
}
}

if (result.filmography.length === 0) {
var creditSources = [];
if (main && main.released && Array.isArray(main.released.edges)) {
creditSources.push(main.released.edges);
}
if (main && main.groupings && Array.isArray(main.groupings.edges)) {
creditSources.push(main.groupings.edges);
}

for (var k = 0; k < creditSources.length && result.filmography.length < 30; k++) {
var groups = creditSources[k];
for (var m = 0; m < groups.length && result.filmography.length < 30; m++) {
var groupNode = groups[m] && groups[m].node;
if (!groupNode) {
continue;
}

var roleName = groupNode.grouping ? groupNode.grouping.text || '' : '';
var credits = groupNode.credits && Array.isArray(groupNode.credits.edges)
? groupNode.credits.edges
: [];
for (var n = 0; n < credits.length && result.filmography.length < 30; n++) {
var creditNode = credits[n] && credits[n].node;
if (!creditNode || !creditNode.title) {
continue;
}
pushFilmography(
creditNode.title.titleText ? creditNode.title.titleText.text : (creditNode.title.originalTitleText ? creditNode.title.originalTitleText.text : ''),
creditNode.title.releaseYear ? String(creditNode.title.releaseYear.year || '') : '',
roleName
);
}
}
}
}
} catch (error) {
void error;
}

return result;
})()
`);

if (!data || typeof data !== 'object' || !('name' in data) || !(data as Record<string, unknown>).name) {
throw new CommandExecutionError(`Person not found: ${id}`, 'Check the person ID and try again');
}

const result = data as Record<string, any>;
if (result.nameId && result.nameId !== id) {
throw new CommandExecutionError(
`IMDb returned a different person payload: ${result.nameId}`,
'Retry the command; if it persists, the person parser may need updating',
);
}
const filmography = Array.isArray(result.filmography) ? result.filmography : [];

// Override url with a clean canonical URL (no query params like ?language=en-US)
result.url = `https://www.imdb.com/name/${id}/`;

const rows = Object.entries(result)
.filter(([field, value]) => field !== 'filmography' && field !== 'nameId' && value !== '' && value != null)
.map(([field, value]) => ({ field, value: String(value) }));

if (filmography.length > 0) {
rows.push({ field: 'filmography', value: '' });
for (const entry of filmography.slice(0, limit)) {
const suffix = [entry.year ? `(${entry.year})` : '', entry.role ? `[${entry.role}]` : '']
.filter(Boolean)
.join(' ');
rows.push({
field: String(entry.title || ''),
value: suffix,
});
}
}

return rows;
},
});
Loading
Loading