Files
pokemon-radar-api/twpk_radar.mjs
Timmy f7ef105436 init: Redis-backed Pokémon 雷達 API
- 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>
2026-04-20 09:13:59 +08:00

376 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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",
};
}