import { chromium } from 'playwright'
import getUrlErrorResult from './getUrlErrorResult.mjs'
import { fetchedAtIso } from './fetchedAt.mjs'
import { DEFAULT_MAX_RETRIES } from './getRetryWaitMs.mjs'
import withRetry from './withRetry.mjs'
import navigateWithRedirectWait from './navigateWithRedirectWait.mjs'
import extractPageContent from './extractPageContent.mjs'
import { getOptPInt, getOptP0Int, getOptBool } from './getOpt.mjs'
import { getBrowserPageOptions } from './requestIdentity.mjs'
import { checkHttpStatus } from './httpStatus.mjs'
//預設值
let DEFAULT_NAV_TIMEOUT_MS = 15000
//單次嘗試: 開瀏覽器、導航、取內容, 並於回傳前必定關閉瀏覽器
//資源在本函數回傳前釋放, 呼叫端才可安全進入重試退避
async function _runOnce(url, opt, cfg, o) {
let browser = null
try {
browser = await chromium.launch(cfg.launch)
//套用呼叫端指定之HTTP請求身分
//未指定者不帶入, 交由瀏覽器用自己的真實身分——硬套寫死的UA會與其餘指紋不一致,
//對反偵測是負面的; 理由詳見requestIdentity.mjs
let page = await browser.newPage(getBrowserPageOptions(opt))
if (cfg.onPage) {
await cfg.onPage(page)
}
let resp = null
if (o.waitForRedirect) {
resp = await navigateWithRedirectWait(page, url, o.navTimeout)
}
else {
resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: o.navTimeout })
await page.waitForTimeout(o.postWait)
}
//HTTP狀態檢核, 判準與curl階共用同一模組
//此前page.goto()之Response被丟棄, 於是**只有curl階看得到HTTP狀態**:
//實測同一個回500與404的網址, curl回error/http-error, playwright回success並帶回錯誤頁HTML。
//curl被擋而升級至playwright時, 站台的404或403頁面遂被當成抓取成功的內容送進判識與解析
let httpCode = resp ? resp.status() : 0
let bad = checkHttpStatus(httpCode)
if (bad) {
return { ok: false, retryable: bad.retryable, reason: bad.reason, message: bad.message, httpCode }
}
let extra = cfg.afterNavigate ? await cfg.afterNavigate(page, opt) : {}
let { html, contentKind } = await extractPageContent(page)
//內容實際來源之網址: 轉址(含JS轉址)後page.url()即為它, 與輸入之url可能不同。
//navigateWithRedirectWait更是刻意等到host脫離原host, 故該路徑上兩者必然不同
return { ok: true, html, contentKind, extra, httpCode, finalUrl: page.url() }
}
catch (err) {
return { ok: false, message: err.message || String(err) }
}
finally {
if (browser) {
await browser.close().catch(() => {})
}
}
}
/**
* 以Playwright抓取網頁原始HTML之共用流程
*
* 無頭與有頭兩種模式僅差別於:方法名稱、導航後預設等待毫秒、瀏覽器啟動參數、
* 開頁後之前置處理、導航後之額外處理與其帶回之結果欄位。
* 其餘(網址檢核、選項解析、重試與線性退避、Shadow DOM穿透、瀏覽器關閉)完全相同,
* 故收斂於本函數,由fetchWebByPlaywrightHeadless與fetchWebByPlaywrightHead各自帶入設定
*
* @param {String} url 輸入待抓取網址字串
* @param {Object} opt 輸入設定物件,由呼叫端原樣轉入
* @param {Object} cfg 輸入模式設定物件
* @param {String} cfg.method 輸入方法名稱字串,寫入結果物件之method欄位
* @param {String} cfg.logName 輸入重試訊息之前綴名稱字串
* @param {Integer} cfg.defaultPostWaitMs 輸入導航後等待毫秒之預設值整數
* @param {Object} cfg.launch 輸入chromium.launch之參數物件
* @param {Function} [cfg.onPage=null] 輸入開頁後、導航前之前置處理async函數,簽章為(page),預設null
* @param {Function} [cfg.afterNavigate=null] 輸入導航後、取內容前之處理async函數,簽章為(page,opt),其回傳物件會併入成功結果,預設null
* @returns {Promise} 回傳Promise,resolve回傳結果物件,成功時為{status:'success',url,html,htmlLength,contentKind,httpCode,...cfg.afterNavigate之回傳,method,fetchedAt,attempts},失敗時為{status:'error',url,message,reason,httpCode,method,fetchedAt,attempts}(reason為'http-error'或'playwright-error'),本函數不會reject
*/
async function runPlaywright(url, opt, cfg) {
//fetchedAt
let fetchedAt = fetchedAtIso()
//check url
let rErr = getUrlErrorResult(url, cfg.method, fetchedAt)
if (rErr) {
return rErr
}
//navTimeout
let navTimeout = getOptPInt(opt, 'navigationTimeoutMs', DEFAULT_NAV_TIMEOUT_MS)
//postWait
let postWait = getOptP0Int(opt, 'postNavigationWaitMs', cfg.defaultPostWaitMs)
//waitForRedirect
let waitForRedirect = getOptBool(opt, 'waitForRedirect', false)
//maxRetries
let maxRetries = getOptP0Int(opt, 'maxRetries', DEFAULT_MAX_RETRIES)
//單次嘗試自行擁有browser之生命週期, 回傳前必定關閉。
//不可把close放在重試迴圈之finally——finally晚於catch內之await delay,
//會使失敗的browser在退避的3至15秒期間繼續存活
let r = await withRetry(() => _runOnce(url, opt, cfg, { navTimeout, postWait, waitForRedirect }), {
maxRetries,
tag: cfg.logName,
})
if (r.ok) {
return {
status: 'success',
url,
html: r.html,
htmlLength: r.html.length,
contentKind: r.contentKind,
//與curl階之成功結果同形: 該階自初版即回報httpCode, 此階先前沒有
httpCode: r.httpCode,
finalUrl: r.finalUrl,
...r.extra,
method: cfg.method,
fetchedAt,
attempts: r.attempts,
}
}
//歸因取單次嘗試自報者, 未自報時才落回playwright-error
//此前一律寫死playwright-error, 使HTTP狀態所致之失敗與導航失敗無從區分——
//前者不該重試(4xx)且是站方回應, 後者是本地環境或網路問題
return {
status: 'error',
url,
message: r.message,
reason: r.reason || 'playwright-error',
httpCode: r.httpCode,
method: cfg.method,
fetchedAt,
attempts: r.attempts,
}
}
export default runPlaywright