324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
|
|
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] }));
|
|||
|
|
}
|