Files
jackwener__opencli/clis/amazon/discussion.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

132 lines
4.7 KiB
TypeScript

import { CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import {
buildDiscussionUrl,
buildProvenance,
cleanText,
extractAsin,
normalizeProductUrl,
parseRatingValue,
parseReviewCount,
trimRatingPrefix,
uniqueNonEmpty,
assertUsableState,
gotoAndReadState,
} from './shared.js';
interface DiscussionPayload {
href?: string;
title?: string;
average_rating_text?: string | null;
total_review_count_text?: string | null;
qa_links?: string[];
review_samples?: Array<{
title?: string | null;
rating_text?: string | null;
author?: string | null;
date_text?: string | null;
body?: string | null;
verified?: boolean;
}>;
}
function normalizeDiscussionPayload(payload: DiscussionPayload): Record<string, unknown> {
const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
const asin = extractAsin(payload.href ?? '') ?? null;
const averageRatingText = cleanText(payload.average_rating_text) || null;
const totalReviewCountText = cleanText(payload.total_review_count_text) || null;
const provenance = buildProvenance(sourceUrl);
return {
asin,
product_url: asin ? normalizeProductUrl(asin) : null,
discussion_url: sourceUrl,
...provenance,
average_rating_text: averageRatingText,
average_rating_value: parseRatingValue(averageRatingText),
total_review_count_text: totalReviewCountText,
total_review_count: parseReviewCount(totalReviewCountText),
qa_urls: uniqueNonEmpty(payload.qa_links ?? []),
review_samples: (payload.review_samples ?? []).map((sample) => ({
title: trimRatingPrefix(sample.title) || null,
rating_text: cleanText(sample.rating_text) || null,
rating_value: parseRatingValue(sample.rating_text),
author: cleanText(sample.author) || null,
date_text: cleanText(sample.date_text) || null,
body: cleanText(sample.body) || null,
verified_purchase: sample.verified === true,
})),
};
}
async function readDiscussionPayload(page: IPage, input: string, limit: number): Promise<DiscussionPayload> {
const url = buildDiscussionUrl(input);
const state = await gotoAndReadState(page, url, 2500, 'discussion');
assertUsableState(state, 'discussion');
return await page.evaluate(`
(() => ({
href: window.location.href,
title: document.title || '',
average_rating_text: document.querySelector('[data-hook="rating-out-of-text"]')?.textContent || '',
total_review_count_text: document.querySelector('[data-hook="total-review-count"]')?.textContent || '',
qa_links: Array.from(document.querySelectorAll('a[href*="ask/questions"]')).map((anchor) => anchor.href || ''),
review_samples: Array.from(document.querySelectorAll('[data-hook="review"]')).slice(0, ${limit}).map((card) => ({
title: card.querySelector('[data-hook="review-title"]')?.textContent || '',
rating_text:
card.querySelector('[data-hook="review-star-rating"]')?.textContent
|| card.querySelector('[data-hook="cmps-review-star-rating"]')?.textContent
|| '',
author: card.querySelector('.a-profile-name')?.textContent || '',
date_text: card.querySelector('[data-hook="review-date"]')?.textContent || '',
body: card.querySelector('[data-hook="review-body"]')?.textContent || '',
verified: !!card.querySelector('[data-hook="avp-badge"]'),
})),
}))()
`) as DiscussionPayload;
}
cli({
site: 'amazon',
name: 'discussion',
description: 'Amazon review summary and sample customer discussion from product review pages',
domain: 'amazon.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
args: [
{
name: 'input',
required: true,
positional: true,
help: 'ASIN or product URL, for example B0FJS72893',
},
{
name: 'limit',
type: 'int',
default: 10,
help: 'Maximum number of review samples to return (default 10)',
},
],
columns: ['asin', 'average_rating_value', 'total_review_count'],
func: async (page, kwargs) => {
const input = String(kwargs.input ?? '');
const limit = Math.max(1, Number(kwargs.limit) || 10);
const payload = await readDiscussionPayload(page, input, limit);
const normalized = normalizeDiscussionPayload(payload);
if (!normalized.average_rating_text && !normalized.total_review_count_text) {
throw new CommandExecutionError(
'amazon discussion page did not expose review summary',
'The review page may have changed or hit a robot check. Open the review page in Chrome and retry.',
);
}
return [normalized];
},
});
export const __test__ = {
normalizeDiscussionPayload,
};