2026-07-13 01:15:30 +00:00
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 08:59:13 +00:00
|
|
|
|
/** The extension uses the page's /api proxy in every environment. */
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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();
|