import { buildStorageState } from "./storage-state.js"; const CONTENT_SCRIPT_ID = "haixun-bridge"; const GO_API_SUCCESS_CODE = 102000; function unwrapGoApiResponse(raw) { if (raw && typeof raw === "object" && "code" in raw) { if (raw.code !== GO_API_SUCCESS_CODE) { throw new Error(raw.message || `API error ${raw.code}`); } return raw.data && typeof raw.data === "object" ? raw.data : {}; } return raw && typeof raw === "object" ? raw : {}; } function parseApiError(raw, status) { if (raw && typeof raw === "object") { if (typeof raw.message === "string" && raw.message.trim()) return raw.message; if (typeof raw.error === "string" && raw.error.trim()) return raw.error; } return `匯入失敗(HTTP ${status})`; } function equivalentOrigins(origin) { const url = new URL(origin); const port = url.port ? `:${url.port}` : ""; const origins = new Set([url.origin]); if (url.hostname === "localhost") { origins.add(`${url.protocol}//127.0.0.1${port}`); } else if (url.hostname === "127.0.0.1") { origins.add(`${url.protocol}//localhost${port}`); } return [...origins]; } /** The extension uses the page's /api proxy in every environment. */ function resolveApiBases(serverUrl) { const url = new URL(serverUrl); return [url.origin]; } async function requestHostPermission(serverOrigin) { const origins = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); try { const granted = await chrome.permissions.contains({ origins }); if (granted) return true; // 無使用者手勢時 request 可能失敗;設定頁從 content script 進來常無 gesture return await chrome.permissions.request({ origins }); } catch { return false; } } async function queryHaixunTabs(serverOrigin) { const tabsById = new Map(); for (const origin of equivalentOrigins(serverOrigin)) { try { const tabs = await chrome.tabs.query({ url: `${origin}/*` }); for (const tab of tabs) { if (tab.id != null) tabsById.set(tab.id, tab); } } catch { // no permission for that origin } } return [...tabsById.values()]; } async function ensureContentScript(serverOrigin) { const patterns = equivalentOrigins(serverOrigin).map((item) => `${item}/*`); try { const existing = await chrome.scripting.getRegisteredContentScripts(); if (existing.some((script) => script.id === CONTENT_SCRIPT_ID)) { try { await chrome.scripting.unregisterContentScripts({ ids: [CONTENT_SCRIPT_ID] }); } catch { // ignore } } await chrome.scripting.registerContentScripts([ { id: CONTENT_SCRIPT_ID, matches: patterns, js: ["content-haixun.js"], runAt: "document_start", }, ]); } catch (error) { console.warn("[haixun] registerContentScripts", error); } } async function injectBridgeIntoTabs(serverOrigin) { const tabs = await queryHaixunTabs(serverOrigin); for (const tab of tabs) { if (!tab.id) continue; try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content-haixun.js"], }); } catch { // Ignore: no host permission / restricted page / already injected } } } /** 從巡樓分頁讀 JWT;支援舊 key 與新版 hb_live_tokens */ async function readAuthFromHaixunTab(serverOrigin) { const tabs = await queryHaixunTabs(serverOrigin); for (const tab of tabs) { if (!tab.id) continue; try { const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: () => { let accessToken = localStorage.getItem("haixun.access_token") ?? ""; if (!accessToken) { try { const raw = localStorage.getItem("hb_live_tokens"); if (raw) { const parsed = JSON.parse(raw); accessToken = String(parsed?.access_token ?? ""); } } catch { // ignore } } const pathname = location.pathname ?? ""; const fromPath = pathname.match(/\/threads\/([^/]+)/)?.[1] ?? ""; const accountId = localStorage.getItem("haixun.active_threads_account_id") || localStorage.getItem("hb_active_threads_account_id") || fromPath || ""; return { accessToken, accountId: String(accountId), origin: location.origin, }; }, }); const payload = result?.result; if (payload?.accessToken) { return payload; } } catch (error) { console.warn("[haixun] readAuth tab failed (host permission?)", tab.url, error); } } return null; } async function postJSON(apiBase, path, accessToken, body) { const res = await fetch(`${apiBase}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(body), }); const raw = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(parseApiError(raw, res.status)); err.status = res.status; err.raw = raw; throw err; } return unwrapGoApiResponse(raw); } /** * 優先 scout crawler-session;失敗再試舊 threads session/import(後端已相容)。 */ async function postSessionToBackend(apiBase, accessToken, storageState, accountId) { try { await postJSON(apiBase, "/api/v1/scout/crawler-session", accessToken, { token: storageState, }); return { valid: true, synced: true, account_id: accountId || "", message: `Chrome session 已同步到巡樓爬蟲(${apiBase})`, apiBase, }; } catch (first) { // 404/405:舊後端或路由未就緒 → 走 session/import const status = first?.status; if (status !== 404 && status !== 405) { throw first; } if (!accountId) { throw first; } const data = await postJSON( apiBase, `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/session/import`, accessToken, { storageState }, ); return { valid: data.valid !== false, synced: data.synced !== false, account_id: data.account_id || accountId, username: data.username, message: data.message || `Chrome session 已同步(${apiBase})`, apiBase, }; } } /** * 同步:收集 Threads cookies → 寫入巡樓 scout crawler-session(dev 模式海巡用) */ export async function syncThreadsSessionToGo(serverUrl, accountId, accessToken) { const origin = new URL(serverUrl).origin; if (!accessToken) { const hint = equivalentOrigins(origin).join(" 或 "); throw new Error( `找不到巡樓登入狀態。請在 Chrome 開啟並登入 ${hint},或在設定頁按「從 Chrome 同步」(會帶上 JWT)`, ); } let storageState; try { storageState = await buildStorageState(); } catch (error) { throw error; } const bases = resolveApiBases(serverUrl); let lastErr = null; for (const apiBase of bases) { try { return await postSessionToBackend(apiBase, accessToken, storageState, accountId); } catch (error) { lastErr = error; const msg = error instanceof Error ? error.message : String(error); if (/fetch|network|failed|Failed to fetch/i.test(msg)) { continue; // try next api base } // auth / business error — don't retry other ports throw error; } } const tried = bases.join("、"); const detail = lastErr instanceof Error ? lastErr.message : String(lastErr ?? ""); throw new Error( `無法連線後端(已試 ${tried})。請確認 gateway 有在跑,且擴充已授權該網址。${detail ? ` ${detail}` : ""}`, ); } async function resolveServerUrl(partial) { if (partial) return new URL(partial).origin; const stored = await chrome.storage.sync.get(["serverUrl"]); if (stored.serverUrl) return new URL(stored.serverUrl).origin; const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const activeUrl = tabs[0]?.url; if (activeUrl) { try { const origin = new URL(activeUrl).origin; const port = new URL(activeUrl).port; if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/app/") || activeUrl.includes("/threads/")) { return origin; } } catch { // ignore } } throw new Error("請在擴充功能選項設定巡樓網址(例如 http://localhost:5173),或先開啟巡樓分頁"); } chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { // 僅收集 cookies / storageState,由網頁用自己的 JWT 上傳(避免 401) if (message?.action === "collect") { (async () => { try { const storageState = await buildStorageState(); sendResponse({ success: true, storageState, message: "已收集 Threads cookies", }); } catch (error) { sendResponse({ success: false, message: error instanceof Error ? error.message : "收集失敗", }); } })(); return true; } if (message?.action !== "sync") return undefined; (async () => { try { const serverUrl = await resolveServerUrl(message.serverUrl); // 權限請求可能因無 user gesture 失敗;不因此中斷(cookies 仍可能夠用) await requestHostPermission(serverUrl); await ensureContentScript(serverUrl); await injectBridgeIntoTabs(serverUrl); let accountId = String(message.accountId ?? "").trim(); let accessToken = String(message.accessToken ?? "").trim(); if (!accountId || !accessToken) { const auth = await readAuthFromHaixunTab(serverUrl); if (auth) { accountId = accountId || String(auth.accountId ?? "").trim(); accessToken = accessToken || String(auth.accessToken ?? "").trim(); } } if (!accessToken) { throw new Error( "找不到巡樓 JWT。請在設定頁按「從 Chrome 同步」(由網頁帶登入態上傳),或先確認已登入巡樓。", ); } const result = await syncThreadsSessionToGo(serverUrl, accountId, accessToken); sendResponse({ success: true, valid: result.valid !== false, synced: result.synced ?? true, account_id: result.account_id, username: result.username, message: result.message || "Chrome session 已同步", }); } catch (error) { sendResponse({ success: false, valid: false, message: error instanceof Error ? error.message : "同步失敗", }); } })(); return true; }); async function bootstrapBridgeFromStorage() { try { const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]); // 預設同時覆蓋本機與遠端 dev const defaults = [ "http://localhost:5173", "http://127.0.0.1:5173", "https://threads-tool-dev.30cm.net", ]; const primary = serverUrl || defaults[0]; if (!serverUrl) { await chrome.storage.sync.set({ serverUrl: primary }); } // 註冊並注入目前設定 + 常見網址 const origins = new Set([primary, ...defaults]); for (const origin of origins) { try { await ensureContentScript(origin); await injectBridgeIntoTabs(origin); } catch (e) { console.warn("[haixun] bootstrap", origin, e); } } } catch (e) { console.warn("[haixun] bootstrapBridgeFromStorage", e); } } chrome.runtime.onInstalled.addListener(() => { void bootstrapBridgeFromStorage(); }); chrome.runtime.onStartup.addListener(() => { void bootstrapBridgeFromStorage(); }); // service worker 冷啟動也試一次 void bootstrapBridgeFromStorage();