fetchWeb.mjs

import isestr from 'wsemi/src/isestr.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import isValidUrl from './isValidUrl.mjs'
import inspectHtml from './inspectHtml.mjs'
import defaultAdapters from './defaultAdapters.mjs'
import buildPlan from './buildPlan.mjs'
import runPlan from './runPlan.mjs'
import findAdapter from './findAdapter.mjs'
import { finalize } from './finalizeResult.mjs'
import { extractRedirectTarget } from './routeByUrl.mjs'
import { MAX_REDIRECT_DEPTH, METHOD_AUTO } from './constants.mjs'
import { getOptStr, getOptBool, getOptArr, getOptP0Int } from './getOpt.mjs'


/**
 * 抓取網頁文章內容,支援四種抓取方法自動階梯升級
 *
 * 抓取方法:
 * 方法①curl(預設,繞過TLS指紋),委派fetchWebByCurl;
 * 方法②Playwright無頭(SPA動態渲染頁面),委派fetchWebByPlaywrightHeadless;
 * 方法③Playwright有頭(反自動化偵測),委派fetchWebByPlaywrightHead;
 * 方法④Camofox反偵測瀏覽器(Cloudflare等),委派fetchWebByCamofox
 *
 * 本函數僅負責階梯升級、內容判識、文章解析之調度,實際抓取由4個抓取函數執行,
 * 流程為fetch(委派)至inspectHtml(原始內容檢測)至Readability解析(可選);
 * 對已知網站另有轉址提取、跳過特定方法之判識規則,且方法④會額外回傳snapshot欄位
 *
 * **並行呼叫之限制**:方法④之Camofox server綁定固定埠(預設19377),同一埠號同時只能有一個抓取,
 * 詳見fetchWebByCamofox之說明。auto模式可能升級至該階,故同時發動多個fetchWeb時,
 * 若其中一個以上走到方法④即會互相破壞(先完成者殺掉server,其餘回'camofox-error')。
 * 需要並行時須為每個呼叫指定互不相同的opt.port;方法①②③則無此限制
 *
 * @param {String} url 輸入待抓取網址字串
 * @param {Object} [opt={}] 輸入設定物件,其餘鍵值會轉傳給實際執行抓取之函數,預設{}
 * @param {String} [opt.method='auto'] 輸入指定抓取方法字串,可為'auto'、'curl'、'playwright'、'playwright-headed'、'camofox','auto'代表自動階梯升級,預設'auto'。本選項塑形的是「要爬的時候用哪一種爬法」,不影響adapter之fetch掛點是否執行(後者回答的是「這次要不要爬」)
 * @param {Boolean} [opt.parse=true] 輸入是否以Readability解析出文章標題與內文布林值,false時直接回傳原始HTML,預設true
 * @param {Array} [opt.adapters=[]] 輸入站台adapter物件陣列,用於覆寫特定站台之取得、判識與解析方式,預設[]。形狀為{id,match,fetch,parse,inspect,fallback},三個掛點分屬管線的三個階段且各自獨立:fetch取代爬取(呼叫端有官方API、快取或已登入session時)、inspect為false表此adapter命中時不做原始內容判識、parse覆寫解析;fallback為false表此adapter階失敗時(fetch失敗、被判識擋下或解析未取得足量正文)不落回階梯,以該階之reason收攤。parse另收第四參數meta(含requestUrl、finalUrl、httpCode、method、contentKind),供其分辨內容實際來自哪裡;其完整契約(輸入形狀、輸出檢核、錯誤邊界)以src/adapterContract.mjs為唯一事實來源。使用端adapters排於內建adapters(gelonghui、bloomberg、msn)之前,同網域以先命中者勝出,故可逐站覆寫之
 * @param {Boolean} [opt.useDefaultAdapters=true] 輸入是否附加內建adapter清單布林值,預設true。false時只用opt.adapters所給者;配合對外匯出之defaultAdapters陣列,可自行剔除某一個(如adapters:defaultAdapters.filter((a)=>a.id!=='msn'))或重排
 * @param {Array} [opt.detectors=[]] 輸入使用端判識器物件陣列,用於補充內建判識器所不涵蓋之攔阻頁形態(如中文與其他語系之挑戰頁),排於內建之前故優先命中,預設[]。形狀為{type,message,test},其完整契約以src/detectorContract.mjs為唯一事實來源
 * @param {Boolean} [opt.inspect=true] 輸入是否以inspectHtml對抓取結果做原始內容判識布林值,預設true。關閉後不因判定為挑戰頁或空內容而升級。此為**整次呼叫**之總開關;若只想豁免特定站台,改以該站台adapter之inspect:false表達,範圍較精確且不必在呼叫端重寫一次網址判斷
 * @param {Object} [opt._fetchers=null] 輸入置換抓取函數之物件,僅供測試使用,鍵可為'curl'、'playwrightHeadless'、'playwrightHead'、'camofox',值為與對應fetchWebByXxx同簽章之函數,未給之鍵沿用實際實作,預設null
 * @param {Boolean} [opt.useShowLog=true] 輸入是否顯示階梯升級過程訊息布林值,預設true
 * @param {Integer} [opt.maxRetries=5] 輸入各抓取函數失敗時最大重試次數整數,含初始共執行maxRetries+1次,預設5
 * @returns {Promise} 回傳Promise,resolve回傳結果物件,其中attempts為各階嘗試紀錄陣列,成功之紀錄為{method,status:'success',htmlLength}(htmlLength為原始HTML長度,與頂層contentLength之正文長度不同),失敗為{method,status:'failed',reason,message},被判識或解析失敗為{method,status:'blocked',type,reason,message}(判識所致者reason與type同值);經adapter之fetch掛點者其紀錄之method為'adapter'且另帶adapterId,成功結果與於該階收攤之失敗結果其頂層亦帶adapterId(落回後階梯耗盡者則無);parse=true成功時為{status:'success',url,title,content,contentLength,method,fetchedAt,attempts},parse=false成功時為{status:'success',url,html,method,fetchedAt,attempts};內容來自轉址後之另一網址時另帶finalUrl(url為本套件最後實際發出請求之網址,finalUrl為內容實際來源,兩者相同時不輸出該欄),失敗時為{status:'error',url,message,fetchedAt,attempts},本函數不會reject
 * @example
 *
 * import fetchWeb from './src/fetchWeb.mjs'
 *
 * let test = async () => {
 *
 *     //auto模式, 解析出文章標題與內文
 *     let r = await fetchWeb('https://example.com/')
 *     console.log(r.status, r.method, r.title, r.contentLength)
 *     // => 'success' 'curl' 'Example Domain' 111
 *
 *     //指定curl且不解析, 直接取原始HTML
 *     let rh = await fetchWeb('https://example.com/', { method: 'curl', parse: false })
 *     console.log(rh.status, rh.html.length)
 *     // => 'success' 559
 *
 *     //失敗時回傳error結果物件, 不會reject
 *     let re = await fetchWeb('abc')
 *     console.log(re.status, re.message)
 *     // => 'error' 'invalid url (must be http/https)'
 *
 * }
 * await test()
 *     .catch((err) => {
 *         console.log(err)
 *     })
 *
 */
