mirror of
https://github.com/steel-dev/cli.git
synced 2026-09-14 20:47:34 +08:00
285 lines
7.0 KiB
TypeScript
285 lines
7.0 KiB
TypeScript
import {resolveApiBaseUrl, resolveExplicitApiUrl} from './browser/apiConfig.js';
|
|
import {resolveBrowserAuth} from './browser/auth.js';
|
|
import {BrowserAdapterError} from './browser/errors.js';
|
|
|
|
type UnknownRecord = Record<string, unknown>;
|
|
|
|
export type TopLevelApiMode = 'cloud' | 'local';
|
|
|
|
export type TopLevelApiRequestOptions = {
|
|
local?: boolean;
|
|
apiUrl?: string;
|
|
environment?: NodeJS.ProcessEnv;
|
|
};
|
|
|
|
const SCRAPE_FORMAT_VALUES = [
|
|
'html',
|
|
'readability',
|
|
'cleaned_html',
|
|
'markdown',
|
|
] as const;
|
|
|
|
const SCRAPE_FORMAT_SET = new Set<string>(SCRAPE_FORMAT_VALUES);
|
|
|
|
export type ScrapeFormat = (typeof SCRAPE_FORMAT_VALUES)[number];
|
|
type ScrapeContentKey = 'markdown' | 'cleaned_html' | 'html' | 'readability';
|
|
|
|
function hasExplicitNavigationProtocol(url: string): boolean {
|
|
const normalized = url.toLowerCase();
|
|
return (
|
|
normalized.includes('://') ||
|
|
normalized.startsWith('about:') ||
|
|
normalized.startsWith('data:') ||
|
|
normalized.startsWith('file:') ||
|
|
normalized.startsWith('blob:') ||
|
|
normalized.startsWith('javascript:')
|
|
);
|
|
}
|
|
|
|
function looksLikeHostWithoutProtocol(url: string): boolean {
|
|
const hostCandidate = url.split('/')[0] || '';
|
|
const normalizedHostCandidate = hostCandidate.toLowerCase();
|
|
|
|
return (
|
|
normalizedHostCandidate === 'localhost' ||
|
|
normalizedHostCandidate.startsWith('localhost:') ||
|
|
(normalizedHostCandidate.startsWith('[') &&
|
|
normalizedHostCandidate.includes(']')) ||
|
|
/^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?$/.test(normalizedHostCandidate) ||
|
|
hostCandidate.includes('.')
|
|
);
|
|
}
|
|
|
|
function normalizeUrlWithHttpsFallback(url: string): string {
|
|
const trimmedUrl = url.trim();
|
|
if (
|
|
!trimmedUrl ||
|
|
hasExplicitNavigationProtocol(trimmedUrl) ||
|
|
!looksLikeHostWithoutProtocol(trimmedUrl)
|
|
) {
|
|
return trimmedUrl;
|
|
}
|
|
|
|
const normalizedUrl = `https://${trimmedUrl}`;
|
|
try {
|
|
new URL(normalizedUrl);
|
|
return normalizedUrl;
|
|
} catch {
|
|
return trimmedUrl;
|
|
}
|
|
}
|
|
|
|
function resolveMode(
|
|
local: boolean | undefined,
|
|
apiUrl: string | null,
|
|
): TopLevelApiMode {
|
|
return local || apiUrl ? 'local' : 'cloud';
|
|
}
|
|
|
|
function extractApiErrorMessage(
|
|
responseData: unknown,
|
|
statusText: string,
|
|
): string {
|
|
if (typeof responseData === 'string' && responseData.trim()) {
|
|
return responseData;
|
|
}
|
|
|
|
if (responseData && typeof responseData === 'object') {
|
|
const payload = responseData as UnknownRecord;
|
|
if (typeof payload['message'] === 'string' && payload['message'].trim()) {
|
|
return payload['message'];
|
|
}
|
|
|
|
const nestedError = payload['error'];
|
|
if (nestedError && typeof nestedError === 'object') {
|
|
const nestedMessage = (nestedError as UnknownRecord)['message'];
|
|
if (typeof nestedMessage === 'string' && nestedMessage.trim()) {
|
|
return nestedMessage;
|
|
}
|
|
}
|
|
}
|
|
|
|
return statusText || 'Unknown API error';
|
|
}
|
|
|
|
export function parseScrapeFormatOption(
|
|
formatOption?: string,
|
|
): ScrapeFormat[] | undefined {
|
|
if (formatOption === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const formats = formatOption
|
|
.split(',')
|
|
.map(part => part.trim())
|
|
.filter(Boolean);
|
|
|
|
if (formats.length === 0) {
|
|
throw new BrowserAdapterError(
|
|
'INVALID_BROWSER_ARGS',
|
|
'Missing value for --format. Example: --format html,markdown',
|
|
);
|
|
}
|
|
|
|
const invalidFormats = formats.filter(value => !SCRAPE_FORMAT_SET.has(value));
|
|
if (invalidFormats.length > 0) {
|
|
throw new BrowserAdapterError(
|
|
'INVALID_BROWSER_ARGS',
|
|
`Invalid scrape format value(s): ${invalidFormats.join(', ')}. Supported values: ${SCRAPE_FORMAT_VALUES.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
return formats as ScrapeFormat[];
|
|
}
|
|
|
|
function pushUniqueContentKey(
|
|
keys: ScrapeContentKey[],
|
|
key: ScrapeContentKey,
|
|
): void {
|
|
if (!keys.includes(key)) {
|
|
keys.push(key);
|
|
}
|
|
}
|
|
|
|
export function getScrapeOutputText(
|
|
responseData: unknown,
|
|
preferredFormats: ScrapeFormat[] = ['markdown'],
|
|
): string | null {
|
|
if (!responseData || typeof responseData !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const content = (responseData as UnknownRecord)['content'];
|
|
if (!content || typeof content !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const contentRecord = content as UnknownRecord;
|
|
const orderedKeys: ScrapeContentKey[] = [];
|
|
|
|
for (const format of preferredFormats) {
|
|
pushUniqueContentKey(orderedKeys, format);
|
|
}
|
|
|
|
pushUniqueContentKey(orderedKeys, 'markdown');
|
|
pushUniqueContentKey(orderedKeys, 'cleaned_html');
|
|
pushUniqueContentKey(orderedKeys, 'html');
|
|
pushUniqueContentKey(orderedKeys, 'readability');
|
|
|
|
for (const key of orderedKeys) {
|
|
const value = contentRecord[key];
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value;
|
|
}
|
|
|
|
if (key === 'readability' && value && typeof value === 'object') {
|
|
return JSON.stringify(value, null, 2);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveTopLevelToolUrl(
|
|
urlFromOption?: string,
|
|
urlFromArg?: string,
|
|
): string {
|
|
const candidate = (urlFromOption || urlFromArg || '').trim();
|
|
if (!candidate) {
|
|
throw new BrowserAdapterError(
|
|
'INVALID_BROWSER_ARGS',
|
|
'Missing URL. Provide a target URL as the first argument or with --url.',
|
|
);
|
|
}
|
|
|
|
const normalizedCandidate = normalizeUrlWithHttpsFallback(candidate);
|
|
|
|
try {
|
|
new URL(normalizedCandidate);
|
|
} catch {
|
|
throw new BrowserAdapterError(
|
|
'INVALID_BROWSER_ARGS',
|
|
`Invalid URL: ${candidate}`,
|
|
);
|
|
}
|
|
|
|
return normalizedCandidate;
|
|
}
|
|
|
|
export function getHostedAssetUrl(responseData: unknown): string {
|
|
if (responseData && typeof responseData === 'object') {
|
|
const url = (responseData as UnknownRecord)['url'];
|
|
if (typeof url === 'string' && url.trim()) {
|
|
return url.trim();
|
|
}
|
|
}
|
|
|
|
throw new BrowserAdapterError(
|
|
'API_ERROR',
|
|
'Steel API response did not include a URL.',
|
|
);
|
|
}
|
|
|
|
export async function requestTopLevelApi<TResponse>(
|
|
endpointPath: '/scrape' | '/screenshot' | '/pdf',
|
|
body: UnknownRecord,
|
|
options: TopLevelApiRequestOptions = {},
|
|
): Promise<TResponse> {
|
|
const environment = options.environment || process.env;
|
|
const explicitApiUrl = resolveExplicitApiUrl(options.apiUrl);
|
|
const mode = resolveMode(options.local, explicitApiUrl);
|
|
const auth = resolveBrowserAuth(environment);
|
|
|
|
if (mode === 'cloud' && !auth.apiKey) {
|
|
throw new BrowserAdapterError(
|
|
'MISSING_AUTH',
|
|
'Missing Steel API key. Run `steel login` or set `STEEL_API_KEY`.',
|
|
);
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (auth.apiKey) {
|
|
headers['Steel-Api-Key'] = auth.apiKey;
|
|
}
|
|
|
|
const apiBaseUrl = await resolveApiBaseUrl(mode, environment, explicitApiUrl);
|
|
let response: Response;
|
|
|
|
try {
|
|
response = await fetch(`${apiBaseUrl}${endpointPath}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (error) {
|
|
throw new BrowserAdapterError(
|
|
'API_ERROR',
|
|
`Failed to reach Steel API at ${apiBaseUrl}.`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
let responseData: unknown = null;
|
|
|
|
if (responseText.trim()) {
|
|
try {
|
|
responseData = JSON.parse(responseText);
|
|
} catch {
|
|
responseData = responseText;
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new BrowserAdapterError(
|
|
'API_ERROR',
|
|
`Steel API request failed (${response.status}): ${extractApiErrorMessage(responseData, response.statusText)}`,
|
|
);
|
|
}
|
|
|
|
return responseData as TResponse;
|
|
}
|