fix search api

This commit is contained in:
王性驊 2026-06-21 16:28:26 +00:00
parent 81bf2a2618
commit 1168d49178
18 changed files with 369 additions and 133 deletions

View File

@ -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

View File

@ -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>

View File

@ -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 } },

View File

@ -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>

View File

@ -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>

View File

@ -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);
}

View File

@ -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. reply2~4
2. researchMap稿`;
const prompt = buildRefineUserPrompt(input); const prompt = buildRefineUserPrompt(input);

View File

@ -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 } },
}, },

View File

@ -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 ?? "",

View File

@ -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),
}, },

View File

@ -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);
} }

View File

@ -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: {

View File

@ -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 },
}, },

View File

@ -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;

View File

@ -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 } : {}) },

View File

@ -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,

View File

@ -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