UNPKG

website-scrap-engine

Version:
376 lines (348 loc) 12 kB
import path from 'node:path'; import type {WriteStream} from 'node:fs'; import {constants, createWriteStream, promises as fs} from 'node:fs'; import {pipeline} from 'node:stream'; import {promisify} from 'node:util'; import type {RequestError, Response} from 'got'; import got, {HTTPError} from 'got'; import type {Resource} from '../resource.js'; import {ResourceType} from '../resource.js'; import type { AsyncResult, DownloadResource, DownloadResourceFunc, RequestOptions } from './types.js'; import {mkdirRetry} from '../io.js'; import {error as errorLogger, retry as retryLogger} from '../logger/logger.js'; import type {StaticDownloadOptions} from '../options.js'; import type {PipelineExecutor} from './pipeline-executor.js'; import {isUrlHttp} from '../util.js'; const promisifyPipeline = promisify(pipeline); export function isBytesAccepted(acceptRange?: string): boolean { if (!acceptRange) { return false; } const ranges = acceptRange.split(','); for (let i = 0; i < ranges.length; i++) { if (ranges[i] === 'bytes') { return true; } } return false; } export function isSameRangeStart(rangeStart: number, contentRange?: string): boolean { if (!contentRange) { return false; } let ranges = contentRange.split(','); ranges = ranges[0].split(' '); if (ranges.length < 2 || !ranges[1]) { return false; } ranges = ranges[1].split('-'); return +ranges[0] === rangeStart; } export async function streamingDownloadToFile( res: Resource & { downloadStartTimestamp: number }, requestOptions: RequestOptions ): Promise<Response | void> { const savePath = path.join(res.localRoot, decodeURI(res.savePath)); try { await fs.access(savePath, constants.W_OK); } catch (e) { // force cast for typescript 4.4 if (e && (e as {code?: string | void}).code === 'ENOENT') { await mkdirRetry(path.dirname(savePath)); } else { throw e; } } // force cast for typescript const options = Object.assign({}, requestOptions, { isStream: true }) as RequestOptions & { isStream?: true }; let fileWriteStream: WriteStream | void; return new Promise<Response>((resolve, reject) => { let rangeIsSupported: void | boolean; let rangeStart: void | number; const makeRequest = (retryCount: number): void => { let isRetry = false; if (!rangeIsSupported && options.headers) { rangeStart = undefined; delete options.headers.range; } else if (rangeStart && rangeIsSupported) { if (!options.headers) { options.headers = {}; } options.headers.range = `bytes=${rangeStart}-`; fileWriteStream = createWriteStream(savePath, { flags: 'a', start: rangeStart }); } const request = got.stream(res.downloadLink, options); request.retryCount = retryCount; request.once('response', async (response: Response) => { response.retryCount = retryCount; res.meta.headers = response.headers; if (rangeIsSupported === undefined) { if (isBytesAccepted(response.headers['accept-ranges'])) { rangeIsSupported = true; } } if (rangeIsSupported && fileWriteStream && rangeStart && (response.statusCode !== 206 || !isSameRangeStart(rangeStart, response.headers['content-range']))) { errorLogger.warn('Unexpected response for range', rangeStart, response.headers['content-range'], response.statusCode); rangeIsSupported = false; fileWriteStream.destroy(); fileWriteStream = undefined; rangeStart = undefined; } if (response.request.isAborted) { // Canceled while downloading //- will throw a `CancelError` or `TimeoutError` error return; } // Download body if (!fileWriteStream) { fileWriteStream = createWriteStream(savePath, { flags: 'w' }); } try { // Download body directly to file await promisifyPipeline(request, fileWriteStream); } catch { // The same error is caught below. // See request.once('error') return; } // if (request._isAboutToError) { // return; // } resolve(response); }); const destroyStream = () => { if (fileWriteStream) { if (rangeIsSupported) { if (rangeStart) { rangeStart += fileWriteStream.bytesWritten; } else { rangeStart = fileWriteStream.bytesWritten; } } else { rangeStart = undefined; } fileWriteStream.destroy(); } else { rangeStart = undefined; } fileWriteStream = undefined; }; const onError = (error: RequestError) => { // https://developer.mozilla.org/docs/Web/HTTP/Headers/Range // https://developer.mozilla.org/docs/Web/HTTP/Status/416 if (error instanceof HTTPError && error.response.statusCode === 416) { errorLogger.warn('Unexpected response for range', rangeStart, error.response.headers['content-range'], error.response.statusCode); rangeIsSupported = false; } destroyStream(); const {options} = request; if (error instanceof HTTPError && !options.throwHttpErrors) { const {response} = error; resolve(response); return; } if (!isRetry) { let retry = -1; if (error && error.message === 'premature close') { retryLogger.warn(retryCount, res.downloadLink, 'manually retry on premature close', // eslint-disable-next-line @typescript-eslint/no-explicit-any error.name, error.code, (error as any).event, error.message); retry = retryCount * 200; } // these events might be accidentally unhandled // eslint-disable-next-line @typescript-eslint/no-explicit-any if (error && !(error as any).retryLimitExceeded && (error.name === 'RequestError' || error.name === 'TimeoutError') && // RequestError: Cannot read property 'request' of undefined // at Object.exports.default (got\dist\source\core\utils\timed-out.js:56:23) // error.code === undefined (error.code === 'ETIMEDOUT' || error.code === undefined)) { retryLogger.warn(retryCount, res.downloadLink, // eslint-disable-next-line @typescript-eslint/no-explicit-any `manually retry on ${(error as any).event} timeout`, error.name, error.code, error.message); retry = retryCount * 300; } if (retry > 0) { makeRequest(retryCount + 1); return; } } reject(error); }; request.once('error', onError); request.once('retry', (newRetryCount: number) => { destroyStream(); isRetry = true; makeRequest(newRetryCount); }); }; makeRequest(0); }); } export async function optionallySetLastModifiedTime( res: Resource, options: StaticDownloadOptions ): Promise<void> { // https://github.com/website-local/website-scrap-engine/issues/174 let mtime: number | void = void 0; if (options.preferRemoteLastModifiedTime && res.meta?.headers?.['last-modified']) { mtime = Date.parse(res.meta.headers?.['last-modified']); } // void and NaN check if (mtime) { const savePath = path.join(res.localRoot, decodeURI(res.savePath)); try { await fs.utimes(savePath, mtime, mtime); } catch (e) { errorLogger.warn('skipping utimes ' + savePath, e); } } } export async function downloadStreamingResource( res: Resource, requestOptions: RequestOptions, options: StaticDownloadOptions ): Promise<Resource | DownloadResource | void> { if (res.body) { return res as DownloadResource; } if (res.type !== ResourceType.StreamingBinary) { return res; } if (!res.downloadStartTimestamp) { res.downloadStartTimestamp = Date.now(); res.waitTime = res.downloadStartTimestamp - res.createTimestamp; } await streamingDownloadToFile( res as (Resource & { downloadStartTimestamp: number }), requestOptions); await optionallySetLastModifiedTime(res, options); /// Not needed before // res.finishTimestamp = Date.now(); // res.downloadTime = // res.finishTimestamp - res.downloadStartTimestamp; return; } export interface StreamingBeforeDownloadHook { /** * @see PipelineExecutor.download * @see downloadStreamingResource * @see downloadStreamingResourceWithHook * @param res target resource * @param requestOptions passed to got * @param options * @param pipeline * @return processed resource, or void to discard resource */ (res: Resource, requestOptions: RequestOptions, options: StaticDownloadOptions, pipeline: PipelineExecutor): AsyncResult<DownloadResource | Resource | void>; } export interface StreamingAfterDownloadHook { /** * @see PipelineExecutor.download * @see downloadStreamingResource * @see downloadStreamingResourceWithHook * @param res target resource * @param requestOptions passed to got * @param options * @param pipeline */ (res: Resource, requestOptions: RequestOptions, options: StaticDownloadOptions, pipeline: PipelineExecutor): AsyncResult<void>; } export interface StreamingDownloadErrorHook { /** * @see PipelineExecutor.download * @see downloadStreamingResource * @see downloadStreamingResourceWithHook * @param e error * @param res target resource * @param requestOptions passed to got * @param options * @param pipeline */ (e: Error | unknown, res: Resource, requestOptions: RequestOptions, options: StaticDownloadOptions, pipeline: PipelineExecutor): AsyncResult<void>; } export function downloadStreamingResourceWithHook( beforeDownload?: StreamingBeforeDownloadHook, afterDownload?: StreamingAfterDownloadHook, downloadError?: StreamingDownloadErrorHook ): DownloadResourceFunc { if (!beforeDownload && !afterDownload && !downloadError) { return downloadStreamingResource; } return async ( res: Resource, requestOptions: RequestOptions, options: StaticDownloadOptions, pipeline: PipelineExecutor ) => { if (res.body) { return res as DownloadResource; } if (res.type !== ResourceType.StreamingBinary) { return res; } if (!isUrlHttp(res.downloadLink)) { return res; } let resource: DownloadResource | Resource | void = res; if (beforeDownload) { resource = await beforeDownload(res, requestOptions, options, pipeline); if (!resource) { return; } if (resource.body) { return resource as DownloadResource; } if (resource.shouldBeDiscardedFromDownload) { return; } } if (!res.downloadStartTimestamp) { res.downloadStartTimestamp = Date.now(); res.waitTime = res.downloadStartTimestamp - res.createTimestamp; } if (!downloadError) { await streamingDownloadToFile( res as (Resource & { downloadStartTimestamp: number }), requestOptions); await optionallySetLastModifiedTime(res, options); } else { try { await streamingDownloadToFile( res as (Resource & { downloadStartTimestamp: number }), requestOptions); await optionallySetLastModifiedTime(res, options); } catch (e) { await downloadError(e, res, requestOptions, options, pipeline); } } res.finishTimestamp = Date.now(); res.downloadTime = res.finishTimestamp - res.downloadStartTimestamp; if (afterDownload) { await afterDownload(res, requestOptions, options, pipeline); } return; }; }