mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
add url reader
This commit is contained in:
@@ -9,10 +9,19 @@ const rootDir = path.resolve(__dirname, '../../..');
|
|||||||
config({ path: path.resolve(rootDir, '.env') });
|
config({ path: path.resolve(rootDir, '.env') });
|
||||||
|
|
||||||
const port = Number(process.env.SERVER_PORT ?? process.env.PORT ?? 3000);
|
const port = Number(process.env.SERVER_PORT ?? process.env.PORT ?? 3000);
|
||||||
|
const urlReaderBaseUrl = (process.env.URL_READER_BASE_URL ?? 'https://r.jina.ai').replace(
|
||||||
|
/\/$/,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
const urlReaderApiKey = process.env.URL_READER_API_KEY ?? '';
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
port,
|
port,
|
||||||
apiBasePath: process.env.API_BASE_PATH ?? '/api',
|
apiBasePath: process.env.API_BASE_PATH ?? '/api',
|
||||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||||
appName: process.env.APP_NAME ?? 'BestChoice'
|
appName: process.env.APP_NAME ?? 'BestChoice',
|
||||||
|
urlReader: {
|
||||||
|
baseUrl: urlReaderBaseUrl,
|
||||||
|
apiKey: urlReaderApiKey
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
parseItemStatusFilter,
|
parseItemStatusFilter,
|
||||||
parseItemUpdatePayload
|
parseItemUpdatePayload
|
||||||
} from '../validation/items.js';
|
} from '../validation/items.js';
|
||||||
|
import { readUrlMarkdown } from '../services/url-reader-service.js';
|
||||||
|
|
||||||
export const listItems = async (req: Request, res: Response) => {
|
export const listItems = async (req: Request, res: Response) => {
|
||||||
const projectId = parseUuid(req.params.projectId, 'projectId');
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
@@ -151,19 +152,31 @@ export const importItemFromUrl = async (req: Request, res: Response) => {
|
|||||||
void projectId;
|
void projectId;
|
||||||
|
|
||||||
const { url } = parseItemImportPayload(req.body);
|
const { url } = parseItemImportPayload(req.body);
|
||||||
const loweredUrl = url.toLowerCase();
|
let hostname: string | null = null;
|
||||||
|
let lastPathSegment: string | null = null;
|
||||||
|
let normalizedUrl = url;
|
||||||
|
|
||||||
if (loweredUrl.includes('fail') || loweredUrl.includes('error')) {
|
try {
|
||||||
|
normalizedUrl = new URL(url).toString();
|
||||||
|
} catch {
|
||||||
|
// If the URL constructor fails here, readUrlMarkdown will surface the error.
|
||||||
|
}
|
||||||
|
|
||||||
|
let markdown: string;
|
||||||
|
try {
|
||||||
|
markdown = await readUrlMarkdown(normalizedUrl);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
422,
|
422,
|
||||||
'We could not read that URL. Please enter the item details manually.'
|
'We could not read that URL. Please enter the item details manually.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostname: string | null = null;
|
|
||||||
let lastPathSegment: string | null = null;
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(normalizedUrl);
|
||||||
hostname = parsed.hostname.replace(/^www\./, '');
|
hostname = parsed.hostname.replace(/^www\./, '');
|
||||||
const pathSegments = parsed.pathname.split('/').filter(Boolean);
|
const pathSegments = parsed.pathname.split('/').filter(Boolean);
|
||||||
if (pathSegments.length) {
|
if (pathSegments.length) {
|
||||||
@@ -190,13 +203,20 @@ export const importItemFromUrl = async (req: Request, res: Response) => {
|
|||||||
url: `https://picsum.photos/seed/${encodeURIComponent(seed)}/800/600`
|
url: `https://picsum.photos/seed/${encodeURIComponent(seed)}/800/600`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const importedAt = new Date().toISOString();
|
||||||
const attributes: Record<string, unknown> = {
|
const attributes: Record<string, unknown> = {
|
||||||
originUrl: url,
|
originUrl: normalizedUrl,
|
||||||
importedAt: new Date().toISOString(),
|
importedAt,
|
||||||
condition: 'New',
|
condition: 'New',
|
||||||
availability: 'In stock',
|
availability: 'In stock',
|
||||||
dpi: 1200,
|
dpi: 1200,
|
||||||
imageCount: images.length
|
imageCount: images.length,
|
||||||
|
urlReader: {
|
||||||
|
provider: 'jina.ai',
|
||||||
|
format: 'markdown',
|
||||||
|
fetchedAt: importedAt,
|
||||||
|
content: markdown
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hostname) {
|
if (hostname) {
|
||||||
@@ -209,6 +229,7 @@ export const importItemFromUrl = async (req: Request, res: Response) => {
|
|||||||
model,
|
model,
|
||||||
note: null,
|
note: null,
|
||||||
attributes,
|
attributes,
|
||||||
|
sourceUrl: normalizedUrl,
|
||||||
images
|
images
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
const ensureHttpUrl = (rawUrl: string): string => {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'Enter a valid absolute URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new HttpError(400, 'URL reader only supports http and https URLs.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildReaderUrl = (targetUrl: string): string => {
|
||||||
|
const base = env.urlReader.baseUrl || 'https://r.jina.ai';
|
||||||
|
return `${base}/${targetUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readUrlMarkdown = async (rawUrl: string): Promise<string> => {
|
||||||
|
if (typeof fetch !== 'function') {
|
||||||
|
throw new HttpError(500, 'URL reader requires fetch support but it is unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.urlReader.apiKey) {
|
||||||
|
throw new HttpError(500, 'URL reader service is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTargetUrl = ensureHttpUrl(rawUrl);
|
||||||
|
const requestUrl = buildReaderUrl(normalizedTargetUrl);
|
||||||
|
|
||||||
|
let response: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
try {
|
||||||
|
response = await fetch(requestUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.urlReader.apiKey}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
'We could not reach the URL reader service. Please try again later.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
'We could not read that URL. Please ensure it is publicly accessible.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpError(
|
||||||
|
502,
|
||||||
|
'The URL reader service returned an unexpected response. Please try again later.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await response.text();
|
||||||
|
if (!markdown.trim()) {
|
||||||
|
throw new HttpError(
|
||||||
|
422,
|
||||||
|
'The URL reader returned empty content for that URL. Please fill in the details manually.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user