thread-master/apps/web/src/lib/mockResearch.ts

324 lines
11 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import type {
ResearchHit,
ScoutKnowledgeRelation,
ScoutResearchNote,
ScoutResearchTier,
} from "../domain/types";
import { mockDelay } from "./mockAi";
import { newId } from "./id";
const SOURCES = [
{ host: "www.commonhealth.com.tw", label: "康健雜誌" },
{ host: "www.edh.tw", label: "早安健康" },
{ host: "heho.com.tw", label: "Heho健康" },
{ host: "www.dcard.tw", label: "Dcard" },
{ host: "www.ptt.cc", label: "PTT" },
{ host: "medium.com", label: "Medium" },
];
export const RESEARCH_TIER_META: Record<
ScoutResearchTier,
{ label: string; hint: string; order: number }
> = {
core: { label: "最貼主題", hint: "直接對準痛點/關鍵語,優先讀", order: 0 },
adjacent: { label: "相關周邊", hint: "鄰近情境,擴搜尋面", order: 1 },
broad: { label: "最廣泛", hint: "背景與對照,選讀即可", order: 2 },
};
export const KNOWLEDGE_RELATION_META: Record<
ScoutKnowledgeRelation,
{ label: string }
> = {
solves_pain: { label: "對準痛點" },
nearby_scene: { label: "鄰近場景" },
myth: { label: "迷思澄清" },
contrast: { label: "對照選購" },
background: { label: "背景脈絡" },
};
function pageUrl(host: string, slug: string): string {
return `https://${host}/article/${encodeURIComponent(slug)}`;
}
/** 漂亮顯示用:來源站名 + 精簡 host */
export function formatResearchLink(url: string, sourceLabel?: string): {
label: string;
host: string;
} {
let host = "";
try {
host = new URL(url).hostname.replace(/^www\./, "");
} catch {
host = url.replace(/^https?:\/\//, "").split("/")[0] || "";
}
return {
label: sourceLabel?.trim() || host || "來源",
host,
};
}
/**
* learn_points summary
*/
export function noteLearnPoints(n: Pick<ScoutResearchNote, "learn_points" | "summary">): string[] {
if (n.learn_points && n.learn_points.length > 0) {
return n.learn_points.slice(0, 5);
}
const raw = (n.summary || "").trim();
if (!raw) return [];
const byNum = raw
.split(/(?=\d)|(?=\d\.)|(?=)/)
.map((s) => s.replace(/^\d+[.]\s*/, "").replace(/^\s*/, "").trim())
.filter((s) => s.length >= 8 && s.length <= 80);
if (byNum.length >= 2) return byNum.slice(0, 4);
const bySentence = raw
.split(/[。!?\n]+/)
.map((s) => s.trim())
.filter((s) => s.length >= 8);
return bySentence.slice(0, 3);
}
export function noteReplyHooks(n: Pick<ScoutResearchNote, "reply_hooks">): string[] {
return (n.reply_hooks || []).filter(Boolean).slice(0, 3);
}
/**
* mock + + + + URL
* core adjacent
*/
export async function mockWebResearch(query: string): Promise<ResearchHit[]> {
await mockDelay(650);
const q = query.trim() || "主題";
const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic";
const pages: Array<Omit<ResearchHit, "id">> = [
{
title: `${q}:常見迷思與實際差異`,
snippet: `多數人討論「${q}」時會忽略使用情境,只看行銷字。`,
learn_points: [
`「溫和/天然」要對應使用頻率與膚況,不是萬能標籤`,
`別只看成分表第一行,香精、防腐與殘留感也會刺`,
`先寫自己的痛點句,再對規格,比問「哪個最好」準`,
],
reply_hooks: [
`先確認是刺鼻、刺癢還是洗不乾淨,三種解法不太一樣`,
`無香也不等於一定不刺激,可以一起對成分表看`,
],
summary: [
`本篇整理「${q}」在公開討論中的三個常見誤區:`,
`把「溫和/天然」當萬能標籤;只比成分表第一行;聽完業配再決策。`,
`建議先寫下痛點句(刺鼻、刺癢、洗不乾淨),再回頭對規格。`,
].join(""),
url: pageUrl(SOURCES[0]!.host, `${slug}-myths`),
source_label: SOURCES[0]!.label,
tier: "core",
},
{
title: `專家觀點:如何評估 ${q}`,
snippet: `可從安全性、長期成本、真實回饋三維度評估。`,
learn_points: [
`優先問:刺激來源能不能排除、有沒有可驗證使用情境`,
`敏感或長時間接觸:先收集失敗案例,不要只看成功廣告`,
`可操作三問:什麼會變糟?多久改善?有無生活替代?`,
],
reply_hooks: [
`你試過之後是「當下就刺」還是「用幾天後才不舒服」?`,
`若目標是敏感肌,失敗案例通常比業配文更有參考價值`,
],
summary: [
`科普向文章:評估「${q}」時優先看刺激來源與使用情境。`,
`問句可直接當 Threads 接話鉤子。`,
].join(""),
url: pageUrl(SOURCES[1]!.host, `${slug}-howto`),
source_label: SOURCES[1]!.label,
tier: "core",
},
{
title: `論壇實測串:${q} 用過的人怎麼說`,
snippet: `公開討論裡「實際用過」的留言互動通常高於純規格文。`,
learn_points: [
`高頻抱怨:一用就刺鼻/刺癢、洗完有殘留、假無香`,
`正向經驗多來自小範圍試用、對照香精欄、接受磨合期`,
`海巡關鍵語:真的無香嗎、有人也刺痛嗎、求非業配`,
],
reply_hooks: [
`也有人一用就刺鼻,你是碰到味道還是接觸後刺癢?`,
`論壇裡「求非業配」串通常比較敢講失敗經驗`,
],
summary: [
`彙整論壇約 40 則心得:抱怨集中在刺鼻、殘留、假無香。`,
`回覆時先共感具體症狀,再輕提解法。`,
].join(""),
url: pageUrl(SOURCES[3]!.host, `f-mood-${slug}-reviews`),
source_label: SOURCES[3]!.label,
tier: "adjacent",
},
{
title: `${q} 對照表與選購清單(摘要)`,
snippet: `把規格拆成痛點對照,比堆疊功能點更容易說服人。`,
learn_points: [
`拆成:刺激源、使用步驟、價格帶、適合誰/不適合誰`,
`適合「已明確知道自己的雷」的人,不適合亂槍打鳥`,
`對方痛點講清楚前,先不要急著丟產品連結`,
],
reply_hooks: [
`你比較在意「完全無味」還是「接觸後不刺」?兩個規格不一樣`,
`若你已經知道自己的雷,對照表會比推薦清單好用`,
],
summary: [
`清單文把「${q}」拆成刺激源、步驟、價格、適合對象。`,
`置入啟發:先確認痛點再決定要不要帶產品。`,
].join(""),
url: pageUrl(SOURCES[2]!.host, `${slug}-checklist`),
source_label: SOURCES[2]!.label,
tier: "adjacent",
},
];
return pages.map((p) => ({ ...p, id: newId("hit") }));
}
export function formatResearchInsert(hits: ResearchHit[]): string {
if (!hits.length) return "";
return (
"\n\n——\n補充資料\n" +
hits
.map((h, i) => {
const points = h.learn_points?.length
? h.learn_points.map((p) => `· ${p}`).join("\n")
: h.summary || h.snippet;
const link = formatResearchLink(h.url, h.source_label);
return `${i + 1}. ${h.title}\n${points}\n${link.label} · ${link.host}`;
})
.join("\n\n")
);
}
function relationForTier(tier: ScoutResearchTier, title: string): ScoutKnowledgeRelation {
if (title.includes("迷思")) return "myth";
if (title.includes("對照") || title.includes("選購")) return "contrast";
if (tier === "core") return "solves_pain";
if (tier === "adjacent") return "nearby_scene";
return "background";
}
/** ResearchHit → 海巡功課用的知識節點 */
export function researchHitsToScoutNotes(hits: ResearchHit[]): ScoutResearchNote[] {
return hits.map((h) => {
const tier = h.tier || "core";
const summary = (h.summary || h.snippet || "").trim();
const learn_points =
h.learn_points && h.learn_points.length > 0
? h.learn_points
: noteLearnPoints({ summary, learn_points: undefined });
return {
id: h.id,
title: h.title,
summary,
learn_points,
reply_hooks: h.reply_hooks?.length ? h.reply_hooks : undefined,
url: h.url,
source_label: h.source_label,
keywords: extractKeywordsFromNote(h.title, h.summary || h.snippet),
tier,
relation: relationForTier(tier, h.title),
};
});
}
function extractKeywordsFromNote(title: string, body: string): string[] {
const blob = `${title} ${body}`;
const candidates = [
"無香",
"敏感肌",
"刺鼻",
"刺癢",
"香精",
"成分表",
"溫和",
"實測",
"非業配",
"插座",
"第三空間",
"久坐",
];
const found = candidates.filter((k) => blob.includes(k));
const head = title.split(/[:]/)[0]?.trim();
if (head && head.length >= 2 && head.length <= 16 && !found.includes(head)) {
found.unshift(head.slice(0, 12));
}
return [...new Set(found)].slice(0, 5);
}
/**
*
* adjacent broad
*/
export async function mockExpandKnowledgePages(
terms: string[],
contextLabel?: string,
): Promise<ScoutResearchNote[]> {
await mockDelay(350);
const take = terms.filter(Boolean).slice(0, 6);
return take.map((term, i) => {
const src = SOURCES[(i + 2) % SOURCES.length]!;
const ctx = contextLabel ? `(對齊 ${contextLabel}` : "";
const tier: ScoutResearchTier = i < 2 ? "adjacent" : "broad";
if (tier === "broad") {
return {
id: newId("ek"),
title: `背景:${term} 的更大討論脈絡${ctx}`,
summary: `較廣角閱讀:「${term}」在生活/消費文化裡如何被框定。`,
learn_points: [
`${term}」常被包進更大的生活/消費敘事,不只是單點規格`,
`用途是聽懂對方從哪個場景開講,不是立刻給選品答案`,
`語氣很散時:先接背景一句,再收斂到具體痛點`,
],
reply_hooks: [
`聽起來你是從「${term}」這條線在煩,我先對一下場景`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "background",
};
}
return {
id: newId("ek"),
title: `周邊:${term} 為什麼常被一起談${ctx}`,
summary: `針對關鍵語「${term}」的延伸摘要。`,
learn_points: [
`社群常把「${term}」和相鄰痛點綁在一起講`,
`對方未必搜產品名,而是用症狀/場景說話`,
`可帶走:用對方的詞接話 → 先釐清情境 → 再談規格`,
],
reply_hooks: [
`很多人提到「${term}」時,其實在講隔壁的痛,你是哪一種?`,
],
url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`),
source_label: src.label,
keywords: [term],
tier,
relation: "nearby_scene",
};
});
}
export function groupNotesByTier(
notes: ScoutResearchNote[],
): { tier: ScoutResearchTier; notes: ScoutResearchNote[] }[] {
const buckets: Record<ScoutResearchTier, ScoutResearchNote[]> = {
core: [],
adjacent: [],
broad: [],
};
for (const n of notes) {
const t = n.tier || "adjacent";
buckets[t].push(n);
}
return (["core", "adjacent", "broad"] as ScoutResearchTier[])
.filter((t) => buckets[t].length > 0)
.map((tier) => ({ tier, notes: buckets[tier] }));
}