355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useMemo, useState } from "react";
|
|||
|
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { Switch } from "@/components/ui/switch";
|
|||
|
|
import {
|
|||
|
|
SEARCH_SOURCE_MODE_OPTIONS,
|
|||
|
|
searchSourceModeLabel,
|
|||
|
|
type SearchSourceMode,
|
|||
|
|
} from "@/lib/search/source-mode";
|
|||
|
|
|
|||
|
|
export type ConnectionPreset = "sync" | "api" | "hybrid" | "custom";
|
|||
|
|
|
|||
|
|
export interface ThreadsConnectionSettingsData {
|
|||
|
|
searchViaApi?: boolean;
|
|||
|
|
searchSourceMode?: SearchSourceMode;
|
|||
|
|
publishViaApi?: boolean;
|
|||
|
|
devMode?: boolean;
|
|||
|
|
scrapeReplies?: boolean;
|
|||
|
|
repliesPerPost?: number;
|
|||
|
|
publishHeaded?: boolean;
|
|||
|
|
playwrightDebug?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ThreadsConnectionSettingsProps {
|
|||
|
|
settings: ThreadsConnectionSettingsData;
|
|||
|
|
accountName?: string | null;
|
|||
|
|
onChange: (patch: Partial<ThreadsConnectionSettingsData>) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PRIMARY_SOURCE_MODES: SearchSourceMode[] = [
|
|||
|
|
"mixed",
|
|||
|
|
"threads",
|
|||
|
|
"brave",
|
|||
|
|
"crawler",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const COMBO_SOURCE_MODES: SearchSourceMode[] = [
|
|||
|
|
"threads_brave",
|
|||
|
|
"threads_crawler",
|
|||
|
|
"brave_crawler",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function detectPreset(data: ThreadsConnectionSettingsData): ConnectionPreset {
|
|||
|
|
const searchApi = data.searchViaApi ?? false;
|
|||
|
|
const publishApi = data.publishViaApi ?? false;
|
|||
|
|
const dev = data.devMode ?? false;
|
|||
|
|
|
|||
|
|
if (searchApi && publishApi && !dev) return "api";
|
|||
|
|
if (!searchApi && !publishApi && dev) return "sync";
|
|||
|
|
if (searchApi && dev) return "hybrid";
|
|||
|
|
return "custom";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function presetPatch(preset: ConnectionPreset): Partial<ThreadsConnectionSettingsData> {
|
|||
|
|
switch (preset) {
|
|||
|
|
case "api":
|
|||
|
|
return {
|
|||
|
|
searchViaApi: true,
|
|||
|
|
publishViaApi: true,
|
|||
|
|
devMode: false,
|
|||
|
|
scrapeReplies: false,
|
|||
|
|
searchSourceMode: "threads",
|
|||
|
|
};
|
|||
|
|
case "sync":
|
|||
|
|
return {
|
|||
|
|
searchViaApi: false,
|
|||
|
|
publishViaApi: false,
|
|||
|
|
devMode: true,
|
|||
|
|
scrapeReplies: true,
|
|||
|
|
searchSourceMode: "crawler",
|
|||
|
|
};
|
|||
|
|
case "hybrid":
|
|||
|
|
return {
|
|||
|
|
searchViaApi: true,
|
|||
|
|
publishViaApi: false,
|
|||
|
|
devMode: true,
|
|||
|
|
scrapeReplies: true,
|
|||
|
|
searchSourceMode: "mixed",
|
|||
|
|
};
|
|||
|
|
default:
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sourceModeHint(mode: SearchSourceMode): string {
|
|||
|
|
return SEARCH_SOURCE_MODE_OPTIONS.find((o) => o.value === mode)?.hint ?? "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function FlowSummary({ settings }: { settings: ThreadsConnectionSettingsData }) {
|
|||
|
|
const mode = settings.searchSourceMode ?? "mixed";
|
|||
|
|
|
|||
|
|
const lines = useMemo(() => {
|
|||
|
|
const sourceLine = `海巡來源:${searchSourceModeLabel(mode)}(${sourceModeHint(mode)})`;
|
|||
|
|
|
|||
|
|
const search = settings.searchViaApi
|
|||
|
|
? "Threads API:已開啟(需 OAuth + keyword search 權限)"
|
|||
|
|
: settings.devMode
|
|||
|
|
? "Threads API:關閉,改走下方選定的來源"
|
|||
|
|
: "Threads API:關閉";
|
|||
|
|
|
|||
|
|
const replies =
|
|||
|
|
settings.scrapeReplies && settings.devMode
|
|||
|
|
? `留言:瀏覽器爬取(每篇最多 ${settings.repliesPerPost ?? 10} 則)`
|
|||
|
|
: settings.scrapeReplies
|
|||
|
|
? "留言:已開啟但 devMode 關閉,實際不會爬留言"
|
|||
|
|
: "留言:不抓取(僅用貼文與互動數)";
|
|||
|
|
|
|||
|
|
const publish = settings.publishViaApi
|
|||
|
|
? "發文:Meta API 優先,失敗時改瀏覽器"
|
|||
|
|
: "發文:Playwright 瀏覽器(需 Chrome 同步 session)";
|
|||
|
|
|
|||
|
|
return [sourceLine, search, replies, publish];
|
|||
|
|
}, [settings, mode]);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="rounded-lg border border-border bg-muted/40 px-3.5 py-3 text-xs leading-relaxed text-muted-foreground">
|
|||
|
|
<p className="mb-2 font-semibold text-foreground">目前流程預覽</p>
|
|||
|
|
<ul className="list-inside list-disc space-y-1">
|
|||
|
|
{lines.map((line) => (
|
|||
|
|
<li key={line}>{line}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ThreadsConnectionSettings({
|
|||
|
|
settings,
|
|||
|
|
accountName,
|
|||
|
|
onChange,
|
|||
|
|
}: ThreadsConnectionSettingsProps) {
|
|||
|
|
const [preset, setPreset] = useState<ConnectionPreset>("sync");
|
|||
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|||
|
|
const [showComboModes, setShowComboModes] = useState(false);
|
|||
|
|
|
|||
|
|
const currentMode = settings.searchSourceMode ?? "mixed";
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
setPreset(detectPreset(settings));
|
|||
|
|
setShowComboModes(COMBO_SOURCE_MODES.includes(currentMode));
|
|||
|
|
}, [settings, currentMode]);
|
|||
|
|
|
|||
|
|
function applyPreset(next: ConnectionPreset) {
|
|||
|
|
setPreset(next);
|
|||
|
|
if (next !== "custom") {
|
|||
|
|
onChange(presetPatch(next));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applySourceMode(mode: SearchSourceMode) {
|
|||
|
|
setPreset("custom");
|
|||
|
|
const patch: Partial<ThreadsConnectionSettingsData> = { searchSourceMode: mode };
|
|||
|
|
|
|||
|
|
if (mode === "threads" || mode === "threads_brave" || mode === "threads_crawler") {
|
|||
|
|
patch.searchViaApi = true;
|
|||
|
|
}
|
|||
|
|
if (mode === "crawler" || mode === "threads_crawler" || mode === "brave_crawler") {
|
|||
|
|
patch.devMode = true;
|
|||
|
|
}
|
|||
|
|
if (mode === "mixed") {
|
|||
|
|
patch.searchViaApi = true;
|
|||
|
|
patch.devMode = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onChange(patch);
|
|||
|
|
setShowComboModes(COMBO_SOURCE_MODES.includes(mode));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-5">
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<CardTitle>海巡搜尋來源</CardTitle>
|
|||
|
|
<CardDescription>
|
|||
|
|
選擇要用哪個 provider,或混合模式。Brave 需設定 BRAVE_SEARCH_API_KEY;爬蟲需 Chrome
|
|||
|
|
同步。
|
|||
|
|
</CardDescription>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{SEARCH_SOURCE_MODE_OPTIONS.filter((o) =>
|
|||
|
|
PRIMARY_SOURCE_MODES.includes(o.value)
|
|||
|
|
).map((option) => (
|
|||
|
|
<Button
|
|||
|
|
key={option.value}
|
|||
|
|
type="button"
|
|||
|
|
variant={currentMode === option.value ? "default" : "outline"}
|
|||
|
|
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
|||
|
|
onClick={() => applySourceMode(option.value)}
|
|||
|
|
>
|
|||
|
|
<span className="text-sm font-semibold">{option.label}</span>
|
|||
|
|
<span className="text-[10px] font-normal opacity-80">{option.hint}</span>
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|||
|
|
onClick={() => setShowComboModes((v) => !v)}
|
|||
|
|
>
|
|||
|
|
{showComboModes ? "收合組合模式" : "顯示組合模式(API + Brave、API + 爬蟲…)"}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{showComboModes && (
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{SEARCH_SOURCE_MODE_OPTIONS.filter((o) =>
|
|||
|
|
COMBO_SOURCE_MODES.includes(o.value)
|
|||
|
|
).map((option) => (
|
|||
|
|
<Button
|
|||
|
|
key={option.value}
|
|||
|
|
type="button"
|
|||
|
|
variant={currentMode === option.value ? "default" : "outline"}
|
|||
|
|
size="sm"
|
|||
|
|
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
|||
|
|
onClick={() => applySourceMode(option.value)}
|
|||
|
|
>
|
|||
|
|
<span className="text-sm font-semibold">{option.label}</span>
|
|||
|
|
<span className="text-[10px] font-normal opacity-80">{option.hint}</span>
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<CardTitle>連線預設</CardTitle>
|
|||
|
|
<CardDescription>
|
|||
|
|
{accountName
|
|||
|
|
? `目前帳號「${accountName}」的抓取策略。側欄切換帳號會載入各自的設定。`
|
|||
|
|
: "請先在側欄建立並選擇經營帳號。"}
|
|||
|
|
</CardDescription>
|
|||
|
|
</CardHeader>
|
|||
|
|
<CardContent className="space-y-4">
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
{(
|
|||
|
|
[
|
|||
|
|
["sync", "Chrome 同步", "全爬蟲:海巡 + 留言 + 發文"],
|
|||
|
|
["api", "API Key 優先", "全官方 API:不爬留言"],
|
|||
|
|
["hybrid", "混合模式", "API 海巡 + 瀏覽器留言/發文"],
|
|||
|
|
["custom", "自訂", "手動調整下方開關"],
|
|||
|
|
] as const
|
|||
|
|
).map(([value, label, hint]) => (
|
|||
|
|
<Button
|
|||
|
|
key={value}
|
|||
|
|
type="button"
|
|||
|
|
variant={preset === value ? "default" : "outline"}
|
|||
|
|
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
|||
|
|
onClick={() => applyPreset(value)}
|
|||
|
|
>
|
|||
|
|
<span className="text-sm font-semibold">{label}</span>
|
|||
|
|
<span className="text-[10px] font-normal opacity-80">{hint}</span>
|
|||
|
|
</Button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<FlowSummary settings={settings} />
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="flex w-full items-center justify-between text-left"
|
|||
|
|
onClick={() => setShowAdvanced((v) => !v)}
|
|||
|
|
>
|
|||
|
|
<div>
|
|||
|
|
<CardTitle>進階開關</CardTitle>
|
|||
|
|
<CardDescription>直接控制底層行為。變更後會切換為「自訂」預設。</CardDescription>
|
|||
|
|
</div>
|
|||
|
|
{showAdvanced ? (
|
|||
|
|
<ChevronUp className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|||
|
|
) : (
|
|||
|
|
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
</CardHeader>
|
|||
|
|
{showAdvanced && (
|
|||
|
|
<CardContent className="space-y-3">
|
|||
|
|
{(
|
|||
|
|
[
|
|||
|
|
{
|
|||
|
|
key: "searchViaApi" as const,
|
|||
|
|
title: "海巡走官方 API",
|
|||
|
|
desc: "關閉則用 Playwright 瀏覽器搜尋",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "publishViaApi" as const,
|
|||
|
|
title: "發文走官方 API",
|
|||
|
|
desc: "關閉則只用瀏覽器發文",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "devMode" as const,
|
|||
|
|
title: "瀏覽器模式(devMode)",
|
|||
|
|
desc: "開啟才允許瀏覽器海巡與爬留言",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "scrapeReplies" as const,
|
|||
|
|
title: "抓取他人貼文留言",
|
|||
|
|
desc: "需 devMode 開啟才會實際執行",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "publishHeaded" as const,
|
|||
|
|
title: "發文時顯示瀏覽器視窗",
|
|||
|
|
desc: "除錯用,server 上通常保持關閉",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: "playwrightDebug" as const,
|
|||
|
|
title: "Playwright 除錯截圖",
|
|||
|
|
desc: "卡住時保留瀏覽器截圖",
|
|||
|
|
},
|
|||
|
|
] as const
|
|||
|
|
).map((item) => (
|
|||
|
|
<div
|
|||
|
|
key={item.key}
|
|||
|
|
className="flex items-center justify-between rounded-lg border border-border bg-muted/50 px-3.5 py-3"
|
|||
|
|
>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm font-semibold">{item.title}</p>
|
|||
|
|
<p className="mt-0.5 text-xs text-muted-foreground">{item.desc}</p>
|
|||
|
|
</div>
|
|||
|
|
<Switch
|
|||
|
|
checked={settings[item.key] ?? false}
|
|||
|
|
onCheckedChange={(checked) => {
|
|||
|
|
setPreset("custom");
|
|||
|
|
onChange({ [item.key]: checked });
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
<div className="space-y-2 pt-1">
|
|||
|
|
<Label>每篇熱門文讀取幾則留言</Label>
|
|||
|
|
<Input
|
|||
|
|
type="number"
|
|||
|
|
min={1}
|
|||
|
|
max={30}
|
|||
|
|
value={settings.repliesPerPost ?? 10}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
onChange({ repliesPerPost: parseInt(e.target.value, 10) || 10 })
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CardContent>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|