- radar_fetcher.py:呼叫 Function Server 抓 twpkinfo.com 資料後寫入 Redis (TTL 300s) - pokemon_radar_api.py:FastAPI /scan,從 Redis 讀取並依 request 即時二次篩選;Redis 不可用時回 200 空結果,不再 500 - pokemon_location_fetcher.py:舊版 CLI,仍由 fetch_and_upload.sh 使用 - 文件:README (參考手冊)、QUICKSTART (操作指南)、SUMMARY (故事線)、CLAUDE.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
// 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",
|
||
};
|
||
}
|