import runPlaywright from './runPlaywright.mjs'
import { METHOD_PW_HEADED as METHOD } from './constants.mjs'
import { VERIFY_SELECTORS, CF_FRAME_HOST, TURNSTILE_CONTAINER_SELECTOR } from './challengeResources.mjs'
import { getOptBool } from './getOpt.mjs'
//預設值
//
//本值與無頭模式之3000不同, 該差異自初版即存在且未見記錄。
//有頭模式另需等待驗證元件出現與人為點擊, 較長之等待有其道理, 但3000與5000這兩個
//具體數字未經量測, 屬未經驗證之推測。調整前應先量測真實頁面之渲染完成時間
let DEFAULT_POST_NAV_WAIT_MS = 5000
//驗證iframe之選取器, 其來源與判識器共用同一份挑戰特徵清單
//模擬人類滑鼠軌跡, 隨機起點至中途點至目標, 並加隨機抖動與停頓
async function _humanClick(page, x, y) {
let startX = 100 + Math.random() * 200
let startY = 500 + Math.random() * 100
await page.mouse.move(startX, startY)
let steps = 2 + Math.floor(Math.random() * 2)
for (let i = 1; i <= steps; i++) {
let ratio = i / (steps + 1)
let mx = startX + (x - startX) * ratio + (Math.random() - 0.5) * 30
let my = startY + (y - startY) * ratio + (Math.random() - 0.5) * 20
await page.mouse.move(mx, my)
await page.waitForTimeout(80 + Math.random() * 120)
}
await page.mouse.move(x, y)
await page.waitForTimeout(50 + Math.random() * 100)
await page.mouse.click(x, y)
}
//偵測並點擊驗證checkbox(Cloudflare Turnstile、hCaptcha等)
async function _tryClickVerification(page) {
//模式A, DOM中可見之iframe(傳統嵌入)
for (let sel of VERIFY_SELECTORS) {
let el = page.locator(sel).first()
let count = await el.count().catch(() => 0)
if (count > 0) {
let box = await el.boundingBox().catch(() => null)
if (box) {
await _humanClick(page, box.x + box.width / 2, box.y + box.height / 2)
process.stderr.write(`[fetchWebByPlaywrightHead] clicked verification iframe (${sel})\n`)
await page.waitForTimeout(5000)
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
return true
}
}
}
//模式B, Cloudflare managed challenge整頁(iframe不在DOM, 須透過page.frames)
let cfFrame = page.frames().find((f) => f.url().includes(CF_FRAME_HOST))
if (cfFrame) {
let container = page.locator(TURNSTILE_CONTAINER_SELECTOR).first()
let box = await container.boundingBox().catch(() => null)
if (box) {
await _humanClick(page, box.x + 30, box.y + box.height / 2)
}
else {
let vp = page.viewportSize() || { width: 1280, height: 720 }
await _humanClick(page, vp.width * 0.39, vp.height * 0.57)
}
process.stderr.write('[fetchWebByPlaywrightHead] clicked Cloudflare managed challenge checkbox\n')
await page.waitForTimeout(8000)
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
return true
}
return false
}
//導航後之處理: 隱藏webdriver標記已於開頁時完成, 此處負責驗證checkbox之偵測與點擊
async function _afterNavigate(page, opt) {
let skipVerify = getOptBool(opt, 'skipVerificationClick', false)
let verificationClicked = false
if (!skipVerify) {
verificationClicked = await _tryClickVerification(page)
}
return { verificationClicked }
}
//開頁後之前置處理: 隱藏webdriver標記
async function _onPage(page) {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false })
})
}
/**
* 使用Playwright有頭Chrome抓取網頁原始HTML,含驗證checkbox自動點擊
*
* 特點:
* 有頭模式(實體視窗)並加反自動化偽裝(隱藏webdriver、disable-blink-features);
* 自動偵測並點擊Cloudflare Turnstile與hCaptcha等驗證checkbox(模擬人類滑鼠軌跡);
* 可見文字過少時自動穿透Shadow DOM取得內文並重組為簡易HTML;
* 失敗時自動重試(線性退避3至15秒);
* 使用playwright之chromium並指定channel='chrome',故執行環境須已安裝Chrome瀏覽器,且執行時會開啟實體瀏覽器視窗
*
* @param {String} url 輸入待抓取網址字串
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Integer} [opt.navigationTimeoutMs=15000] 輸入頁面導航最長等待毫秒整數,預設15000
* @param {Integer} [opt.postNavigationWaitMs=5000] 輸入導航後額外等待毫秒整數,預設5000
* @param {Boolean} [opt.waitForRedirect=false] 輸入是否等待JS轉址完成布林值,預設false
* @param {Boolean} [opt.skipVerificationClick=false] 輸入是否跳過驗證checkbox自動點擊布林值,預設false
* @param {Integer} [opt.maxRetries=5] 輸入失敗時最大重試次數整數,含初始共執行maxRetries+1次,預設5
* @returns {Promise} 回傳Promise,resolve回傳結果物件,成功時為{status:'success',url,html,htmlLength,contentKind,verificationClicked,method,fetchedAt,attempts}(contentKind為'raw'或'synthesized',後者代表內容由Shadow DOM穿透後合成),失敗時為{status:'error',url,message,reason,method,fetchedAt,attempts},本函數不會reject
* @example
*
* import fetchWebByPlaywrightHead from './src/fetchWebByPlaywrightHead.mjs'
*
* let test = async () => {
*
* let r = await fetchWebByPlaywrightHead('https://example.com/')
* console.log(r.status, r.verificationClicked, r.htmlLength)
* // => 'success' false 234
*
* let re = await fetchWebByPlaywrightHead('abc')
* console.log(re.status, re.reason)
* // => 'error' 'invalid-url'
*
* }
* await test()
* .catch((err) => {
* console.log(err)
* })
*
*/
async function fetchWebByPlaywrightHead(url, opt = {}) {
return runPlaywright(url, opt, {
method: METHOD,
logName: 'fetchWebByPlaywrightHead',
defaultPostWaitMs: DEFAULT_POST_NAV_WAIT_MS,
launch: {
headless: false,
channel: 'chrome',
args: ['--disable-blink-features=AutomationControlled'],
},
onPage: _onPage,
afterNavigate: _afterNavigate,
})
}
export default fetchWebByPlaywrightHead