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)
.PHONY: help init db-init install build up start dev stop restart status logs down \
playwright-setup save
playwright-setup playwright-deps save
help:
@echo "巡樓 Haixun — 常用指令"
@ -32,6 +32,7 @@ help:
@echo " 其他"
@echo " make build next build"
@echo " make playwright-setup 安裝 Chromium 與 Playwright 依賴"
@echo " make playwright-deps 安裝 Playwright 系統函式庫apt"
@echo ""
@echo " 環境變數PORT=$(PORT)(預設 3000"
@ -91,5 +92,12 @@ save: check-pm2
@pm2 save
@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:
@cd "$(ROOT)" && npm run playwright:setup

View File

@ -760,6 +760,7 @@ export default function TopicDetailPage() {
onToggle={toggleTag}
onSelectAllAccounts={selectAllAccountTags}
hideAccounts={isPlacementGoal(topic.topicGoal)}
researchMap={researchMap}
/>
</CardContent>
</Card>

View File

@ -59,7 +59,7 @@ export async function POST(request: Request) {
include: {
topic: true,
items: {
where: { qualityTier: { not: "EXCLUDE" } },
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: body.limit ?? 8,
include: { replies: { orderBy: { likeCount: "desc" }, take: 5 } },

View File

@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
SIMILAR_ACCOUNT_SOURCE_LABELS,
threadsProfileUrl,
type ResearchMap,
@ -177,6 +178,16 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
{accounts.map((a) => {
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
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 (
<div
key={a.username}
@ -197,12 +208,22 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
@{a.username}
</span>
)}
{confidenceLabel && (
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
{confidenceLabel}
</Badge>
)}
{sourceLabel && (
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
{sourceLabel}
</Badge>
)}
</div>
{daysSinceActive !== null && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
</p>
)}
{a.postUrl ? (
<a
href={a.postUrl}
@ -224,7 +245,7 @@ export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMap
</div>
) : (
<p className="text-[13px] text-muted-foreground">
調 @帳號
調 @帳號
</p>
)}
</MapBlock>

View File

@ -4,7 +4,7 @@ import { Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 {
tags: SuggestedTag[];
@ -13,17 +13,28 @@ interface SuggestedTagsPickerProps {
onSelectAllAccounts?: () => void;
/** 置入模式不顯示相似帳號標籤 */
hideAccounts?: boolean;
researchMap?: ResearchMap | null;
}
function TagRow({
item,
isSelected,
onToggle,
confidence,
}: {
item: SuggestedTag;
isSelected: boolean;
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 (
<button
type="button"
@ -46,6 +57,11 @@ function TagRow({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<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 && (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{item.searchType}
@ -71,7 +87,11 @@ export function SuggestedTagsPicker({
onToggle,
onSelectAllAccounts,
hideAccounts = false,
researchMap,
}: SuggestedTagsPickerProps) {
const accountConfidence = new Map(
(researchMap?.similarAccounts ?? []).map((a) => [`@${a.username.toLowerCase()}`, a.confidence])
);
const accountTags = hideAccounts
? []
: tags.filter((t) => t.searchType === "帳號" || t.tag.startsWith("@"));
@ -101,6 +121,7 @@ export function SuggestedTagsPicker({
item={item}
isSelected={selected.includes(item.tag)}
onToggle={() => onToggle(item.tag)}
confidence={accountConfidence.get(item.tag.toLowerCase())}
/>
))}
</div>

View File

@ -1,6 +1,7 @@
import { generateObject, generateText } from "ai";
import { z } from "zod";
import type { ProviderApiKeys } from "./keys";
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
import { withAgentSystem } from "./agent";
import { buildPersonaPromptBlock } from "./persona";
import { getModel } from "./provider";
@ -16,7 +17,6 @@ import {
} from "./coerce-matrix";
import { HASHTAG_USER_REMINDER, HASHTAG_WRITING_RULES } from "./hashtag-rules";
import { sanitizePromptText, THREADS_MAX_CHARS } from "@/lib/utils";
import type { MatrixRow, ResearchMap } from "@/lib/types/research";
const matrixSchema = z.object({
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 BATCH_COUNT = 3;
export interface GenerateMatrixInput {
topicLabel: 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) {
return withAgentSystem(`你是 Threads 內容企劃師。根據篩選後的優質素材,產出「內容矩陣」——一週可發的貼文企劃表。
return withAgentSystem(`你是 Threads 內容企劃師。根據參考素材(含優質與中等品質),產出「內容矩陣」——一週可發的貼文企劃表。
${buildPersonaPromptBlock(persona)}
@ -72,19 +117,29 @@ ${HASHTAG_WRITING_RULES}
- searchTag
- 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)}
${sanitizePromptText(input.query)}
${input.brief ? `Brief${sanitizePromptText(input.brief)}` : ""}
${researchBlock}
${batchIndex + 1}/${totalBatches}
${batchIndex + 1} ${materials.split("\n\n").length} ${thisBatchCount}
${materials}
${input.count}
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(
model: ReturnType<typeof getModel>,
system: string,
@ -157,76 +228,46 @@ async function generateWithObject(
return normalizeRows(object.rows);
}
export async function generateContentMatrix(input: GenerateMatrixInput): Promise<MatrixRow[]> {
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
const researchBlock = input.researchMap
? `
${sanitizePromptText(input.researchMap.audienceSummary)}
${sanitizePromptText(input.researchMap.contentGoal)}
${input.researchMap.questions.map(sanitizePromptText).join("、")}
${input.researchMap.pillars.map(sanitizePromptText).join("、")}
`
: "";
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;
async function attemptBatch(
model: ReturnType<typeof getModel>,
system: string,
prompt: string,
fallback: { query: string; count: number },
provider: string,
modelId: string,
preferText: boolean
): Promise<MatrixRow[]> {
const attempts: Array<() => Promise<MatrixRow[]>> = preferText
? [
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithText(model, system, prompt, fallback, provider, modelId),
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
() =>
generateWithText(
model,
system,
`${prompt}\n\n上次格式不完整請務必補齊 ${input.count} 篇 rows。`,
`${prompt}\n\n上次格式不完整請務必補齊 ${fallback.count} 篇 rows。`,
fallback,
input.aiProvider,
input.aiModel
provider,
modelId
),
]
: [
() => generateWithObject(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithText(model, system, prompt, fallback, input.aiProvider, input.aiModel),
() => generateWithObject(model, system, prompt, fallback, provider, modelId),
() => generateWithText(model, system, prompt, fallback, provider, modelId),
() =>
generateWithText(
model,
system,
`${prompt}\n\n上次格式不完整請務必補齊 ${input.count} 篇 rows。`,
`${prompt}\n\n上次格式不完整請務必補齊 ${fallback.count} 篇 rows。`,
fallback,
input.aiProvider,
input.aiModel
provider,
modelId
),
];
let rows: MatrixRow[] | null = null;
let lastError: unknown;
for (const attempt of attempts) {
try {
rows = await attempt();
@ -242,3 +283,32 @@ export async function generateContentMatrix(input: GenerateMatrixInput): Promise
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(),
profileUrl: z.string().optional(),
source: z.enum(["web", "threads", "scan"]).optional(),
confidence: z.enum(["high", "medium", "low"]).optional(),
lastActiveAt: z.string().optional(),
})
)
.optional(),
@ -105,7 +107,11 @@ export async function refineResearchMap(input: RefineResearchMapInput): Promise<
-
- Threads
-
- reply `;
- reply
JSON
1. reply2~4
2. researchMap稿`;
const prompt = buildRefineUserPrompt(input);

View File

@ -283,7 +283,7 @@ async function runOutreachTask(
const candidates = await prisma.scanItem.findMany({
where: {
externalId: { not: null },
qualityTier: { not: "EXCLUDE" },
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
outreachTargets: { none: {} },
scan: { accountId, scanGoal: "placement", createdAt: { gte: since } },
},

View File

@ -33,7 +33,7 @@ function mapBraveItems(
threadsOnly: boolean
): SearchResult[] {
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)
.map((item) => ({
title: item.title ?? "",

View File

@ -55,7 +55,7 @@ export function getSearchConfig() {
process.env.BRAVE_SEARCH_BASE_URL?.trim() ||
"https://api.search.brave.com/res/v1/web/search",
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),
scanMaxQueries: envInt("SCAN_BRAVE_MAX_QUERIES", 8),
},

View File

@ -15,6 +15,7 @@ import {
normalizeThreadsPostUrl,
normalizeUsername,
threadsProfileUrl,
type AccountConfidence,
type SimilarAccount,
} from "@/lib/types/research";
@ -102,6 +103,16 @@ function buildDiscoverAnchor(ctx: DiscoverContext): DiscoverAnchor {
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[] {
const found = new Set<string>();
for (const match of text.match(/#[\w\u4e00-\u9fff]{2,24}/g) ?? []) {
@ -136,7 +147,6 @@ function addCandidate(
if (!isValidUsername(clean)) return;
const relevance = scoreTopicRelevance(params.reason, params.anchor);
if (relevance < 3) return;
const key = clean.toLowerCase();
const existing = map.get(key);
@ -167,10 +177,13 @@ function addCandidate(
function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): string[] {
const quoted = `"${anchor.corePhrase}"`;
const queries = [
`site:threads.com ${quoted}`,
`site:threads.net ${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) ?? "";
@ -180,7 +193,8 @@ function buildWebSearchQueries(anchor: DiscoverAnchor, brief?: string | null): s
for (const pillar of anchor.pillars.slice(0, 2)) {
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 queries = buildWebSearchQueries(anchor, brief);
const perQueryLimit = 8;
const perQueryLimit = 15;
const results = await Promise.all(
queries.map((q) =>
searchWebThorough(q, perQueryLimit, {
@ -225,7 +239,6 @@ async function discoverFromWebSearch(
for (const item of batch.results) {
const blob = `${item.link} ${item.title} ${item.snippet}`;
const relevance = scoreTopicRelevance(blob, anchor);
if (relevance < 3) continue;
const tags = extractTagsFromText(blob);
for (const username of extractUsernamesFromText(blob)) {
@ -267,7 +280,6 @@ async function discoverFromThreadsSearch(
if (!post.authorName) continue;
const postText = post.text.trim();
const relevance = scoreTopicRelevance(postText, anchor);
if (relevance < 3) continue;
addCandidate(map, post.authorName, {
reason:
@ -290,9 +302,7 @@ async function discoverFromThreadsSearch(
}
function rankCandidates(candidates: AccountCandidate[]): AccountCandidate[] {
return candidates
.filter((c) => c.relevance >= 3)
.sort(
return candidates.sort(
(a, b) =>
b.score +
b.relevance * 2 +
@ -334,18 +344,24 @@ async function applyAiRelevanceFilter(
apiKeys: ai.apiKeys,
});
return candidates
.map((c) => {
return candidates.map((c) => {
const verdict = verdicts.get(c.username.toLowerCase());
if (!verdict) return c;
return {
...c,
aiScore: verdict.score,
aiReason: verdict.reason,
relevance: verdict.relevant ? c.relevance + Math.round(verdict.score * 4) : -100,
relevance: verdict.relevant
? 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[] {
@ -355,10 +371,10 @@ function toSimilarAccounts(candidates: AccountCandidate[], limit: number): Simil
source: c.source,
profileUrl: threadsProfileUrl(c.username) ?? undefined,
postUrl: c.postUrl,
confidence: assignConfidence(c),
}));
}
/** 優先瀏覽器爬蟲,不足時 Brave 網搜補充;不讓 AI 捏造 username */
export async function discoverSimilarAccounts(params: {
label: string;
query: string;
@ -378,7 +394,6 @@ export async function discoverSimilarAccounts(params: {
const merged = new Map<string, AccountCandidate>();
// 1. 瀏覽器爬蟲Threads 站內搜尋)
if (params.storageState) {
const threadsCandidates = await discoverFromThreadsSearch(
params.storageState,
@ -390,9 +405,6 @@ export async function discoverSimilarAccounts(params: {
}
}
// 2. Brave 網搜補充(結果不足時)
const rankedSoFar = rankCandidates(Array.from(merged.values()));
if (rankedSoFar.length < 3) {
const webCandidates = await discoverFromWebSearch(anchor, params.brief);
for (const c of webCandidates) {
const key = c.username.toLowerCase();
@ -406,7 +418,6 @@ export async function discoverSimilarAccounts(params: {
merged.set(key, c);
}
}
}
let sorted = rankCandidates(Array.from(merged.values()));
@ -419,5 +430,8 @@ export async function discoverSimilarAccounts(params: {
sorted = rankCandidates(sorted);
}
sorted = verifyAccountConsistency(sorted);
sorted = rankCandidates(sorted);
return toSimilarAccounts(sorted, limit);
}

View File

@ -10,7 +10,7 @@ export async function generateDraftsForScan(scanId: string) {
include: {
topic: true,
items: {
where: { qualityTier: { not: "EXCLUDE" } },
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: 8,
include: {

View File

@ -11,9 +11,9 @@ export async function generateMatrixForScan(scanId: string, count?: number) {
include: {
topic: true,
items: {
where: { qualityTier: { not: "EXCLUDE" } },
where: { OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }] },
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: 12,
take: 24,
include: {
replies: { orderBy: { likeCount: "desc" }, take: 5 },
},

View File

@ -84,7 +84,7 @@ function buildPlacementKeywordQueries(tag: string, meta?: TagSearchMeta): string
meta?.searchIntent === "求助" ||
meta?.searchIntent === "痛點";
const intent = isNeedTag ? "求推薦" : "請問";
return [`site:threads.com "${tag}" ${intent} after:${after}`];
return [`site:threads.net "${tag}" ${intent} after:${after}`];
}
function resolveBraveQueryCap(placementMode: boolean): number {
@ -102,7 +102,7 @@ function buildKeywordQueries(
meta?: TagSearchMeta
): string[] {
if (placementMode) return buildPlacementKeywordQueries(tag, meta);
return [`site:threads.com "${tag}"`, `site:threads.net "${tag}"`];
return [`site:threads.net "${tag}"`];
}
function passesPlacementWebFilter(
@ -230,7 +230,7 @@ export async function discoverPostsViaWebSearch(
tags: string[],
options?: WebDiscoverOptions
): Promise<WebDiscoveredPost[]> {
const perQueryLimit = options?.perQueryLimit ?? 10;
const perQueryLimit = options?.perQueryLimit ?? 15;
const placementMode = options?.placementMode ?? false;
const contentBand = options?.contentBand;
const concurrency = options?.concurrency ?? 2;
@ -273,7 +273,7 @@ export async function discoverPostsViaWebSearch(
for (const phrase of bandPhrases) {
jobs.push({
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 braveOptions = resolveBraveSearchOptions(placementMode, options?.keywordPriority);
const useBrave = braveOptions.priority === "high";
const perAccountLimit = options?.perAccountLimit ?? 10;
const perAccountLimit = options?.perAccountLimit ?? 15;
const seen = new Set<string>();
const posts: WebDiscoveredPost[] = [];
@ -369,12 +369,11 @@ export async function discoverPostsFromSimilarAccounts(
const after = placementMode ? ` after:${formatGoogleAfterDate(PLACEMENT_WEB_SEARCH_MAX_AGE_DAYS)}` : "";
const queries = placementMode
? [
`site:threads.com/@${username} 求推薦${after}`,
`site:threads.com/@${username} 請益${after}`,
`site:threads.com/@${username}${after}`,
`site:threads.net/@${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;

View File

@ -15,7 +15,7 @@ import { getRepliesParallel } from "@/lib/threads-browser/replies";
import { keywordSearchViaThreadsApi } from "@/lib/threads-api";
import { getActiveThreadsCredentials } from "@/lib/services/threads-credentials";
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 { runWithConcurrency } from "@/lib/utils/concurrency";
import {
@ -255,7 +255,7 @@ export async function runScanForTopic(
const accountWebTargets = [...accountTargets.values()];
const [keywordWebPosts, accountWebPosts] = await Promise.all([
discoverPostsViaWebSearch(webSearchTags, {
perQueryLimit: placementMode ? 8 : 10,
perQueryLimit: 15,
placementMode,
concurrency: 2,
tagMeta,
@ -270,9 +270,10 @@ export async function runScanForTopic(
},
}),
!placementMode && accountWebTargets.length > 0
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 8), {
perAccountLimit: placementMode ? 12 : 10,
? discoverPostsFromSimilarAccounts(accountWebTargets.slice(0, 4), {
perAccountLimit: 20,
placementMode,
keywordPriority: braveKeywordPriority,
})
: Promise.resolve([]),
]);
@ -839,10 +840,13 @@ export async function runScanForTopic(
await applyQualityFilter(scan.id);
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 });
await enrichAccountsFromScan(scan.id, topic.id);
progressDetail.summary = `完成 · ${ranked.length} 篇 · ${repliesCount} 則留言`;
await report(progressDetail.summary, progressDetail);
@ -902,6 +906,86 @@ export async function applyQualityFilter(scanId: string) {
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) {
const topics = await prisma.topic.findMany({
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({
where: {
scanId,
qualityTier: { not: "EXCLUDE" },
OR: [{ qualityTier: null }, { qualityTier: { not: "EXCLUDE" } }],
},
orderBy: [{ combinedScore: "desc" }, { score: "desc" }],
take: limit,

View File

@ -31,14 +31,17 @@ export interface SuggestedTag {
searchType?: SearchTagType;
}
export type AccountConfidence = "high" | "medium" | "low";
export interface SimilarAccount {
username: string;
reason: string;
/** web=網路搜尋找到的真實連結threads=Threads 關鍵字搜尋熱門作者 */
source?: "web" | "threads" | "scan";
profileUrl?: string;
/** 發現此帳號時參考的那篇 Threads 貼文(若有) */
postUrl?: string;
confidence?: AccountConfidence;
lastActiveAt?: string;
}
export interface ResearchMap {
@ -126,3 +129,12 @@ export const SIMILAR_ACCOUNT_SOURCE_LABELS: Record<
threads: "Threads 搜尋",
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