Files
jackwener__opencli/clis/imdb/search.ts
jakevin 4fe9a73ebc refactor: migrate adapter imports to package exports (#795)
* refactor: migrate adapter imports to package exports

Replace all relative imports (../../src/registry.js, ../../browser/cdp.js, etc.)
with package exports (@jackwener/opencli/registry, @jackwener/opencli/errors, etc.)
across all 484 adapter files.

This decouples adapter import resolution from directory structure:
- User CLIs in ~/.opencli/clis/ resolve via node_modules symlink
- Internal adapters resolve via Node.js self-referencing
- No more shim files needed for import resolution

Changes:
- package.json: add sub-path exports for all public modules
- clis/**: replace relative imports with @jackwener/opencli/...
- discovery.ts: simplify ensureUserCliCompatShims to symlink-only
- registry-api.ts: export CommandArgs type
- Remove root-level shim directories (browser/, download/, pipeline/)
- Remove shim entries from tsconfig.json include and package.json files

* test: add regression tests for package exports

Prevents regressions like #788/#791 by:
1. Scanning all adapter files for forbidden relative imports
   (../../src/, ../../browser/, etc.) — fails if any remain
2. Verifying every package.json export maps to an existing source file

18 new test cases.

* fix: use junction on Windows + broaden test patterns

- discovery.ts: use 'junction' symlink type on Windows (no admin required)
- package-exports.test.ts: generalize forbidden patterns to catch any
  depth of ../ traversal (not just ../../ and ../../../)

* fix: update stale vi.mock/importActual paths in adapter tests

Test files still used old relative paths for vi.mock() and
vi.importActual() calls. Updated 5 test files to use package exports.
Also broadened regression test patterns to catch mock/importActual paths.

* fix: use rm instead of unlink for symlink cleanup, add warn on failure

Addresses review feedback from Astro-Han:
- rm() handles both symlinks and stale directories (unlink fails on dirs)
- Log a warning when symlink creation fails instead of silent catch

* docs: update import examples to use package exports

Update all documentation, contributing guides, and skills to use
@jackwener/opencli/registry instead of ../../src/registry.js.

Without this, users following the docs would write adapters with
broken imports since the old shim files are no longer created.
2026-04-05 16:02:17 +08:00

180 lines
7.0 KiB
TypeScript

import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
forceEnglishUrl,
isChallengePage,
normalizeImdbTitleType,
waitForImdbPath,
waitForImdbSearchReady,
} from './utils.js';
/**
* Search IMDb via the public search page and parse Next.js payload first.
*/
cli({
site: 'imdb',
name: 'search',
description: 'Search IMDb for movies, TV shows, and people',
domain: 'www.imdb.com',
strategy: Strategy.PUBLIC,
browser: true,
args: [
{ name: 'query', positional: true, required: true, help: 'Search query' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
],
columns: ['rank', 'id', 'title', 'year', 'type', 'url'],
func: async (page, args) => {
const query = String(args.query || '').trim();
// Reject empty or whitespace-only queries early
if (!query) {
throw new ArgumentError('Search query cannot be empty');
}
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 50));
const url = forceEnglishUrl(`https://www.imdb.com/find/?q=${encodeURIComponent(query)}&ref_=nv_sr_sm`);
await page.goto(url);
const onSearchPage = await waitForImdbPath(page, '^/find/?$');
const searchReady = await waitForImdbSearchReady(page, 15000);
if (await isChallengePage(page)) {
throw new CommandExecutionError(
'IMDb blocked this request',
'Try again with a normal browser session or extension mode',
);
}
if (!onSearchPage || !searchReady) {
throw new CommandExecutionError(
'IMDb search results did not finish loading',
'Retry the command; if it persists, the search page structure may have changed',
);
}
const results = await page.evaluate(`
(function() {
var results = [];
function pushResult(item) {
if (!item || !item.id || !item.title) {
return;
}
results.push(item);
}
var nextDataEl = document.getElementById('__NEXT_DATA__');
if (nextDataEl) {
try {
var nextData = JSON.parse(nextDataEl.textContent || 'null');
var pageProps = nextData && nextData.props && nextData.props.pageProps;
if (pageProps) {
// IMDb wraps results as {index: "tt...", listItem: {...}}
var titleResults = (pageProps.titleResults && pageProps.titleResults.results) || [];
for (var i = 0; i < titleResults.length; i++) {
var tr = titleResults[i] || {};
var tItem = tr.listItem || {};
var tId = tr.index || '';
var tTitle = typeof tItem.originalTitleText === 'string'
? tItem.originalTitleText
: (tItem.originalTitleText && tItem.originalTitleText.text) || '';
if (!tTitle) {
tTitle = typeof tItem.titleText === 'string'
? tItem.titleText
: (tItem.titleText && tItem.titleText.text) || '';
}
var tYear = '';
if (typeof tItem.releaseYear === 'number' || typeof tItem.releaseYear === 'string') {
tYear = String(tItem.releaseYear);
} else if (tItem.releaseYear && typeof tItem.releaseYear === 'object') {
tYear = String(tItem.releaseYear.year || '');
}
pushResult({
id: tId,
title: tTitle,
year: tYear,
type: tItem.titleType || (tItem.endYear != null ? 'tvSeries' : ''),
url: tId ? 'https://www.imdb.com/title/' + tId + '/' : ''
});
}
var nameResults = (pageProps.nameResults && pageProps.nameResults.results) || [];
for (var j = 0; j < nameResults.length; j++) {
var nr = nameResults[j] || {};
var nItem = nr.listItem || {};
var nId = nr.index || '';
var nTitle = typeof nItem.nameText === 'string'
? nItem.nameText
: (nItem.nameText && nItem.nameText.text) || '';
if (!nTitle) {
nTitle = typeof nItem.originalNameText === 'string'
? nItem.originalNameText
: (nItem.originalNameText && nItem.originalNameText.text) || '';
}
var nType = '';
if (typeof nItem.primaryProfession === 'string') {
nType = nItem.primaryProfession;
} else if (Array.isArray(nItem.primaryProfessions) && nItem.primaryProfessions.length > 0) {
nType = String(nItem.primaryProfessions[0] || '');
} else if (Array.isArray(nItem.professions) && nItem.professions.length > 0) {
nType = String(nItem.professions[0] || '');
}
pushResult({
id: nId,
title: nTitle,
year: nItem.knownFor && nItem.knownFor.yearRange ? String(nItem.knownFor.yearRange.year || '') : (nItem.knownForTitleYear ? String(nItem.knownForTitleYear) : ''),
type: nType || 'Person',
url: nId ? 'https://www.imdb.com/name/' + nId + '/' : ''
});
}
}
} catch (error) {
void error;
}
}
if (results.length === 0) {
var items = document.querySelectorAll('[class*="find-title-result"], [class*="find-name-result"], .ipc-metadata-list-summary-item');
for (var k = 0; k < items.length; k++) {
var el = items[k];
var linkEl = el.querySelector('a[href*="/title/"], a[href*="/name/"]');
if (!linkEl) {
continue;
}
var href = linkEl.getAttribute('href') || '';
var idMatch = href.match(/(tt|nm)\\d{7,8}/);
if (!idMatch) {
continue;
}
var titleEl = el.querySelector('.ipc-metadata-list-summary-item__t, h3, a');
var metaEls = el.querySelectorAll('.ipc-metadata-list-summary-item__li, span');
var absoluteUrl = href.startsWith('http') ? href : 'https://www.imdb.com' + href.split('?')[0];
pushResult({
id: idMatch[0],
title: titleEl ? (titleEl.textContent || '').trim() : '',
year: metaEls.length > 0 ? (metaEls[0].textContent || '').trim() : '',
type: metaEls.length > 1 ? (metaEls[1].textContent || '').trim() : '',
url: absoluteUrl
});
}
}
return results;
})()
`);
if (!Array.isArray(results)) {
return [];
}
return results.slice(0, limit).map((item: any, index: number) => ({
rank: index + 1,
id: item.id || '',
title: item.title || '',
year: item.year || '',
type: normalizeImdbTitleType(item.type),
url: item.url || '',
}));
},
});