async function fetchWeb(url, opt = {}) {

    //check url
    //須於此攔截無效網址, 否則auto模式會空跑完4種抓取方法才逐一失敗
    if (!isestr(url)) {
        return finalize(String(url), { success: false, reason: 'invalid-url', message: 'url is required (string)' }, [])
    }
    if (!isValidUrl(url)) {
        return finalize(url, { success: false, reason: 'invalid-url', message: 'invalid url (must be http/https)' }, [])
    }

    //method
    let method = getOptStr(opt, 'method', METHOD_AUTO)

    //parse
    let parse = getOptBool(opt, 'parse', true)

    //useShowLog
    let useShowLog = getOptBool(opt, 'useShowLog', true)

    //inspect, 與parse對稱之開關
    //關閉後不做原始內容判識, 亦即不因判定為挑戰頁或空內容而升級;
    //供呼叫端在已註冊adapter、自知如何解析該站台時,避免內容被通用判識先行攔下
    let doInspect = getOptBool(opt, 'inspect', true)

    //adapters, 使用端註冊者排於內建之前, 故可覆寫內建同網域adapter(首個命中者勝出)
    //
    //useDefaultAdapters為false時不附加內建清單, 只用呼叫端所給者。
    //此前內建清單恆被附加, 呼叫端只能「逐站以同網域adapter蓋過」而無法停用或剔除某一個——
    //要讓某站改回走Readability, 只能註冊一個回status:'skip'的假fetch去搶先命中, 形同繞路。
    //配合對外匯出之defaultAdapters陣列, 呼叫端即可自行組合:
    //  整份停用          { useDefaultAdapters: false }
    //  剔除其中一個      { useDefaultAdapters: false, adapters: defaultAdapters.filter((a) => a.id !== 'msn') }
    //  自己的排前面      { useDefaultAdapters: false, adapters: [...mine, ...defaultAdapters] }
    let optAdapters = getOptArr(opt, 'adapters', [])
    let useDefaultAdapters = getOptBool(opt, 'useDefaultAdapters', true)
    let adapters = useDefaultAdapters ? [...optAdapters, ...defaultAdapters] : optAdapters

    //auto模式之轉址提取
    let depth = getOptP0Int(opt, '_depth', 0)

    //判識B, 網址參數中含真實網址, 提取後以目標網址重走完整流程
    if (method === METHOD_AUTO && depth < MAX_REDIRECT_DEPTH) {
        let target = extractRedirectTarget(url)
        if (target) {
            if (useShowLog) {
                console.log('[fetchWeb] redirect param extracted → ' + target)
            }
            return fetchWeb(target, { ...opt, _depth: depth + 1 })
        }
    }

    //adapter於執行計畫產生前解析一次, 供整次抓取共用
    //
    //挑選只取決於**網址**, 與抓回之HTML無關, 故整個計畫內恆為同一結果。
    //此前挑選寫在parseArticle內, 每成功抓取一階就重跑一次: 使用端之match可為async且可做I/O,
    //四階即最多四次; 且若match每次回傳不同結果(非決定性), 各階會用到不同adapter而不自知。
    //更嚴重的是match拋錯這條路——實測整條階梯4次網路抓取全部跑完, 每階都得到同一個錯誤。
    //
    //**閘門是「挑到之後諮詢哪些掛點」, 不是「要不要挑」**:
    //  fetch與inspect宣告   與opt.parse無關, 恆諮詢
    //  parse               僅opt.parse為true時諮詢(由runPlan依parse決定)
    //此前以opt.parse決定要不要挑adapter, 理由是「關閉解析的呼叫端會新收到一種用不到的失敗」;
    //該理由在adapter只有parse一個掛點時成立, 加上inspect與fetch掛點後前提即消失——
    //實測parse:false時match呼叫次數為0, 宣告的inspect:false完全不生效而白跑四階
    let hit = await findAdapter(url, adapters)

    //match拋錯不得靜默改走預設解析器: 呼叫端註冊adapter即代表選定該階段,
    //靜默替換等於讓其經歷未選擇的處理管線階段。
    //attempts為空陣列而非各階之blocked紀錄, 與invalid-url、invalid-method一致——
    //此類失敗在發出任何請求前即確定, 沒有任何抓取方法被嘗試過
    if (hit.type === 'error') {
        if (useShowLog) {
            console.warn('[fetchWeb] ' + hit.message + ' — aborted before fetching')
        }
        return finalize(url, { success: false, reason: 'adapter-error', message: hit.message }, [])
    }

    //命中之adapter具fetch掛點時, 計畫最前多一個adapter階
    let hasFetchAdapter = hit.type === 'hit' && isfun(hit.adapter.fetch)

    //產生執行計畫(純資料)後交由單一runner執行; 轉址旗標之後續變化由runner擁有
    let { plan, error, log, redirect } = buildPlan(url, method, doInspect, hasFetchAdapter)
    if (error) {
        return finalize(url, { success: false, reason: 'invalid-method', message: error }, [])
    }
    if (log && useShowLog) {
        console.log('[fetchWeb] ' + log)
    }

    return runPlan(url, opt, parse, useShowLog, hit, plan, redirect)
}


export { inspectHtml, defaultAdapters }
export default fetchWeb