buildPlan.mjs

import { METHOD_AUTO, METHOD_CURL, METHOD_PW_HEADLESS, METHOD_PW_HEADED, METHOD_CAMOFOX, METHOD_ADAPTER } from './constants.mjs'
import { requiresHeaded, requiresCamofox, requiresJsRedirect, requiresHeadless } from './routeByUrl.mjs'


//各階之靜態描述
//key為抓取器識別、method為結果之方法名稱、label供訊息顯示、
//inspect表該階是否做原始內容判識、redirectAware表該階是否受轉址旗標影響
//
//四階一律做判識。此前Camofox階為inspect:false, 其理由有二, 現皆不成立:
//
//  (二)「Camofox回傳者為accessibility snapshot合成之內容」——此理由已被contentKind機制取代。
//      合成內容以contentKind:'synthesized'告知判識器只比對semantic類判準(見inspectHtml),
//      該機制成立於本豁免之後, 豁免卻未隨之收斂
//  (一)「末階擋下亦無可升級, 只會放棄一次原本可能成功的解析」——與同檔之單階計畫不一致:
//      method:'curl'同樣是「唯一一階、擋下無可升級」, 卻照常判識並回reason:'captcha'。
//      同一條規則套在兄弟成員上結果相反, 屬對稱破缺
//
//實測後果: 四階皆取回同一份Cloudflare挑戰頁時, 前三階判captcha, camofox階把該挑戰頁樣板
//當文章回傳(status:'success', title:'Just a moment...'), 呼叫端無任何欄位可據以察覺。
//
//誤判風險已量測: 合成HTML之可見文字與位元組數約1:1.2(實測200段正文為8415B/6903字),
//故empty之「位元組>5000且可見文字<200」在此結構下不可能觸發; 而挑戰頁快照保有原標題,
//由標題前綴這類強證據判識器接住。
//
//「末階誤判不可回復」之顧慮成立, 但它與「末階漏判不可回復」對稱, 差別在於誤判會出聲
//(呼叫端收到reason:'captcha'可據以重試或改道), 漏判則是靜默把攔阻頁樣板當文章交出去。
//出聲的錯優於靜默的錯
//
//adapter階不在METHOD_OPTIONS內, 亦不可由opt.method指定: 它不是「另一種爬法」,
//而是「這次不爬」。是否插入取決於命中之adapter有沒有fetch掛點, 由呼叫端註冊決定
let STEPS = {
    adapter: Object.freeze({ key: 'adapter', method: METHOD_ADAPTER, label: 'adapter fetch', inspect: true, redirectAware: false }),
    curl: Object.freeze({ key: 'curl', method: METHOD_CURL, label: 'curl', inspect: true, redirectAware: false }),
    headless: Object.freeze({ key: 'headless', method: METHOD_PW_HEADLESS, label: 'Playwright headless', inspect: true, redirectAware: true }),
    headed: Object.freeze({ key: 'headed', method: METHOD_PW_HEADED, label: 'Playwright headed', inspect: true, redirectAware: true }),
    camofox: Object.freeze({ key: 'camofox', method: METHOD_CAMOFOX, label: 'Camofox', inspect: true, redirectAware: false }),
}


//對外method選項至階之對應
//注意'playwright'為公開選項名, 其結果之method為'playwright-headless', 兩者刻意不同名, 改動即為破壞性變更
let METHOD_OPTIONS = {
    'curl': 'curl',
    'playwright': 'headless',
    'playwright-headed': 'headed',
    'camofox': 'camofox',
}


//opt.method之完整合法值域
//
//'auto'不在METHOD_OPTIONS內: 它不對應單一階而是整條階梯, 在下方先行分流。
//但它仍是合法值(且為預設值), 故錯誤訊息須由本清單產生而非由METHOD_OPTIONS之鍵產生——
//此前訊息取自後者, 使'Auto'一類的大小寫錯誤收到「valid: curl, playwright, ...」,
//被告知的合法清單裡沒有他原本想用的那個值
let METHOD_VALID = [METHOD_AUTO, ...Object.keys(METHOD_OPTIONS)]


