fix search api
This commit is contained in:
parent
81bf2a2618
commit
1168d49178
10
Makefile
10
Makefile
|
|
@ -8,7 +8,7 @@ PORT ?= 3000
|
||||||
PM2 := $(shell command -v pm2 2>/dev/null)
|
PM2 := $(shell command -v pm2 2>/dev/null)
|
||||||
|
|
||||||
.PHONY: help init db-init install build up start dev stop restart status logs down \
|
.PHONY: help init db-init install build up start dev stop restart status logs down \
|
||||||
playwright-setup save
|
playwright-setup playwright-deps save
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "巡樓 Haixun — 常用指令"
|
@echo "巡樓 Haixun — 常用指令"
|
||||||
|
|
@ -32,6 +32,7 @@ help:
|
||||||
@echo " 其他"
|
@echo " 其他"
|
||||||
@echo " make build next build"
|
@echo " make build next build"
|
||||||
@echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴"
|
@echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴"
|
||||||
|
@echo " make playwright-deps 安裝 Playwright 系統函式庫(apt)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " 環境變數:PORT=$(PORT)(預設 3000)"
|
@echo " 環境變數:PORT=$(PORT)(預設 3000)"
|
||||||
|
|
||||||
|
|
@ -91,5 +92,12 @@ save: check-pm2
|
||||||
@pm2 save
|
@pm2 save
|
||||||
@echo "PM2 程序列表已儲存(搭配 pm2 startup 可開機自啟)"
|
@echo "PM2 程序列表已儲存(搭配 pm2 startup 可開機自啟)"
|
||||||
|
|
||||||
|
playwright-deps:
|
||||||
|
@echo "安裝 Playwright 系統函式庫..."
|
||||||
|
@sudo apt-get install -y libatk1.0-0 libatk-bridge2.0-0t64 libcups2t64 \
|
||||||
|
libdrm2 libdbus-1-3 libxkbcommon-x11-0 libxcomposite1 libxdamage1 \
|
||||||
|
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64
|
||||||
|
@echo "Playwright 系統函式庫安裝完成"
|
||||||
|
|
||||||
playwright-setup:
|
playwright-setup:
|
||||||
@cd "$(ROOT)" && npm run playwright:setup
|
@cd "$(ROOT)" && npm run playwright:setup
|
||||||
|
|
@ -760,6 +760,7 @@ export default function TopicDetailPage() {
|
||||||
onToggle={toggleTag}
|
onToggle={toggleTag}
|
||||||
onSelectAllAccounts={selectAllAccountTags}
|
onSelectAllAccounts={selectAllAccountTags}
|
||||||
hideAccounts={isPlacementGoal(topic.topicGoal)}
|
hideAccounts={isPlacementGoal(topic.topicGoal)}
|
||||||
|
researchMap={researchMap}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export async function POST(request: Request) {
|
||||||
include: {
|
include: {
|
||||||
topic: true,
|
topic: true,
|
||||||
items: {
|
items: {
|
||||||
where: { qualityTier: { not: "EXCLUDE" } },
|
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||||
take: body.limit ?? 8,
|
take: body.limit ?? 8,
|
||||||
include: { replies: { orderBy: { likeCount: "desc" }, take: 5 } },
|
include: { replies: { orderBy: { likeCount: "desc" }, take: 5 } },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
|
||||||
SIMILAR_ACCOUNT_SOURCE_LABELS,
|
SIMILAR_ACCOUNT_SOURCE_LABELS,
|
||||||
threadsProfileUrl,
|
threadsProfileUrl,
|
||||||
type ResearchMap,
|
type ResearchMap,
|
||||||
|
|
@ -177,6 +178,16 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
||||||
{accounts.map((a) => {
|
{accounts.map((a) => {
|
||||||
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
|
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
|
||||||
const sourceLabel = a.source ? SIMILAR_ACCOUNT_SOURCE_LABELS[a.source] : null;
|
const sourceLabel = a.source ? SIMILAR_ACCOUNT_SOURCE_LABELS[a.source] : null;
|
||||||
|
const confidenceLabel = a.confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[a.confidence] : null;
|
||||||
|
const confidenceColor =
|
||||||
|
a.confidence === "high"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
|
||||||
|
: a.confidence === "medium"
|
||||||
|
? "bg-amber-500/10 text-amber-600 border-amber-200"
|
||||||
|
: "bg-muted text-muted-foreground border-border";
|
||||||
|
const daysSinceActive = a.lastActiveAt
|
||||||
|
? Math.floor((Date.now() - new Date(a.lastActiveAt).getTime()) / 86400000)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.username}
|
key={a.username}
|
||||||
|
|
@ -197,12 +208,22 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
||||||
@{a.username}
|
@{a.username}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{confidenceLabel && (
|
||||||
|
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
|
||||||
|
{confidenceLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{sourceLabel && (
|
{sourceLabel && (
|
||||||
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
|
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
|
||||||
{sourceLabel}
|
{sourceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{daysSinceActive !== null && (
|
||||||
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{a.postUrl ? (
|
{a.postUrl ? (
|
||||||
<a
|
<a
|
||||||
href={a.postUrl}
|
href={a.postUrl}
|
||||||
|
|
@ -224,7 +245,7 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
尚未找到相似帳號,可重試分析或在微調面板手動加入 @帳號
|
尚未找到相似帳號(沒有高品質也無中低品質候選),可重試分析或在微調面板手動加入 @帳號
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</MapBlock>
|
</MapBlock>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Check } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { SuggestedTag } from "@/lib/types/research";
|
import { SIMILAR_ACCOUNT_CONFIDENCE_LABELS, type ResearchMap, type SuggestedTag } from "@/lib/types/research";
|
||||||
|
|
||||||
interface SuggestedTagsPickerProps {
|
interface SuggestedTagsPickerProps {
|
||||||
tags: SuggestedTag[];
|
tags: SuggestedTag[];
|
||||||
|
|
@ -13,17 +13,28 @@ interface SuggestedTagsPickerProps {
|
||||||
onSelectAllAccounts?: () => void;
|
onSelectAllAccounts?: () => void;
|
||||||
/** 置入模式不顯示相似帳號標籤 */
|
/** 置入模式不顯示相似帳號標籤 */
|
||||||
hideAccounts?: boolean;
|
hideAccounts?: boolean;
|
||||||
|
researchMap?: ResearchMap | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagRow({
|
function TagRow({
|
||||||
item,
|
item,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
confidence,
|
||||||
}: {
|
}: {
|
||||||
item: SuggestedTag;
|
item: SuggestedTag;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
confidence?: "high" | "medium" | "low" | null;
|
||||||
}) {
|
}) {
|
||||||
|
const confidenceLabel = confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[confidence] : null;
|
||||||
|
const confidenceColor =
|
||||||
|
confidence === "high"
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
|
||||||
|
: confidence === "medium"
|
||||||
|
? "bg-amber-500/10 text-amber-600 border-amber-200"
|
||||||
|
: "bg-muted text-muted-foreground border-border";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -46,6 +57,11 @@ function TagRow({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<p className="text-[13px] font-medium text-foreground">{item.tag}</p>
|
<p className="text-[13px] font-medium text-foreground">{item.tag}</p>
|
||||||
|
{confidenceLabel && (
|
||||||
|
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px]", confidenceColor)}>
|
||||||
|
{confidenceLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{item.searchType && (
|
{item.searchType && (
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||||
{item.searchType}
|
{item.searchType}
|
||||||
|
|
@ -71,7 +87,11 @@ export function SuggestedTagsPicker({
|
||||||
onToggle,
|
onToggle,
|
||||||
onSelectAllAccounts,
|
onSelectAllAccounts,
|
||||||
hideAccounts = false,
|
hideAccounts = false,
|
||||||
|
researchMap,
|
||||||
}: SuggestedTagsPickerProps) {
|
}: SuggestedTagsPickerProps) {
|
||||||
|
const accountConfidence = new Map(
|
||||||
|
(researchMap?.similarAccounts ?? []).map((a) => [`@${a.username.toLowerCase()}`, a.confidence])
|
||||||
|
);
|
||||||
const accountTags = hideAccounts
|
const accountTags = hideAccounts
|
||||||
? []
|
? []
|
||||||
: tags.filter((t) => t.searchType === "帳號" || t.tag.startsWith("@"));
|
: tags.filter((t) => t.searchType === "帳號" || t.tag.startsWith("@"));
|
||||||
|
|
@ -101,6 +121,7 @@ export function SuggestedTagsPicker({
|
||||||
item={item}
|
item={item}
|
||||||
isSelected={selected.includes(item.tag)}
|
isSelected={selected.includes(item.tag)}
|
||||||
onToggle={() => onToggle(item.tag)}
|
onToggle={() => onToggle(item.tag)}
|
||||||
|
confidence={accountConfidence.get(item.tag.toLowerCase())}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { generateObject, generateText } from "ai";
|
import { generateObject, generateText } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ProviderApiKeys } from "./keys";
|
import type { ProviderApiKeys } from "./keys";
|
||||||
|
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
|
||||||
import { withAgentSystem } from "./agent";
|
import { withAgentSystem } from "./agent";
|
||||||
import { buildPersonaPromptBlock } from "./persona";
|
import { buildPersonaPromptBlock } from "./persona";
|
||||||
import { getModel } from "./provider";
|
import { getModel } from "./provider";
|
||||||
|
|
@ -16,7 +17,6 @@ import {
|
||||||
} from "./coerce-matrix";
|
} from "./coerce-matrix";
|
||||||
import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules";
|
import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules";
|
||||||
import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils";
|
import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils";
|
||||||
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
|
|
||||||
|
|
||||||
const matrixSchema = z.object({
|
const matrixSchema = z.object({
|
||||||
rows: z.array(
|
rows: z.array(
|
||||||
|
|
@ -33,9 +33,10 @@ const matrixSchema = z.object({
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 非 OpenCode Go 但 structured output 不穩的模型 */
|
|
||||||
const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]);
|
const TEXT_FIRST_MODELS = new Set(["grok-3", "grok-3-fast"]);
|
||||||
|
|
||||||
|
const BATCH_COUNT = 3;
|
||||||
|
|
||||||
export interface GenerateMatrixInput {
|
export interface GenerateMatrixInput {
|
||||||
topicLabel: string;
|
topicLabel: string;
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -58,8 +59,52 @@ export interface GenerateMatrixInput {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function distributePosts<T>(posts: T[], n: number): T[][] {
|
||||||
|
const batches: T[][] = Array.from({ length: n }, () => []);
|
||||||
|
posts.forEach((post, i) => batches[i % n].push(post));
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMaterialsBlock(posts: GenerateMatrixInput["posts"], defaultQuery: string): string {
|
||||||
|
return posts
|
||||||
|
.map((post, i) => {
|
||||||
|
const repliesBlock =
|
||||||
|
post.replies && post.replies.length > 0
|
||||||
|
? `\n 留言:${post.replies
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`@${sanitizePromptText(r.authorName) || "匿名"}:${sanitizePromptText(r.text).slice(0, 80)}`
|
||||||
|
)
|
||||||
|
.join(" | ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(defaultQuery)}] @${sanitizePromptText(post.authorName) || "匿名"}(${post.likeCount ?? 0}讚)
|
||||||
|
${sanitizePromptText(post.text).slice(0, 300)}
|
||||||
|
連結:${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResearchBlock(researchMap?: ResearchMap | null): string {
|
||||||
|
if (!researchMap) return "";
|
||||||
|
|
||||||
|
const similarAccounts = (researchMap.similarAccounts ?? [])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((a) => ` @${sanitizePromptText(a.username)}${a.reason ? `(${sanitizePromptText(a.reason)})` : ""}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `
|
||||||
|
受眾:${sanitizePromptText(researchMap.audienceSummary)}
|
||||||
|
內容目標:${sanitizePromptText(researchMap.contentGoal)}
|
||||||
|
想回答的問題:${researchMap.questions.map(sanitizePromptText).join("、")}
|
||||||
|
內容支柱:${researchMap.pillars.map(sanitizePromptText).join("、")}
|
||||||
|
${similarAccounts ? `同領域參考帳號:\n${similarAccounts}` : ""}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSystemPrompt(persona?: string | null) {
|
function buildSystemPrompt(persona?: string | null) {
|
||||||
return withAgentSystem(`你是 Threads 內容企劃師。根據篩選後的優質素材,產出「內容矩陣」——一週可發的貼文企劃表。
|
return withAgentSystem(`你是 Threads 內容企劃師。根據參考素材(含優質與中等品質),產出「內容矩陣」——一週可發的貼文企劃表。
|
||||||
|
|
||||||
${buildPersonaPromptBlock(persona)}
|
${buildPersonaPromptBlock(persona)}
|
||||||
|
|
||||||
|
|
@ -72,19 +117,29 @@ ${HASHTAG_WRITING_RULES}
|
||||||
- 每篇對應一個 searchTag(主題標籤)
|
- 每篇對應一個 searchTag(主題標籤)
|
||||||
- sortOrder 從 1 開始遞增
|
- sortOrder 從 1 開始遞增
|
||||||
- 繁體中文台灣用語
|
- 繁體中文台灣用語
|
||||||
- 知識型貼文:不寫未查證的醫療/數據斷言,rationale 標註需網路查證的關鍵句`);
|
- 知識型貼文:不寫未查證的醫療/數據斷言,rationale 標註需網路查證的關鍵句
|
||||||
|
- 部分素材附有[品質說明]:品質較低的仍有參考價值(角度、切點可用),產出時不要直接沿用其內容`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserPrompt(input: GenerateMatrixInput, researchBlock: string, materials: string) {
|
function buildBatchPrompt(
|
||||||
|
input: GenerateMatrixInput,
|
||||||
|
researchBlock: string,
|
||||||
|
materials: string,
|
||||||
|
batchIndex: number,
|
||||||
|
totalBatches: number,
|
||||||
|
thisBatchCount: number
|
||||||
|
) {
|
||||||
return `主題:${sanitizePromptText(input.topicLabel)}
|
return `主題:${sanitizePromptText(input.topicLabel)}
|
||||||
種子關鍵字:${sanitizePromptText(input.query)}
|
種子關鍵字:${sanitizePromptText(input.query)}
|
||||||
${input.brief ? `Brief:${sanitizePromptText(input.brief)}` : ""}
|
${input.brief ? `Brief:${sanitizePromptText(input.brief)}` : ""}
|
||||||
${researchBlock}
|
${researchBlock}
|
||||||
|
|
||||||
優質參考素材:
|
【批次 ${batchIndex + 1}/${totalBatches}】
|
||||||
|
以下為第 ${batchIndex + 1} 批素材(共 ${materials.split("\n\n").length} 篇),請產出 ${thisBatchCount} 篇不同切角,注意與其他批次做出區隔,避免重複。
|
||||||
|
|
||||||
|
參考素材:
|
||||||
${materials}
|
${materials}
|
||||||
|
|
||||||
請產出 ${input.count} 篇內容矩陣。每篇涵蓋不同切角,避免重複。
|
|
||||||
寫作提醒:矩陣裡的 text 必須像創作者親筆,有代表句範例時語感要向範例靠攏。${HASHTAG_USER_REMINDER}`;
|
寫作提醒:矩陣裡的 text 必須像創作者親筆,有代表句範例時語感要向範例靠攏。${HASHTAG_USER_REMINDER}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +173,22 @@ function normalizeRows(rows: MatrixRow[]): MatrixRow[] {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeBatches(batches: MatrixRow[][]): MatrixRow[] {
|
||||||
|
const all = batches.flat();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: MatrixRow[] = [];
|
||||||
|
|
||||||
|
for (const row of all.sort((a, b) => a.sortOrder - b.sortOrder)) {
|
||||||
|
const key = row.angle.slice(0, 30).toLowerCase().replace(/\s+/g, "");
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.map((row, i) => ({ ...row, sortOrder: i + 1 }));
|
||||||
|
}
|
||||||
|
|
||||||
async function generateWithText(
|
async function generateWithText(
|
||||||
model: ReturnType<typeof getModel>,
|
model: ReturnType<typeof getModel>,
|
||||||
system: string,
|
system: string,
|
||||||
|
|
@ -157,76 +228,46 @@ async function generateWithObject(
|
||||||
return normalizeRows(object.rows);
|
return normalizeRows(object.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
|
async function attemptBatch(
|
||||||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
model: ReturnType<typeof getModel>,
|
||||||
|
system: string,
|
||||||
const researchBlock = input.researchMap
|
prompt: string,
|
||||||
? `
|
fallback: { query: string; count: number },
|
||||||
受眾:${sanitizePromptText(input.researchMap.audienceSummary)}
|
provider: string,
|
||||||
內容目標:${sanitizePromptText(input.researchMap.contentGoal)}
|
modelId: string,
|
||||||
想回答的問題:${input.researchMap.questions.map(sanitizePromptText).join("、")}
|
preferText: boolean
|
||||||
內容支柱:${input.researchMap.pillars.map(sanitizePromptText).join("、")}
|
): Promise<MatrixRow[]> {
|
||||||
`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const materials = input.posts
|
|
||||||
.map((post, i) => {
|
|
||||||
const repliesBlock =
|
|
||||||
post.replies && post.replies.length > 0
|
|
||||||
? `\n 留言:${post.replies
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(
|
|
||||||
(r) =>
|
|
||||||
`@${sanitizePromptText(r.authorName) || "匿名"}:${sanitizePromptText(r.text).slice(0, 80)}`
|
|
||||||
)
|
|
||||||
.join(" | ")}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `${i + 1}. [${sanitizePromptText(post.searchTag) || sanitizePromptText(input.query)}] @${sanitizePromptText(post.authorName) || "匿名"}(${post.likeCount ?? 0}讚)
|
|
||||||
${sanitizePromptText(post.text).slice(0, 300)}
|
|
||||||
連結:${sanitizePromptText(post.permalink) || "無"}${repliesBlock}${post.qualityReason ? `\n 品質說明:${sanitizePromptText(post.qualityReason)}` : ""}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
const system = buildSystemPrompt(input.persona);
|
|
||||||
const prompt = buildUserPrompt(input, researchBlock, materials);
|
|
||||||
const fallback = { query: input.query, count: input.count };
|
|
||||||
|
|
||||||
const preferText =
|
|
||||||
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
|
|
||||||
TEXT_FIRST_MODELS.has(input.aiModel);
|
|
||||||
|
|
||||||
let rows: MatrixRow[] | null = null;
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
|
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
|
||||||
? [
|
? [
|
||||||
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
() => generateWithText(model, system, prompt, fallback, provider, modelId),
|
||||||
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
|
||||||
() =>
|
() =>
|
||||||
generateWithText(
|
generateWithText(
|
||||||
model,
|
model,
|
||||||
system,
|
system,
|
||||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`,
|
`${prompt}\n\n上次格式不完整,請務必補齊 ${fallback.count} 篇 rows。`,
|
||||||
fallback,
|
fallback,
|
||||||
input.aiProvider,
|
provider,
|
||||||
input.aiModel
|
modelId
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
|
||||||
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
|
() => generateWithText(model, system, prompt, fallback, provider, modelId),
|
||||||
() =>
|
() =>
|
||||||
generateWithText(
|
generateWithText(
|
||||||
model,
|
model,
|
||||||
system,
|
system,
|
||||||
`${prompt}\n\n上次格式不完整,請務必補齊 ${input.count} 篇 rows。`,
|
`${prompt}\n\n上次格式不完整,請務必補齊 ${fallback.count} 篇 rows。`,
|
||||||
fallback,
|
fallback,
|
||||||
input.aiProvider,
|
provider,
|
||||||
input.aiModel
|
modelId
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let rows: MatrixRow[] | null = null;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
for (const attempt of attempts) {
|
for (const attempt of attempts) {
|
||||||
try {
|
try {
|
||||||
rows = await attempt();
|
rows = await attempt();
|
||||||
|
|
@ -241,4 +282,33 @@ export async function generateContentMatrix(input: GenerateMatrixInput): Promise
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeRows(rows);
|
return normalizeRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
|
||||||
|
if (input.posts.length === 0) {
|
||||||
|
throw new Error("此海巡沒有素材,請重新海巡");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||||||
|
const researchBlock = buildResearchBlock(input.researchMap);
|
||||||
|
const system = buildSystemPrompt(input.persona);
|
||||||
|
const preferText =
|
||||||
|
prefersOpenCodeTextFirst(input.aiProvider, input.aiModel) ||
|
||||||
|
TEXT_FIRST_MODELS.has(input.aiModel);
|
||||||
|
|
||||||
|
const batches = distributePosts(input.posts, BATCH_COUNT);
|
||||||
|
const perBatch = Math.ceil(input.count / BATCH_COUNT);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
batches.map((batchPosts, batchIndex) => {
|
||||||
|
if (batchPosts.length === 0) return Promise.resolve([] as MatrixRow[]);
|
||||||
|
const materials = buildMaterialsBlock(batchPosts, input.query);
|
||||||
|
const prompt = buildBatchPrompt(input, researchBlock, materials, batchIndex, BATCH_COUNT, perBatch);
|
||||||
|
const fallback = { query: input.query, count: perBatch };
|
||||||
|
return attemptBatch(model, system, prompt, fallback, input.aiProvider, input.aiModel, preferText);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = mergeBatches(results);
|
||||||
|
return normalizeRows(merged).slice(0, input.count);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ const researchMapSchema = z.object({
|
||||||
postUrl: z.string().optional(),
|
postUrl: z.string().optional(),
|
||||||
profileUrl: z.string().optional(),
|
profileUrl: z.string().optional(),
|
||||||
source: z.enum(["web", "threads", "scan"]).optional(),
|
source: z.enum(["web", "threads", "scan"]).optional(),
|
||||||
|
confidence: z.enum(["high", "medium", "low"]).optional(),
|
||||||
|
lastActiveAt: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
@ -105,7 +107,11 @@ export async function refineResearchMap(input: RefineResearchMapInput): Promise<
|
||||||
- 精準執行用戶意圖,不要整份重寫除非用戶明確要求
|
- 精準執行用戶意圖,不要整份重寫除非用戶明確要求
|
||||||
- 維持 Threads 搜尋標籤品質(短詞優先、像真人會搜的字、扣緊種子關鍵字)
|
- 維持 Threads 搜尋標籤品質(短詞優先、像真人會搜的字、扣緊種子關鍵字)
|
||||||
- 用戶沒要求刪的標籤盡量保留;複合主題不可拆成過寬單字(如寵物洗毛精≠只搜寵物或洗碗精)
|
- 用戶沒要求刪的標籤盡量保留;複合主題不可拆成過寬單字(如寵物洗毛精≠只搜寵物或洗碗精)
|
||||||
- reply 簡潔說明變更重點,繁體中文台灣用語`;
|
- reply 簡潔說明變更重點,繁體中文台灣用語
|
||||||
|
|
||||||
|
回傳 JSON 格式,須包含兩個欄位:
|
||||||
|
1. reply:字串,2~4 句繁體中文說明本次變更重點
|
||||||
|
2. researchMap:研究地圖物件,格式與「目前研究地圖草稿」相同`;
|
||||||
|
|
||||||
const prompt = buildRefineUserPrompt(input);
|
const prompt = buildRefineUserPrompt(input);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ async function runOutreachTask(
|
||||||
const candidates = await prisma.scanItem.findMany({
|
const candidates = await prisma.scanItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
externalId: { not: null },
|
externalId: { not: null },
|
||||||
qualityTier: { not: "EXCLUDE" },
|
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
|
||||||
outreachTargets: { none: {} },
|
outreachTargets: { none: {} },
|
||||||
scan: { accountId, scanGoal: "placement", createdAt: { gte: since } },
|
scan: { accountId, scanGoal: "placement", createdAt: { gte: since } },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function mapBraveItems(
|
||||||
threadsOnly: boolean
|
threadsOnly: boolean
|
||||||
): SearchResult[] {
|
): SearchResult[] {
|
||||||
return items
|
return items
|
||||||
.filter((item) => item.url && (!threadsOnly || /threads\.com/i.test(item.url)))
|
.filter((item) => item.url && (!threadsOnly || /threads\.(?:com|net)/i.test(item.url)))
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
title: item.title ?? "",
|
title: item.title ?? "",
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function getSearchConfig() {
|
||||||
process.env.BRAVE_SEARCH_BASE_URL?.trim() ||
|
process.env.BRAVE_SEARCH_BASE_URL?.trim() ||
|
||||||
"https://api.search.brave.com/res/v1/web/search",
|
"https://api.search.brave.com/res/v1/web/search",
|
||||||
dailyLimit: envInt("BRAVE_DAILY_LIMIT", 30),
|
dailyLimit: envInt("BRAVE_DAILY_LIMIT", 30),
|
||||||
resultLimit: envInt("BRAVE_RESULT_LIMIT", 10),
|
resultLimit: envInt("BRAVE_RESULT_LIMIT", 20),
|
||||||
cacheTtlMs: parseDurationMs(process.env.BRAVE_CACHE_TTL, 4 * 3_600_000),
|
cacheTtlMs: parseDurationMs(process.env.BRAVE_CACHE_TTL, 4 * 3_600_000),
|
||||||
scanMaxQueries: envInt("SCAN_BRAVE_MAX_QUERIES", 8),
|
scanMaxQueries: envInt("SCAN_BRAVE_MAX_QUERIES", 8),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
normalizeThreadsPostUrl,
|
normalizeThreadsPostUrl,
|
||||||
normalizeUsername,
|
normalizeUsername,
|
||||||
threadsProfileUrl,
|
threadsProfileUrl,
|
||||||
|
type AccountConfidence,
|
||||||
type SimilarAccount,
|
type SimilarAccount,
|
||||||
} from "@/lib/types/research";
|
} from "@/lib/types/research";
|
||||||
|
|
||||||
|
|
@ -102,6 +103,16 @@ function buildDiscoverAnchor(ctx: DiscoverContext): DiscoverAnchor {
|
||||||
return { ...base, pillars, specificTags };
|
return { ...base, pillars, specificTags };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assignConfidence(candidate: AccountCandidate): AccountConfidence {
|
||||||
|
if (candidate.score > 20 && (candidate.source === "threads" || candidate.source === "scan")) {
|
||||||
|
return "high";
|
||||||
|
}
|
||||||
|
if (candidate.score > 10 || (candidate.aiScore ?? 0) > 0.5) {
|
||||||
|
return "medium";
|
||||||
|
}
|
||||||
|
return "low";
|
||||||
|
}
|
||||||
|
|
||||||
function extractTagsFromText(text: string): string[] {
|
function extractTagsFromText(text: string): string[] {
|
||||||
const found = new Set<string>();
|
const found = new Set<string>();
|
||||||
for (const match of text.match(/#[\w\u4e00-\u9fff]{2,24}/g) ?? []) {
|
for (const match of text.match(/#[\w\u4e00-\u9fff]{2,24}/g) ?? []) {
|
||||||
|
|
@ -136,7 +147,6 @@ function addCandidate(
|
||||||
if (!isValidUsername(clean)) return;
|
if (!isValidUsername(clean)) return;
|
||||||
|
|
||||||
const relevance = scoreTopicRelevance(params.reason, params.anchor);
|
const relevance = scoreTopicRelevance(params.reason, params.anchor);
|
||||||
if (relevance < 3) return;
|
|
||||||
|
|
||||||
const key = clean.toLowerCase();
|
const key = clean.toLowerCase();
|
||||||
const existing = map.get(key);
|
const existing = map.get(key);
|
||||||
|
|
@ -167,10 +177,13 @@ function addCandidate(
|
||||||
function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): string[] {
|
function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): string[] {
|
||||||
const quoted = `"${anchor.corePhrase}"`;
|
const quoted = `"${anchor.corePhrase}"`;
|
||||||
const queries = [
|
const queries = [
|
||||||
`site:threads.com ${quoted}`,
|
|
||||||
`site:threads.net ${quoted}`,
|
`site:threads.net ${quoted}`,
|
||||||
`threads ${quoted} 帳號`,
|
`threads ${quoted} 帳號`,
|
||||||
`threads ${quoted} 創作者`,
|
`threads ${quoted} 創作者`,
|
||||||
|
`site:threads.net ${quoted} 推薦`,
|
||||||
|
`site:threads.net ${quoted} 心得`,
|
||||||
|
`${quoted} 創作者 threads`,
|
||||||
|
`"${anchor.corePhrase}" site:threads.net`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const briefHint = brief?.trim().slice(0, 24) ?? "";
|
const briefHint = brief?.trim().slice(0, 24) ?? "";
|
||||||
|
|
@ -180,7 +193,8 @@ function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): s
|
||||||
|
|
||||||
for (const pillar of anchor.pillars.slice(0, 2)) {
|
for (const pillar of anchor.pillars.slice(0, 2)) {
|
||||||
if (pillar.length >= 4 && scoreTopicRelevance(pillar, anchor) >= 6) {
|
if (pillar.length >= 4 && scoreTopicRelevance(pillar, anchor) >= 6) {
|
||||||
queries.push(`site:threads.com "${pillar}"`);
|
queries.push(`site:threads.net "${pillar}"`);
|
||||||
|
queries.push(`threads "${pillar}" 推薦`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,7 +224,7 @@ async function discoverFromWebSearch(
|
||||||
const map = new Map<string, AccountCandidate>();
|
const map = new Map<string, AccountCandidate>();
|
||||||
const queries = buildWebSearchQueries(anchor, brief);
|
const queries = buildWebSearchQueries(anchor, brief);
|
||||||
|
|
||||||
const perQueryLimit = 8;
|
const perQueryLimit = 15;
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
queries.map((q) =>
|
queries.map((q) =>
|
||||||
searchWebThorough(q, perQueryLimit, {
|
searchWebThorough(q, perQueryLimit, {
|
||||||
|
|
@ -225,7 +239,6 @@ async function discoverFromWebSearch(
|
||||||
for (const item of batch.results) {
|
for (const item of batch.results) {
|
||||||
const blob = `${item.link} ${item.title} ${item.snippet}`;
|
const blob = `${item.link} ${item.title} ${item.snippet}`;
|
||||||
const relevance = scoreTopicRelevance(blob, anchor);
|
const relevance = scoreTopicRelevance(blob, anchor);
|
||||||
if (relevance < 3) continue;
|
|
||||||
|
|
||||||
const tags = extractTagsFromText(blob);
|
const tags = extractTagsFromText(blob);
|
||||||
for (const username of extractUsernamesFromText(blob)) {
|
for (const username of extractUsernamesFromText(blob)) {
|
||||||
|
|
@ -267,7 +280,6 @@ async function discoverFromThreadsSearch(
|
||||||
if (!post.authorName) continue;
|
if (!post.authorName) continue;
|
||||||
const postText = post.text.trim();
|
const postText = post.text.trim();
|
||||||
const relevance = scoreTopicRelevance(postText, anchor);
|
const relevance = scoreTopicRelevance(postText, anchor);
|
||||||
if (relevance < 3) continue;
|
|
||||||
|
|
||||||
addCandidate(map, post.authorName, {
|
addCandidate(map, post.authorName, {
|
||||||
reason:
|
reason:
|
||||||
|
|
@ -290,15 +302,13 @@ async function discoverFromThreadsSearch(
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankCandidates(candidates: AccountCandidate[]): AccountCandidate[] {
|
function rankCandidates(candidates: AccountCandidate[]): AccountCandidate[] {
|
||||||
return candidates
|
return candidates.sort(
|
||||||
.filter((c) => c.relevance >= 3)
|
(a, b) =>
|
||||||
.sort(
|
b.score +
|
||||||
(a, b) =>
|
b.relevance * 2 +
|
||||||
b.score +
|
(b.aiScore ?? 0) * 3 -
|
||||||
b.relevance * 2 +
|
(a.score + a.relevance * 2 + (a.aiScore ?? 0) * 3)
|
||||||
(b.aiScore ?? 0) * 3 -
|
);
|
||||||
(a.score + a.relevance * 2 + (a.aiScore ?? 0) * 3)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyAiRelevanceFilter(
|
async function applyAiRelevanceFilter(
|
||||||
|
|
@ -334,18 +344,24 @@ async function applyAiRelevanceFilter(
|
||||||
apiKeys: ai.apiKeys,
|
apiKeys: ai.apiKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
return candidates
|
return candidates.map((c) => {
|
||||||
.map((c) => {
|
const verdict = verdicts.get(c.username.toLowerCase());
|
||||||
const verdict = verdicts.get(c.username.toLowerCase());
|
if (!verdict) return c;
|
||||||
if (!verdict) return c;
|
return {
|
||||||
return {
|
...c,
|
||||||
...c,
|
aiScore: verdict.score,
|
||||||
aiScore: verdict.score,
|
aiReason: verdict.reason,
|
||||||
aiReason: verdict.reason,
|
relevance: verdict.relevant
|
||||||
relevance: verdict.relevant ? c.relevance + Math.round(verdict.score * 4) : -100,
|
? c.relevance + Math.round(verdict.score * 4)
|
||||||
};
|
: c.relevance,
|
||||||
})
|
};
|
||||||
.filter((c) => c.relevance >= 3);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyAccountConsistency(
|
||||||
|
candidates: AccountCandidate[]
|
||||||
|
): AccountCandidate[] {
|
||||||
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSimilarAccounts(candidates: AccountCandidate[], limit: number): SimilarAccount[] {
|
function toSimilarAccounts(candidates: AccountCandidate[], limit: number): SimilarAccount[] {
|
||||||
|
|
@ -355,10 +371,10 @@ function toSimilarAccounts(candidates: AccountCandidate[], limit: number): Simil
|
||||||
source: c.source,
|
source: c.source,
|
||||||
profileUrl: threadsProfileUrl(c.username) ?? undefined,
|
profileUrl: threadsProfileUrl(c.username) ?? undefined,
|
||||||
postUrl: c.postUrl,
|
postUrl: c.postUrl,
|
||||||
|
confidence: assignConfidence(c),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 優先瀏覽器爬蟲,不足時 Brave 網搜補充;不讓 AI 捏造 username */
|
|
||||||
export async function discoverSimilarAccounts(params: {
|
export async function discoverSimilarAccounts(params: {
|
||||||
label: string;
|
label: string;
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -378,7 +394,6 @@ export async function discoverSimilarAccounts(params: {
|
||||||
|
|
||||||
const merged = new Map<string, AccountCandidate>();
|
const merged = new Map<string, AccountCandidate>();
|
||||||
|
|
||||||
// 1. 瀏覽器爬蟲(Threads 站內搜尋)
|
|
||||||
if (params.storageState) {
|
if (params.storageState) {
|
||||||
const threadsCandidates = await discoverFromThreadsSearch(
|
const threadsCandidates = await discoverFromThreadsSearch(
|
||||||
params.storageState,
|
params.storageState,
|
||||||
|
|
@ -390,21 +405,17 @@ export async function discoverSimilarAccounts(params: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Brave 網搜補充(結果不足時)
|
const webCandidates = await discoverFromWebSearch(anchor, params.brief);
|
||||||
const rankedSoFar = rankCandidates(Array.from(merged.values()));
|
for (const c of webCandidates) {
|
||||||
if (rankedSoFar.length < 3) {
|
const key = c.username.toLowerCase();
|
||||||
const webCandidates = await discoverFromWebSearch(anchor, params.brief);
|
const existing = merged.get(key);
|
||||||
for (const c of webCandidates) {
|
if (existing) {
|
||||||
const key = c.username.toLowerCase();
|
existing.score += c.score;
|
||||||
const existing = merged.get(key);
|
existing.relevance = Math.max(existing.relevance, c.relevance);
|
||||||
if (existing) {
|
if (c.postUrl) existing.postUrl = c.postUrl;
|
||||||
existing.score += c.score;
|
if (c.reason.length > existing.reason.length) existing.reason = c.reason;
|
||||||
existing.relevance = Math.max(existing.relevance, c.relevance);
|
} else {
|
||||||
if (c.postUrl) existing.postUrl = c.postUrl;
|
merged.set(key, c);
|
||||||
if (c.reason.length > existing.reason.length) existing.reason = c.reason;
|
|
||||||
} else {
|
|
||||||
merged.set(key, c);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,5 +430,8 @@ export async function discoverSimilarAccounts(params: {
|
||||||
sorted = rankCandidates(sorted);
|
sorted = rankCandidates(sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sorted = verifyAccountConsistency(sorted);
|
||||||
|
sorted = rankCandidates(sorted);
|
||||||
|
|
||||||
return toSimilarAccounts(sorted, limit);
|
return toSimilarAccounts(sorted, limit);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export async function generateDraftsForScan(scanId: string) {
|
||||||
include: {
|
include: {
|
||||||
topic: true,
|
topic: true,
|
||||||
items: {
|
items: {
|
||||||
where: { qualityTier: { not: "EXCLUDE" } },
|
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||||
take: 8,
|
take: 8,
|
||||||
include: {
|
include: {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ export async function generateMatrixForScan(scanId: string, count?: number) {
|
||||||
include: {
|
include: {
|
||||||
topic: true,
|
topic: true,
|
||||||
items: {
|
items: {
|
||||||
where: { qualityTier: { not: "EXCLUDE" } },
|
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||||
take: 12,
|
take: 24,
|
||||||
include: {
|
include: {
|
||||||
replies: { orderBy: { likeCount: "desc" }, take: 5 },
|
replies: { orderBy: { likeCount: "desc" }, take: 5 },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ function buildPlacementKeywordQueries(tag: string, meta?: TagSearchMeta): string
|
||||||
meta?.searchIntent === "求助" ||
|
meta?.searchIntent === "求助" ||
|
||||||
meta?.searchIntent === "痛點";
|
meta?.searchIntent === "痛點";
|
||||||
const intent = isNeedTag ? "求推薦" : "請問";
|
const intent = isNeedTag ? "求推薦" : "請問";
|
||||||
return [`site:threads.com "${tag}" ${intent} after:${after}`];
|
return [`site:threads.net "${tag}" ${intent} after:${after}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBraveQueryCap(placementMode: boolean): number {
|
function resolveBraveQueryCap(placementMode: boolean): number {
|
||||||
|
|
@ -102,7 +102,7 @@ function buildKeywordQueries(
|
||||||
meta?: TagSearchMeta
|
meta?: TagSearchMeta
|
||||||
): string[] {
|
): string[] {
|
||||||
if (placementMode) return buildPlacementKeywordQueries(tag, meta);
|
if (placementMode) return buildPlacementKeywordQueries(tag, meta);
|
||||||
return [`site:threads.com "${tag}"`, `site:threads.net "${tag}"`];
|
return [`site:threads.net "${tag}"`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function passesPlacementWebFilter(
|
function passesPlacementWebFilter(
|
||||||
|
|
@ -230,7 +230,7 @@ export async function discoverPostsViaWebSearch(
|
||||||
tags: string[],
|
tags: string[],
|
||||||
options?: WebDiscoverOptions
|
options?: WebDiscoverOptions
|
||||||
): Promise<WebDiscoveredPost[]> {
|
): Promise<WebDiscoveredPost[]> {
|
||||||
const perQueryLimit = options?.perQueryLimit ?? 10;
|
const perQueryLimit = options?.perQueryLimit ?? 15;
|
||||||
const placementMode = options?.placementMode ?? false;
|
const placementMode = options?.placementMode ?? false;
|
||||||
const contentBand = options?.contentBand;
|
const contentBand = options?.contentBand;
|
||||||
const concurrency = options?.concurrency ?? 2;
|
const concurrency = options?.concurrency ?? 2;
|
||||||
|
|
@ -273,7 +273,7 @@ export async function discoverPostsViaWebSearch(
|
||||||
for (const phrase of bandPhrases) {
|
for (const phrase of bandPhrases) {
|
||||||
jobs.push({
|
jobs.push({
|
||||||
tag: phrase,
|
tag: phrase,
|
||||||
query: `site:threads.com "${phrase}" 求推薦 after:${after}`,
|
query: `site:threads.net "${phrase}" 求推薦 after:${after}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +355,7 @@ export async function discoverPostsFromSimilarAccounts(
|
||||||
const placementMode = options?.placementMode ?? false;
|
const placementMode = options?.placementMode ?? false;
|
||||||
const braveOptions = resolveBraveSearchOptions(placementMode, options?.keywordPriority);
|
const braveOptions = resolveBraveSearchOptions(placementMode, options?.keywordPriority);
|
||||||
const useBrave = braveOptions.priority === "high";
|
const useBrave = braveOptions.priority === "high";
|
||||||
const perAccountLimit = options?.perAccountLimit ?? 10;
|
const perAccountLimit = options?.perAccountLimit ?? 15;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const posts: WebDiscoveredPost[] = [];
|
const posts: WebDiscoveredPost[] = [];
|
||||||
|
|
||||||
|
|
@ -369,12 +369,11 @@ export async function discoverPostsFromSimilarAccounts(
|
||||||
const after = placementMode ? ` after:${formatGoogleAfterDate(PLACEMENT_WEB_SEARCH_MAX_AGE_DAYS)}` : "";
|
const after = placementMode ? ` after:${formatGoogleAfterDate(PLACEMENT_WEB_SEARCH_MAX_AGE_DAYS)}` : "";
|
||||||
const queries = placementMode
|
const queries = placementMode
|
||||||
? [
|
? [
|
||||||
`site:threads.com/@${username} 求推薦${after}`,
|
`site:threads.net/@${username} 求推薦${after}`,
|
||||||
`site:threads.com/@${username} 請益${after}`,
|
`site:threads.net/@${username} 請益${after}`,
|
||||||
`site:threads.com/@${username}${after}`,
|
|
||||||
`site:threads.net/@${username}${after}`,
|
`site:threads.net/@${username}${after}`,
|
||||||
]
|
]
|
||||||
: [`site:threads.com/@${username}`, `site:threads.net/@${username}`];
|
: [`site:threads.net/@${username}`];
|
||||||
|
|
||||||
if (!useBrave) continue;
|
if (!useBrave) continue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { getRepliesParallel } from "@/lib/threads-browser/replies";
|
||||||
import { keywordSearchViaThreadsApi } from "@/lib/threads-api";
|
import { keywordSearchViaThreadsApi } from "@/lib/threads-api";
|
||||||
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
|
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
|
||||||
import { computePlacementScore, type RankedPost } from "@/lib/ranking";
|
import { computePlacementScore, type RankedPost } from "@/lib/ranking";
|
||||||
import { parseResearchMap, parseSelectedTags } from "@/lib/types/research";
|
import { parseResearchMap, parseSelectedTags, threadsProfileUrl, type SimilarAccount } from "@/lib/types/research";
|
||||||
import { humanDelay } from "@/lib/utils";
|
import { humanDelay } from "@/lib/utils";
|
||||||
import { runWithConcurrency } from "@/lib/utils/concurrency";
|
import { runWithConcurrency } from "@/lib/utils/concurrency";
|
||||||
import {
|
import {
|
||||||
|
|
@ -255,7 +255,7 @@ export async function runScanForTopic(
|
||||||
const accountWebTargets = [...accountTargets.values()];
|
const accountWebTargets = [...accountTargets.values()];
|
||||||
const [keywordWebPosts, accountWebPosts] = await Promise.all([
|
const [keywordWebPosts, accountWebPosts] = await Promise.all([
|
||||||
discoverPostsViaWebSearch(webSearchTags, {
|
discoverPostsViaWebSearch(webSearchTags, {
|
||||||
perQueryLimit: placementMode ? 8 : 10,
|
perQueryLimit: 15,
|
||||||
placementMode,
|
placementMode,
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
tagMeta,
|
tagMeta,
|
||||||
|
|
@ -270,9 +270,10 @@ export async function runScanForTopic(
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
!placementMode && accountWebTargets.length > 0
|
!placementMode && accountWebTargets.length > 0
|
||||||
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 8), {
|
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 4), {
|
||||||
perAccountLimit: placementMode ? 12 : 10,
|
perAccountLimit: 20,
|
||||||
placementMode,
|
placementMode,
|
||||||
|
keywordPriority: braveKeywordPriority,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
@ -839,10 +840,13 @@ export async function runScanForTopic(
|
||||||
await applyQualityFilter(scan.id);
|
await applyQualityFilter(scan.id);
|
||||||
|
|
||||||
const visibleCount = await prisma.scanItem.count({
|
const visibleCount = await prisma.scanItem.count({
|
||||||
where: { scanId: scan.id, qualityTier: { not: "EXCLUDE" } },
|
where: { scanId: scan.id, OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||||
});
|
});
|
||||||
|
|
||||||
setTaskStatus(progressDetail, "quality", { status: "done", found: visibleCount });
|
setTaskStatus(progressDetail, "quality", { status: "done", found: visibleCount });
|
||||||
|
|
||||||
|
await enrichAccountsFromScan(scan.id, topic.id);
|
||||||
|
|
||||||
progressDetail.summary = `完成 · ${ranked.length} 篇 · ${repliesCount} 則留言`;
|
progressDetail.summary = `完成 · ${ranked.length} 篇 · ${repliesCount} 則留言`;
|
||||||
await report(progressDetail.summary, progressDetail);
|
await report(progressDetail.summary, progressDetail);
|
||||||
|
|
||||||
|
|
@ -902,6 +906,86 @@ export async function applyQualityFilter(scanId: string) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enrichAccountsFromScan(scanId: string, topicId: string) {
|
||||||
|
const scan = await prisma.scan.findUnique({
|
||||||
|
where: { id: scanId },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
|
||||||
|
orderBy: { combinedScore: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!scan || scan.items.length === 0) return;
|
||||||
|
|
||||||
|
const cutoff = Math.max(3, Math.ceil(scan.items.length * 0.3));
|
||||||
|
const topItems = scan.items.slice(0, cutoff);
|
||||||
|
const authorMap = new Map<string, { count: number; maxScore: number; latestPost: Date; reason: string }>();
|
||||||
|
|
||||||
|
for (const item of topItems) {
|
||||||
|
if (!item.authorName) continue;
|
||||||
|
const key = item.authorName.toLowerCase();
|
||||||
|
const existing = authorMap.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.count++;
|
||||||
|
if ((item.combinedScore ?? item.score) > existing.maxScore) {
|
||||||
|
existing.maxScore = item.combinedScore ?? item.score;
|
||||||
|
}
|
||||||
|
if (item.postedAt && item.postedAt > existing.latestPost) {
|
||||||
|
existing.latestPost = item.postedAt;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
authorMap.set(key, {
|
||||||
|
count: 1,
|
||||||
|
maxScore: item.combinedScore ?? item.score,
|
||||||
|
latestPost: item.postedAt ?? new Date(0),
|
||||||
|
reason: item.qualityReason || item.text.slice(0, 80) || "海巡發現的高品質作者",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topic = await prisma.topic.findUnique({ where: { id: topicId } });
|
||||||
|
if (!topic) return;
|
||||||
|
|
||||||
|
const existingMap = parseResearchMap(topic.researchMap);
|
||||||
|
if (!existingMap) return;
|
||||||
|
|
||||||
|
const existingAccounts = existingMap.similarAccounts ?? [];
|
||||||
|
const existingByKey = new Map(existingAccounts.map((a) => [a.username.toLowerCase(), a]));
|
||||||
|
|
||||||
|
const newAccounts: SimilarAccount[] = [];
|
||||||
|
for (const [key, data] of authorMap) {
|
||||||
|
const existing = existingByKey.get(key);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.confidence || existing.confidence === "low") {
|
||||||
|
existing.confidence = data.count >= 2 ? "high" : "medium";
|
||||||
|
}
|
||||||
|
if (data.latestPost > new Date(existing.lastActiveAt ?? 0)) {
|
||||||
|
existing.lastActiveAt = data.latestPost.toISOString();
|
||||||
|
}
|
||||||
|
existingByKey.set(key, existing);
|
||||||
|
} else {
|
||||||
|
newAccounts.push({
|
||||||
|
username: key,
|
||||||
|
reason: data.reason,
|
||||||
|
source: "scan",
|
||||||
|
profileUrl: threadsProfileUrl(key) ?? undefined,
|
||||||
|
confidence: data.count >= 2 ? "high" : "medium",
|
||||||
|
lastActiveAt: data.latestPost.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAccounts.length === 0) return;
|
||||||
|
|
||||||
|
const merged = [...newAccounts, ...existingByKey.values()];
|
||||||
|
|
||||||
|
await prisma.topic.update({
|
||||||
|
where: { id: topicId },
|
||||||
|
data: { researchMap: JSON.stringify({ ...existingMap, similarAccounts: merged }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function runScanForAllActiveTopics(accountId?: string | null) {
|
export async function runScanForAllActiveTopics(accountId?: string | null) {
|
||||||
const topics = await prisma.topic.findMany({
|
const topics = await prisma.topic.findMany({
|
||||||
where: { active: true, ...(accountId ? { accountId } : {}) },
|
where: { active: true, ...(accountId ? { accountId } : {}) },
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export async function analyzeScanTopViral(scanId: string, limit = 5) {
|
||||||
const items = await prisma.scanItem.findMany({
|
const items = await prisma.scanItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
scanId,
|
scanId,
|
||||||
qualityTier: { not: "EXCLUDE" },
|
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
|
||||||
},
|
},
|
||||||
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
|
||||||
take: limit,
|
take: limit,
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,17 @@ export interface SuggestedTag {
|
||||||
searchType?: SearchTagType;
|
searchType?: SearchTagType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AccountConfidence = "high" | "medium" | "low";
|
||||||
|
|
||||||
export interface SimilarAccount {
|
export interface SimilarAccount {
|
||||||
username: string;
|
username: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
/** web=網路搜尋找到的真實連結;threads=Threads 關鍵字搜尋熱門作者 */
|
/** web=網路搜尋找到的真實連結;threads=Threads 關鍵字搜尋熱門作者 */
|
||||||
source?: "web" | "threads" | "scan";
|
source?: "web" | "threads" | "scan";
|
||||||
profileUrl?: string;
|
profileUrl?: string;
|
||||||
/** 發現此帳號時參考的那篇 Threads 貼文(若有) */
|
|
||||||
postUrl?: string;
|
postUrl?: string;
|
||||||
|
confidence?: AccountConfidence;
|
||||||
|
lastActiveAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResearchMap {
|
export interface ResearchMap {
|
||||||
|
|
@ -125,4 +128,13 @@ export const SIMILAR_ACCOUNT_SOURCE_LABELS: Record<
|
||||||
web: "網路搜尋",
|
web: "網路搜尋",
|
||||||
threads: "Threads 搜尋",
|
threads: "Threads 搜尋",
|
||||||
scan: "海巡發現",
|
scan: "海巡發現",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIMILAR_ACCOUNT_CONFIDENCE_LABELS: Record<
|
||||||
|
NonNullable<SimilarAccount["confidence"]>,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
high: "高",
|
||||||
|
medium: "中",
|
||||||
|
low: "低",
|
||||||
};
|
};
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue