// twpk_radar.mjs - 寶可夢雷達抓取 (Production Optimized: Smart Wait) export default async function ({ page, context }) { // 1. 參數初始化:優先使用 Python 注入的變數 const TARGET = { lat: 25.0478, lng: 121.5170, zoom: 11 // 預設值,實際執行時會由 Python 覆蓋 }; // 注入的狀態 (Cookies, LocalStorage, UA) const INJECT_STATE = globalThis.__POKE_STATE__ || { cookies: [], localStorage: {}, ua: null, langs: null }; const DO_SCREENSHOT = true; const ORIGIN = "https://twpkinfo.com"; const RADAR_URL = `${ORIGIN}/ipoke.aspx`; const WARMUP_PATHS = ["/", "/igym.aspx", "/iresearch.aspx"]; // === 核心設定 === const MAX_ATTEMPTS = 20; // 最大重試次數 const MASK_THRESHOLD = 0.35; // 黑影容忍度 (35%) // const POPUP_DELAY = 240; // <--- 舊的固定等待已移除,改用下方的動態檢查 const BETWEEN_ATTEMPT_MS = 350; const JITTER_M = 120; // 座標抖動範圍 (公尺) const results = []; const debug = { notes: [] }; const note = (s) => debug.notes.push(String(s)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const metersToDeg = (m) => m / 111320; // 座標抖動算法 const jitter = (i) => { const dx = ((i % 3) - 1) * metersToDeg(JITTER_M); const dy = (((i + 1) % 3) - 1) * metersToDeg(JITTER_M) * Math.cos(TARGET.lat * Math.PI / 180); return { lat: TARGET.lat + dx, lng: TARGET.lng + dy }; }; // ---- 1. 環境偽裝 (Stealth) ---- try { const uaStr = INJECT_STATE.ua || null; const langs = INJECT_STATE.langs || ["zh-TW", "zh", "en-US", "en"]; await page.evaluateOnNewDocument((uaStr, langs) => { Object.defineProperty(navigator, "webdriver", { get: () => undefined }); Object.defineProperty(navigator, "languages", { get: () => langs }); Object.defineProperty(navigator, "language", { get: () => langs?.[0] || "zh-TW" }); if (uaStr) { Object.defineProperty(navigator, "userAgent", { get: () => uaStr }); try { Object.defineProperty(navigator, "userAgentData", { get: () => ({ brands: [{ brand: "Chromium", version: "141" }], mobile: false, platform: "macOS" }), }); } catch {} } Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3] }); window.chrome = window.chrome || { runtime: {} }; const getParameter = WebGLRenderingContext.prototype.getParameter; WebGLRenderingContext.prototype.getParameter = function (p) { if (p === 37445) return "Intel Inc."; if (p === 37446) return "Intel(R) Iris(TM) Graphics"; return getParameter.call(this, p); }; const originalQuery = navigator.permissions && navigator.permissions.query; if (originalQuery) { navigator.permissions.query = (parameters) => parameters.name === "notifications" ? Promise.resolve({ state: "granted" }) : originalQuery(parameters); } }, uaStr, langs); note(`✅ stealth patches injected${INJECT_STATE.ua ? " + ua spoof" : ""}`); } catch (e) { note("⚠️ stealth inject error: " + e); } // ---- 2. 請求頭與權限設定 ---- try { await page.setExtraHTTPHeaders({ "Accept-Language": INJECT_STATE.langs?.join(",") || "zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7", Referer: ORIGIN + "/", }); if (context?.grantPermissions) { await context.grantPermissions(["geolocation"], { origin: ORIGIN }); } if (page.setGeolocation) { await page.setGeolocation({ latitude: TARGET.lat, longitude: TARGET.lng }); note(`✅ setGeolocation=${TARGET.lat},${TARGET.lng}`); } } catch (e) { note("⚠️ header/perm error: " + e); } // ---- 3. 匯入 Cookies ---- try { if (context?.addCookies && INJECT_STATE.cookies?.length) { await context.addCookies( INJECT_STATE.cookies.map((c) => ({ name: c.name, value: c.value, domain: c.domain || "twpkinfo.com", path: c.path || "/", expires: c.expires || Math.floor(Date.now() / 1000) + 31536000, httpOnly: !!c.httpOnly, secure: !!c.secure, sameSite: c.sameSite || "Lax", })), ); note(`✅ imported cookies: ${INJECT_STATE.cookies.length}`); } else { await context?.addCookies?.([{ name: "lang", value: "TW", domain: "twpkinfo.com", path: "/", expires: Math.floor(Date.now() / 1000) + 31536000, httpOnly: false, secure: false, sameSite: "Lax", }]); note("✅ cookie lang=TW set (fallback)"); } } catch (e) { note("⚠️ addCookies error: " + e); } // ---- 4. 暖身 (注入 LocalStorage) ---- const warmupOnce = async () => { try { await page.goto(ORIGIN + "/", { waitUntil: "domcontentloaded", timeout: 30000 }); await sleep(800); await page.evaluate((kv) => { try { if (kv && typeof kv === "object") { for (const [k, v] of Object.entries(kv)) localStorage.setItem(k, String(v)); } localStorage.setItem("mapAccess", "true"); localStorage.setItem("AllowMap", "true"); localStorage.setItem("pokeMap", "true"); } catch {} }, INJECT_STATE.localStorage || {}); note("✅ localStorage injected"); for (const p of WARMUP_PATHS) { await page.goto(ORIGIN + p, { waitUntil: "domcontentloaded", timeout: 30000 }); await sleep(500); } } catch (e) { note("⚠️ warmup error: " + e); } }; const openRadar = async () => { await page.goto(RADAR_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); await sleep(900); }; // ---- 5. 核心解析邏輯 (Browser Context) ---- const parseOnce = async (center, zoom) => { const frame = page.frames().find((f) => (f.url() || "").toLowerCase().includes("map.aspx")) || page; return await frame.evaluate(async (C, z) => { const debugNotes = []; const out = []; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 尋找 Leaflet 地圖實例 const map = [self.map, self.mymap, self.leafletMap].find((m) => m && typeof m.eachLayer === "function") || null; if (!map) { debugNotes.push("❌ no leaflet map in frame"); return { out, debugNotes, maskedRatio: 1 }; } // 移動地圖 try { map.setView([C.lat, C.lng], z, { animate: false }); } catch {} await sleep(1000); // 等待載入 // 抓取圖層 const layers = Object.values(map._layers || {}).filter( (l) => l && typeof l.getLatLng === "function" ); debugNotes.push(`layers=${layers.length} center=${C.lat.toFixed(5)},${C.lng.toFixed(5)} z=${z}`); // HTML 解析器 const parsePopup = (html) => { const div = document.createElement("div"); div.innerHTML = html || ""; const text = (div.textContent || "").replace(/\s+/g, " ").trim(); const info = { text, gender: null, dex: null, name: null, remaining_hint: null, masked: /\*{2,}/.test(text), iv_pct: null, iv_atk: null, iv_def: null, iv_sta: null, cp: null, level: null, fast_move: null, charge_move: null, }; const mDN = text.match(/\s*([♂♀])?\s*(\d{1,4})\s*[-–]\s*([A-Za-z\u4e00-\u9fff\*]+)/); if (mDN) { info.gender = mDN[1] === "♂" ? "male" : mDN[1] === "♀" ? "female" : null; info.dex = parseInt(mDN[2], 10); info.name = mDN[3].replace(/\*/g, ""); } const mMin = text.match(/Time[::]\s*(?:About\s*)?(\d+)\s*Min/i); const mSec = text.match(/Time[::]\s*(?:About\s*)?(\d+)\s*Sec/i); if (mMin) info.remaining_hint = parseInt(mMin[1], 10) * 60; else if (mSec) info.remaining_hint = parseInt(mSec[1], 10); const mIV = text.match(/IV\s*[::]\s*(\d{1,3})\s*[%%]/i); if (mIV) info.iv_pct = parseInt(mIV[1], 10); const mABC = text.match(/攻[^0-9]*\/[^0-9]*防[^0-9]*\/[^0-9]*體[^::]*[::]\s*(\d{1,2})\s*\/\s*(\d{1,2})\s*\/\s*(\d{1,2})/); if (mABC) { info.iv_atk = +mABC[1]; info.iv_def = +mABC[2]; info.iv_sta = +mABC[3]; } const mCP = text.match(/CP\s*[::]\s*(\d{1,5})/i); if (mCP) info.cp = parseInt(mCP[1], 10); const mLV = text.match(/LV\s*[::]\s*(\d{1,2})/i); if (mLV) info.level = parseInt(mLV[1], 10); const mMove = text.match(/[技招式][::]\s*([^\//]+?)\s*[\//]\s*([^\s\//]+)/); if (mMove) { info.fast_move = mMove[1].trim(); info.charge_move = mMove[2].trim(); } return info; }; try { map.panBy([50, -30], { animate: false }); map.panBy([-30, 40], { animate: false }); } catch {} let masked = 0; // 遍歷所有 Marker for (const layer of layers) { try { const ll = layer.getLatLng?.(); if (!ll) continue; // 觸發點擊 if (typeof layer.openPopup === "function") { try { layer.openPopup(); } catch {} } if (layer._icon?.dispatchEvent) { try { layer._icon.dispatchEvent(new MouseEvent("click", { bubbles: true })); } catch {} } else if (typeof layer.fire === "function") { try { layer.fire("click"); } catch {} } // ★★★ 優化:動態檢查取代死等 ★★★ let popup_html = ""; // 最多嘗試 15 次,每次 20ms (最多 300ms,通常 40ms 就抓到了) for (let k = 0; k < 15; k++) { await sleep(20); const popupEl = document.querySelector(".leaflet-popup-pane .leaflet-popup-content") || document.querySelector(".leaflet-tooltip-pane .leaflet-tooltip"); // 嘗試讀取內容 (DOM 優先,Leaflet 屬性次之) const currentHtml = popupEl?.innerHTML || layer.getPopup?.()?.getContent?.() || layer.getTooltip?.()?.getContent?.() || ""; // 如果有內容且不是 Loading,提早結束 if (currentHtml && currentHtml.length > 15 && !currentHtml.includes("Loading")) { popup_html = currentHtml; break; } } // ★★★ 優化結束 ★★★ const info = parsePopup(popup_html); if (info.masked) masked++; // 補充名稱 (如果是 mask 狀態) let pid = info.dex || null; let name = info.name || null; try { if (pid && (self.poke?.poke?.[pid]?.zhtw || self.poke?.poke?.[pid]?.name)) { name = self.poke.poke[pid].zhtw || self.poke.poke[pid].name; } } catch {} // 計算剩餘時間 let remaining = null; let expire_time = null; try { const now = Date.now(); const t = layer?.options?.times || layer?.options?.expire_time || 0; if (t > 0) { expire_time = Math.floor(t / 1000); if (t > now) remaining = Math.max(0, Math.floor((t - now) / 1000)); } } catch {} if (remaining == null && info.remaining_hint != null) remaining = info.remaining_hint; // 收錄資料 out.push({ id: pid, name, remaining, expire_time, latitude: ll?.lat ?? null, longitude: ll?.lng ?? null, gender: info.gender, iv_pct: info.iv_pct ?? null, iv_atk: info.iv_atk ?? null, iv_def: info.iv_def ?? null, iv_sta: info.iv_sta ?? null, cp: info.cp ?? null, level: info.level ?? null, fast_move: info.fast_move ?? null, charge_move: info.charge_move ?? null, is_perfect: info.iv_pct === 100 || (info.iv_atk === 15 && info.iv_def === 15 && info.iv_sta === 15) || false, }); if (typeof layer.closePopup === "function") { try { layer.closePopup(); } catch {} } } catch {} } const maskedRatio = layers.length ? masked / layers.length : 1; debugNotes.push(`maskRatio=${maskedRatio.toFixed(2)} (${masked}/${layers.length})`); return { out, debugNotes, maskedRatio }; }, center, zoom); // 移除 POPUP_DELAY 參數,因為已經內建在 evaluate 裡 }; // ---- 6. 主流程 ---- try { if (page.setViewportSize) await page.setViewportSize({ width: 1280, height: 900 }); await warmupOnce(); await openRadar(); let best = { out: [], maskedRatio: 1, debugNotes: [] }; for (let i = 0; i < MAX_ATTEMPTS; i++) { const c = jitter(i); const z = TARGET.zoom + (i % 2 ? 1 : -1); note(`🔁 attempt ${i + 1}/${MAX_ATTEMPTS} center=${c.lat.toFixed(5)},${c.lng.toFixed(5)} z=${z}`); const payload = await parseOnce(c, z); debug.notes.push(...(payload.debugNotes || [])); if (Array.isArray(payload.out) && payload.out.length > best.out.length) best = payload; if (payload.maskedRatio <= MASK_THRESHOLD) { note(`✅ mask ratio OK: ${payload.maskedRatio.toFixed(2)} ≤ ${MASK_THRESHOLD}`); best = payload; break; } try { await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 }); } catch {} await sleep(BETWEEN_ATTEMPT_MS); } if (Array.isArray(best.out)) results.push(...best.out); } catch (e) { note("❌ main error: " + e); } // ---- 7. 截圖 ---- let screenshotBase64 = null; if (DO_SCREENSHOT) { try { const buf = await page.screenshot({ fullPage: true }); screenshotBase64 = buf.toString("base64"); note("📸 screenshot captured"); } catch (e) { note("⚠️ screenshot error: " + e); } } return { data: JSON.stringify({ ok: true, count: results.length, results, screenshot: screenshotBase64 ? `data:image/png;base64,${screenshotBase64}` : null, debug, }), type: "application/json", }; }