/**
 * 依網址與選項產生執行計畫
 *
 * 四條網域分流與指定方法,差別僅在於「要跑哪幾階」,故一律表達為step陣列,
 * 交由同一個runPlan執行,不再各自複製一份「抓取至判識至解析」流程。
 *
 * 本函數只回傳**純資料**:不捕捉url與opt、不建立closure、不修改任何共享狀態。
 * 轉址旗標之初值以redirect欄位回報,由runPlan擁有其後續變化
 *
 * @param {String} url 輸入待抓取網址字串
 * @param {String} method 輸入方法字串,'auto'代表自動階梯升級
 * @param {Boolean} doInspect 輸入是否做原始內容判識布林值,false時各階之inspect一律關閉
 * @param {Boolean} [hasFetchAdapter=false] 輸入命中之adapter是否具fetch掛點布林值,true時於計畫最前插入adapter階,預設false
 * @returns {Object} 回傳{plan,redirect,log}物件,plan為step描述陣列,redirect為轉址旗標初值,log為分流說明字串;method不合法時回傳{error}
 * @example
 *
 * import buildPlan from './src/buildPlan.mjs'
 *
 * console.log(buildPlan('https://example.com/', 'auto', true).plan.map((s) => s.key))
 * // => ['curl', 'headless', 'headed', 'camofox']
 *
 * console.log(buildPlan('https://mp.weixin.qq.com/s/a', 'auto', true).plan.map((s) => s.key))
 * // => ['camofox']
 *
 */
function buildPlan(url, method, doInspect, hasFetchAdapter = false) {

    let pick = (keys, log, redirect = false) => {

        //adapter階恆插在最前: 呼叫端手上若有更好的取得方式, 沒有理由先去爬一次。
        //它也插在指定method之計畫前——opt.method回答「要爬的時候用哪種爬法」,
        //adapter之fetch回答「這次要不要爬」, 兩者是不同問題故不需位階。
        //失敗後要不要續走其餘階由runPlan依adapter之fallback決定, 不在此表達:
        //計畫是純資料, 執行期的分支歸runPlan(與轉址旗標同一分工)
        let ks = hasFetchAdapter ? ['adapter', ...keys] : keys
        return {
            plan: ks.map((k) => (doInspect ? STEPS[k] : { ...STEPS[k], inspect: false })),
            redirect,
            log,
        }
    }

    //指定方法: 單一階, 不升級
    if (method !== METHOD_AUTO) {
        let k = METHOD_OPTIONS[method]
        if (!k) {
            return { error: `unknown method "${method}" (valid: ${METHOD_VALID.join(', ')})` }
        }
        return pick([k], '')
    }

    //判識D, 已知須Camofox之網站, 跳過方法①②③直接方法④
    if (requiresCamofox(url)) {
        return pick(['camofox'], 'camofox-required domain, jumping to camofox ...')
    }

    //判識C, 已知須headed模式之網站, 跳過curl與headless直接headed
    if (requiresHeaded(url)) {
        return pick(['headed', 'camofox'], 'headed-required domain, jumping to playwright-headed ...')
    }

    //判識A, 已知JS轉址域名, 跳過curl直接Playwright並等待轉址
    if (requiresJsRedirect(url)) {
        return pick(['headless', 'headed', 'camofox'], 'JS redirect domain, skipping curl ...', true)
    }

    //判識E, 已知SPA網站, 跳過curl直接Playwright無頭
    if (requiresHeadless(url)) {
        return pick(['headless', 'headed', 'camofox'], 'headless-required SPA domain, skipping curl ...')
    }

    //階梯式升級, curl至headless至headed至camofox
    return pick(['curl', 'headless', 'headed', 'camofox'], '')
}


export default buildPlan