Files
jina-ai--reader/src/services/curl.ts
T
wehub-resource-sync f1ba9c6c36
/ test (push) Failing after 1s
/ build-and-push-to-ghcr (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:23:39 +08:00

644 lines
28 KiB
TypeScript

import { AsyncService } from 'civkit/async-service';
import { singleton } from 'tsyringe';
import { Blob } from 'buffer';
import { Curl, CurlCode, CurlFeature, HeaderInfo, Browser, getChromeConfig } from '@nomagick/node-libcurl-impersonate';
import { parseString as parseSetCookieString } from 'set-cookie-parser';
import { ScrappingOptions } from './puppeteer';
import { GlobalLogger } from './logger';
import { AssertionFailureError } from 'civkit/civ-rpc';
import { FancyFile } from 'civkit/fancy-file';
import { ServiceBadAttemptError, ServiceBadApproachError, TargetFileTooLargeError } from './errors';
import { TempFileManager } from '../services/temp-file';
import _ from 'lodash';
import { PassThrough, Readable } from 'stream';
import { AsyncLocalContext } from './async-context';
import { BlackHoleDetector } from './blackhole-detector';
import { humanReadableDataSize } from 'civkit/readability';
export interface CURLScrappingOptions<T = any> extends ScrappingOptions<T> {
method?: string;
body?: string | Buffer;
maxSize?: number;
}
export const REDIRECTION_CODES = new Set([301, 302, 303, 307, 308]);
@singleton()
export class CurlControl extends AsyncService {
logger = this.globalLogger.child({ service: this.constructor.name });
chromeVersion: string = `136`;
safariVersion: string = `537.36`;
platform: string = `Linux`;
ua: string = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/${this.safariVersion} (KHTML, like Gecko) Chrome/${this.chromeVersion}.0.0.0 Safari/${this.safariVersion}`;
lifeCycleTrack = new WeakMap();
impersonateCfg?: ReturnType<typeof getChromeConfig>;
constructor(
protected globalLogger: GlobalLogger,
protected tempFileManager: TempFileManager,
protected asyncLocalContext: AsyncLocalContext,
protected blackHoleDetector: BlackHoleDetector,
) {
super(...arguments);
}
override async init() {
await this.dependencyReady();
if (process.platform === 'darwin') {
this.platform = `macOS`;
} else if (process.platform === 'win32') {
this.platform = `Windows`;
}
this.emit('ready');
}
impersonateChrome(ua: string) {
this.chromeVersion = ua.match(/Chrome\/(\d+)/)![1];
this.safariVersion = ua.match(/AppleWebKit\/([\d\.]+)/)![1];
this.ua = ua;
this.impersonateCfg = getChromeConfig({
version: this.chromeVersion,
fingerprint: {
ja3: '772,4865-4866-4867-49195-49199-49196-49200-52393-52392-49171-49172-156-157-47-53,23-0-45-17613-65281-18-43-65037-5-51-16-10-27-13-35-11,4588-29-23-24,0',
ja4: 't13d1514h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0401,0403,0501,0503,0601,0804,0805,0806',
akami: '1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p'
}
});
}
curlImpersonateHeader(curl: Curl, headers?: object) {
let uaPlatform = this.platform;
if (this.ua.includes('Windows')) {
uaPlatform = 'Windows';
} else if (this.ua.includes('Android')) {
uaPlatform = 'Android';
} else if (this.ua.includes('iPhone') || this.ua.includes('iPad') || this.ua.includes('iPod')) {
uaPlatform = 'iOS';
} else if (this.ua.includes('CrOS')) {
uaPlatform = 'Chrome OS';
} else if (this.ua.includes('Macintosh')) {
uaPlatform = 'macOS';
}
const mixinHeaders: Record<string, string> = {
'sec-ch-ua': `"Chromium";v="${this.chromeVersion}", "Google Chrome";v="${this.chromeVersion}", "Not.A/Brand";v="99"`,
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': `"${uaPlatform}"`,
'Upgrade-Insecure-Requests': '1',
'User-Agent': this.ua,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-User': '?1',
'Sec-Fetch-Dest': 'document',
// 'Accept-Encoding': 'gzip, deflate, br, zstd',
'Accept-Language': 'en-US,en;q=0.9',
'Priority': 'u=0, i'
};
const headersCopy: Record<string, string | undefined> = { ...headers };
for (const k of Object.keys(mixinHeaders)) {
const lowerK = k.toLowerCase();
if (headersCopy[lowerK]) {
mixinHeaders[k] = headersCopy[lowerK];
delete headersCopy[lowerK];
}
}
Object.assign(mixinHeaders, headersCopy);
curl.setOpt(Curl.option.HTTPHEADER, Object.entries(mixinHeaders).flatMap(([k, v]) => {
if (Array.isArray(v) && v.length) {
return v.map((v2) => `${k}: ${v2}`);
}
return [`${k}: ${v}`];
}));
return curl;
}
urlToStream(urlToCrawl: URL, crawlOpts?: CURLScrappingOptions) {
return new Promise<{
statusCode: number,
statusText?: string,
data?: Readable,
headers: HeaderInfo[],
}>((resolve, reject) => {
const actx = this.asyncLocalContext.ctx;
let contentType = '';
const curl = Curl.impersonate(this.impersonateCfg || Browser.Chrome);
curl.enable(CurlFeature.StreamResponse);
curl.setOpt('URL', urlToCrawl.toString());
curl.setOpt(Curl.option.FOLLOWLOCATION, false);
curl.setOpt(Curl.option.SSL_VERIFYPEER, false);
curl.setOpt(Curl.option.TIMEOUT_MS, crawlOpts?.timeoutMs || 30_000);
curl.setOpt(Curl.option.CONNECTTIMEOUT_MS, 3_000);
curl.setOpt(Curl.option.LOW_SPEED_LIMIT, 32768);
curl.setOpt(Curl.option.LOW_SPEED_TIME, 5_000);
if (crawlOpts?.method) {
curl.setOpt(Curl.option.CUSTOMREQUEST, crawlOpts.method.toUpperCase());
}
if (crawlOpts?.body) {
// @ts-ignore
curl.setOpt(Curl.option.POSTFIELDS, crawlOpts.body.toString());
}
const headersToSet = { ...crawlOpts?.extraHeaders };
if (crawlOpts?.cookies?.length) {
const cookieKv: Record<string, string> = {};
for (const cookie of crawlOpts.cookies) {
cookieKv[cookie.name] = cookie.value;
}
for (const cookie of crawlOpts.cookies) {
if (cookie.maxAge && cookie.maxAge < 0) {
delete cookieKv[cookie.name];
continue;
}
if (cookie.expires && cookie.expires < new Date()) {
delete cookieKv[cookie.name];
continue;
}
if (cookie.secure && urlToCrawl.protocol !== 'https:') {
delete cookieKv[cookie.name];
continue;
}
if (cookie.domain && !urlToCrawl.hostname.endsWith(cookie.domain)) {
delete cookieKv[cookie.name];
continue;
}
if (cookie.path && !urlToCrawl.pathname.startsWith(cookie.path)) {
delete cookieKv[cookie.name];
continue;
}
}
const cookieChunks = Object.entries(cookieKv).map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
headersToSet.cookie ??= cookieChunks.join('; ');
}
if (crawlOpts?.referer) {
headersToSet.referer ??= crawlOpts.referer;
}
if (crawlOpts?.overrideUserAgent) {
headersToSet['user-agent'] ??= crawlOpts.overrideUserAgent;
}
this.curlImpersonateHeader(curl, headersToSet);
if (crawlOpts?.proxyUrl) {
const proxyUrlCopy = new URL(crawlOpts.proxyUrl);
curl.setOpt(Curl.option.PROXY, proxyUrlCopy.href);
curl.setOpt(Curl.option.CONNECTTIMEOUT_MS, 6_000);
curl.setOpt(Curl.option.PROXY_CREDENTIAL_NO_REUSE, 1);
}
let curlStream: Readable | undefined;
let streamRecvCounter = 0;
curl.on('error', (err, errCode) => {
this.asyncLocalContext.bridge(actx, () => {
curl.close();
const err2 = this.digestCurlCode(errCode, err.message) ||
new AssertionFailureError(`Failed to access ${urlToCrawl.origin}: ${err.message}`);
err2.cause ??= err;
if (curlStream) {
// For some reason, manually emitting error event is required for curlStream.
if (errCode === CurlCode.CURLE_HTTP2_STREAM && streamRecvCounter) {
this.logger.warn(`Curl(${errCode}) ${urlToCrawl.origin}: ${err}, but some data was received, not emitting error to the stream.`, { err, urlToCrawl });
curlStream.push(null);
return;
}
curlStream.emit('error', err2);
curlStream.destroy(err2);
} else {
this.logger.warn(`Curl(${errCode}) ${urlToCrawl.origin}: ${err}`, { err, urlToCrawl });
}
reject(err2);
});
});
curl.setOpt(Curl.option.MAXFILESIZE, 4 * 1024 * 1024 * 1024); // 4GB
let status = -1;
let statusText: string | undefined;
let contentEncoding = '';
curl.once('end', () => {
if (curlStream) {
curlStream.once('end', () => curl.close());
return;
}
curl.close();
});
curl.on('stream', (stream, statusCode, headers) => {
status = statusCode;
for (const headerSet of (headers as HeaderInfo[])) {
for (const [k, v] of Object.entries(headerSet)) {
if (k.trim().endsWith(':')) {
Reflect.set(headerSet, k.slice(0, k.indexOf(':')), v || '');
Reflect.deleteProperty(headerSet, k);
continue;
}
if (v === undefined) {
Reflect.set(headerSet, k, '');
continue;
}
if (k.toLowerCase() === 'content-type' && typeof v === 'string') {
contentType = v.toLowerCase();
}
}
}
const lastResHeaders = headers[headers.length - 1];
statusText = (lastResHeaders as HeaderInfo).result?.reason;
for (const [k, v] of Object.entries(lastResHeaders)) {
const kl = k.toLowerCase();
if (kl === 'content-type') {
contentType = (v || '').toLowerCase();
}
if (kl === 'content-encoding') {
contentEncoding = (v || '').toLowerCase();
}
if (contentType && contentEncoding) {
break;
}
}
curlStream = stream;
stream.on('data', (buf) => {
if (buf.length > 0) {
streamRecvCounter += buf.length;
}
});
const passThrough = new PassThrough();
stream.pipe(passThrough);
stream.once('error', (err) => {
passThrough.destroy(err);
});
stream = passThrough;
stream.once('end', () => {
this.asyncLocalContext.bridge(actx, () => {
this.logger.debug(`CURL: [${statusCode}${statusText ? ` ${statusText}` : ''}] ${urlToCrawl.origin} ${humanReadableDataSize(streamRecvCounter)} ${contentType} ${contentEncoding}`, {
statusCode, statusText, traffic: streamRecvCounter, contentType, contentEncoding
});
});
});
if (
REDIRECTION_CODES.has(statusCode) &&
headers.some((h) => h.hasOwnProperty('Location') || h.hasOwnProperty('location'))
) {
stream.resume();
stream.once('end', () => {
resolve({
statusCode: status,
statusText,
data: undefined,
headers: headers as HeaderInfo[],
});
});
return;
}
resolve({
statusCode: status,
statusText,
data: stream,
headers: headers as HeaderInfo[],
});
});
curl.perform();
});
}
async urlToFile(urlToCrawl: URL, crawlOpts?: CURLScrappingOptions) {
let leftRedirection = 6;
let cookieRedirects = 0;
let opts = { ...crawlOpts };
let nextHopUrl = urlToCrawl;
const fakeHeaderInfos: HeaderInfo[] = [];
do {
const s = await this.urlToStream(nextHopUrl, opts);
const r = { ...s } as {
statusCode: number,
statusText?: string,
data?: FancyFile,
headers: HeaderInfo[],
};
const headers = r.headers[r.headers.length - 1];
const claimedSize = parseInt(headers['Content-Length'] || headers['content-length']);
if (crawlOpts?.maxSize && claimedSize > crawlOpts.maxSize) {
throw new TargetFileTooLargeError(`Target file is too large: ${humanReadableDataSize(claimedSize)} > ${humanReadableDataSize(crawlOpts.maxSize)}`);
}
if (r.data) {
const fpath = this.tempFileManager.alloc();
const fancyFile = FancyFile.auto(r.data, fpath);
this.tempFileManager.bindPathTo(fancyFile, fpath);
r.data = fancyFile;
}
if (REDIRECTION_CODES.has(r.statusCode)) {
fakeHeaderInfos.push(...r.headers);
const location: string | undefined = headers.Location || headers.location;
const setCookieHeader = headers['Set-Cookie'] || headers['set-cookie'];
if (setCookieHeader) {
const cookieAssignments = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
const parsed = cookieAssignments.filter(Boolean).map((x) => parseSetCookieString(x, { decodeValues: true }));
if (parsed.length) {
opts.cookies = [...(opts.cookies || []), ...parsed];
}
if (!location) {
cookieRedirects += 1;
}
}
if (!location && !setCookieHeader) {
let sizeAcc = 0;
s.data!.on('data', (chunk) => {
sizeAcc += chunk.length;
if (crawlOpts?.maxSize && sizeAcc > crawlOpts.maxSize) {
s.data!.destroy(new TargetFileTooLargeError(`Max size exceeded: ${humanReadableDataSize(sizeAcc)}`));
}
});
// Follow curl behavior
return {
statusCode: r.statusCode,
data: r.data,
headers: fakeHeaderInfos.concat(r.headers),
};
}
if (!location && cookieRedirects > 1) {
throw new ServiceBadApproachError(`Failed to access ${urlToCrawl}: Browser required to solve complex cookie preconditions.`);
}
nextHopUrl = new URL(location || '', nextHopUrl);
leftRedirection -= 1;
continue;
}
let sizeAcc = 0;
s.data!.on('data', (chunk) => {
sizeAcc += chunk.length;
if (crawlOpts?.maxSize && sizeAcc > crawlOpts.maxSize) {
s.data!.destroy(new TargetFileTooLargeError(`Max size exceeded: ${humanReadableDataSize(sizeAcc)}`));
}
});
return {
statusCode: r.statusCode,
statusText: r.statusText,
data: r.data,
headers: fakeHeaderInfos.concat(r.headers),
};
} while (leftRedirection > 0);
throw new ServiceBadAttemptError(`Failed to access ${urlToCrawl}: Too many redirections.`);
}
async sideLoad(targetUrl: URL, crawlOpts?: CURLScrappingOptions) {
const curlResult = await this.urlToFile(targetUrl, crawlOpts);
this.blackHoleDetector.itWorked();
let finalURL = targetUrl;
const sideLoadOpts: CURLScrappingOptions<FancyFile>['sideLoad'] = {
impersonate: {},
proxyOrigin: {},
};
for (const headers of curlResult.headers) {
const potentialLastResult = sideLoadOpts.impersonate[finalURL.href];
sideLoadOpts.impersonate[finalURL.href] = {
status: headers.result?.code || -1,
headers: _.mergeWith(potentialLastResult?.headers || {}, _.omit(headers, 'result'), (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return [...objValue, ...srcValue];
}
return undefined;
}),
contentType: headers['Content-Type'] || headers['content-type'],
};
if (crawlOpts?.proxyUrl) {
sideLoadOpts.proxyOrigin[finalURL.origin] = crawlOpts.proxyUrl;
}
if (headers.result?.code && [301, 302, 307, 308].includes(headers.result.code)) {
const location = headers.Location || headers.location;
if (location) {
finalURL = new URL(location, finalURL);
}
}
}
const lastHeaders = curlResult.headers[curlResult.headers.length - 1];
const contentType = (lastHeaders['Content-Type'] || lastHeaders['content-type'])?.toLowerCase() || (await curlResult.data?.mimeType) || 'application/octet-stream';
const contentDisposition = lastHeaders['Content-Disposition'] || lastHeaders['content-disposition'];
const fileName = contentDisposition?.match(/filename="([^"]+)"/i)?.[1] || finalURL.pathname.split('/').pop();
if (sideLoadOpts.impersonate[finalURL.href] && (await curlResult.data?.size)) {
sideLoadOpts.impersonate[finalURL.href].body = curlResult.data;
}
// This should keep the file from being garbage collected and deleted until this asyncContext/request is done.
this.lifeCycleTrack.set(this.asyncLocalContext.ctx, curlResult.data);
return {
finalURL,
sideLoadOpts,
chain: curlResult.headers,
status: curlResult.statusCode,
statusText: curlResult.statusText,
headers: lastHeaders,
contentType,
contentDisposition,
fileName,
file: curlResult.data
};
}
async urlToBlob(urlToCrawl: URL, crawlOpts?: CURLScrappingOptions) {
let leftRedirection = 6;
let cookieRedirects = 0;
let opts = { ...crawlOpts };
let nextHopUrl = urlToCrawl;
const fakeHeaderInfos: HeaderInfo[] = [];
do {
const s = await this.urlToStream(nextHopUrl, opts);
const r = { ...s } as {
statusCode: number,
statusText?: string,
data?: Blob,
headers: HeaderInfo[],
};
const headers = r.headers[r.headers.length - 1];
const claimedSize = parseInt(headers['Content-Length'] || headers['content-length']);
if (crawlOpts?.maxSize && claimedSize > crawlOpts.maxSize) {
throw new TargetFileTooLargeError(`Target file is too large: ${humanReadableDataSize(claimedSize)} > ${humanReadableDataSize(crawlOpts.maxSize)}`);
}
if (REDIRECTION_CODES.has(r.statusCode)) {
fakeHeaderInfos.push(...r.headers);
const location: string | undefined = headers.Location || headers.location;
const setCookieHeader = headers['Set-Cookie'] || headers['set-cookie'];
if (setCookieHeader) {
const cookieAssignments = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
const parsed = cookieAssignments.filter(Boolean).map((x) => parseSetCookieString(x, { decodeValues: true }));
if (parsed.length) {
opts.cookies = [...(opts.cookies || []), ...parsed];
}
if (!location) {
cookieRedirects += 1;
}
}
if (!location && !setCookieHeader) {
// Follow curl behavior
if (s.data) {
const chunks: Buffer[] = [];
let sizeAcc = 0;
s.data.on('data', (chunk) => {
chunks.push(chunk);
sizeAcc += chunk.length;
if (crawlOpts?.maxSize && sizeAcc > crawlOpts.maxSize) {
s.data!.destroy(new TargetFileTooLargeError(`Max size exceeded: ${humanReadableDataSize(sizeAcc)}`));
}
});
await new Promise((resolve, reject) => {
s.data!.once('end', resolve);
s.data!.once('error', reject);
});
r.data = new Blob(chunks, { type: headers['Content-Type'] || headers['content-type'] });
}
return {
statusCode: r.statusCode,
data: r.data,
headers: fakeHeaderInfos.concat(r.headers),
};
}
if (!location && cookieRedirects > 1) {
throw new ServiceBadApproachError(`Failed to access ${urlToCrawl}: Browser required to solve complex cookie preconditions.`);
}
nextHopUrl = new URL(location || '', nextHopUrl);
leftRedirection -= 1;
continue;
}
if (s.data) {
const chunks: Buffer[] = [];
let sizeAcc = 0;
s.data.on('data', (chunk) => {
chunks.push(chunk);
sizeAcc += chunk.length;
if (crawlOpts?.maxSize && sizeAcc > crawlOpts.maxSize) {
s.data!.destroy(new TargetFileTooLargeError(`Max size exceeded: ${humanReadableDataSize(sizeAcc)}`));
}
});
await new Promise((resolve, reject) => {
s.data!.once('end', resolve);
s.data!.once('error', reject);
});
r.data = new Blob(chunks, { type: headers['Content-Type'] || headers['content-type'] });
}
return {
statusCode: r.statusCode,
statusText: r.statusText,
data: r.data,
headers: fakeHeaderInfos.concat(r.headers),
};
} while (leftRedirection > 0);
throw new ServiceBadAttemptError(`Failed to access ${urlToCrawl}: Too many redirections.`);
}
async sideLoadBlob(targetUrl: URL, crawlOpts?: CURLScrappingOptions) {
const curlResult = await this.urlToBlob(targetUrl, crawlOpts);
this.blackHoleDetector.itWorked();
let finalURL = targetUrl;
const sideLoadOpts: CURLScrappingOptions<Blob>['sideLoad'] = {
impersonate: {},
proxyOrigin: {},
};
for (const headers of curlResult.headers) {
const potentialLastResult = sideLoadOpts.impersonate[finalURL.href];
sideLoadOpts.impersonate[finalURL.href] = {
status: headers.result?.code || -1,
headers: _.mergeWith(potentialLastResult?.headers || {}, _.omit(headers, 'result'), (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return [...objValue, ...srcValue];
}
return undefined;
}),
contentType: headers['Content-Type'] || headers['content-type'],
};
if (crawlOpts?.proxyUrl) {
sideLoadOpts.proxyOrigin[finalURL.origin] = crawlOpts.proxyUrl;
}
if (headers.result?.code && [301, 302, 307, 308].includes(headers.result.code)) {
const location = headers.Location || headers.location;
if (location) {
finalURL = new URL(location, finalURL);
}
}
}
const lastHeaders = curlResult.headers[curlResult.headers.length - 1];
const contentType = (lastHeaders['Content-Type'] || lastHeaders['content-type'])?.toLowerCase() || (curlResult.data?.type) || 'application/octet-stream';
const contentDisposition = lastHeaders['Content-Disposition'] || lastHeaders['content-disposition'];
const fileName = contentDisposition?.match(/filename="([^"]+)"/i)?.[1] || finalURL.pathname.split('/').pop();
if (sideLoadOpts.impersonate[finalURL.href] && (curlResult.data?.size)) {
sideLoadOpts.impersonate[finalURL.href].body = curlResult.data;
}
// This should keep the file from being garbage collected and deleted until this asyncContext/request is done.
this.lifeCycleTrack.set(this.asyncLocalContext.ctx, curlResult.data);
return {
finalURL,
sideLoadOpts,
chain: curlResult.headers,
status: curlResult.statusCode,
statusText: curlResult.statusText,
headers: lastHeaders,
contentType,
contentDisposition,
fileName,
file: curlResult.data
};
}
digestCurlCode(code: CurlCode, msg: string) {
switch (code) {
// 400 User errors
case CurlCode.CURLE_COULDNT_RESOLVE_HOST: {
return new AssertionFailureError(msg);
}
// Maybe retry but dont retry with curl again
case CurlCode.CURLE_OPERATION_TIMEDOUT:
case CurlCode.CURLE_UNSUPPORTED_PROTOCOL:
case CurlCode.CURLE_PEER_FAILED_VERIFICATION: {
return new ServiceBadApproachError(msg);
}
// Retryable errors
case CurlCode.CURLE_PROXY:
case CurlCode.CURLE_REMOTE_ACCESS_DENIED:
case CurlCode.CURLE_SEND_ERROR:
case CurlCode.CURLE_RECV_ERROR:
case CurlCode.CURLE_GOT_NOTHING:
case CurlCode.CURLE_SSL_CONNECT_ERROR:
case CurlCode.CURLE_QUIC_CONNECT_ERROR:
case CurlCode.CURLE_COULDNT_RESOLVE_PROXY:
case CurlCode.CURLE_COULDNT_CONNECT:
case CurlCode.CURLE_PARTIAL_FILE: {
return new ServiceBadAttemptError(msg);
}
default: {
return undefined;
}
}
}
}