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>
This commit is contained in:
2026-04-20 09:13:59 +08:00
commit f7ef105436
18 changed files with 2919 additions and 0 deletions

375
twpk_radar.mjs Normal file
View File

@@ -0,0 +1,375 @@
// 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",
};
}