thread-master/apps/extension/haixun-threads-sync/service-worker.js

386 lines
12 KiB
JavaScript
Raw 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.

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-sessiondev 模式海巡用)
*/
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();