fix all bug

This commit is contained in:
王性驊 2026-08-09 07:57:35 +00:00
parent 7938b2efa8
commit 85d2ad37f6
62 changed files with 4265 additions and 523 deletions

View File

@ -39,6 +39,7 @@ Optional: `SCOUT_CRAWLER_PORT=8891` changes the local port.
- `dev_mode=false` does not use this service; it uses the configured API provider. - `dev_mode=false` does not use this service; it uses the configured API provider.
- `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error. - `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error.
- The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer <CrawlerToken>`. - The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer <CrawlerToken>`.
- **Dual-track search** (熱門 + 最新): each query hits Top and Recent in parallel; merge is **Recent-primary order + Top fill** (both is a badge only — does not reshuffle relevance). Hard-drops only when age &gt; `SCOUT_MAX_AGE_DAYS` (default **180**). Response: `track`, `serp_rank`, `published_at`.
## Operations ## Operations

View File

@ -1,13 +1,29 @@
import { chromium, type Page } from "playwright"; import { chromium, type BrowserContext, type Page } from "playwright";
import { createServer } from "node:http"; import { createServer } from "node:http";
type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number }; type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
type ResolveRequest = { storage_state?: string; permalink?: string }; type ResolveRequest = { storage_state?: string; permalink?: string };
type Post = { permalink: string; author: string; text: string };
type Post = {
permalink: string;
author: string;
text: string;
track?: "top" | "recent" | "both";
serp_rank?: number;
published_at?: string;
published_label?: string;
};
const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891); const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
const token = process.env.SCOUT_CRAWLER_TOKEN || ""; const token = process.env.SCOUT_CRAWLER_TOKEN || "";
/** 硬擋:可解析且超過此天數才丟。預設 180。 */
const hardMaxAgeDays = (() => {
const n = Number(process.env.SCOUT_MAX_AGE_DAYS);
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
return 180;
})();
function isThreadsURL(value: string): boolean { function isThreadsURL(value: string): boolean {
try { try {
const host = new URL(value).hostname.toLowerCase(); const host = new URL(value).hostname.toLowerCase();
@ -17,42 +33,261 @@ function isThreadsURL(value: string): boolean {
} }
} }
async function readPosts(page: Page, limit: number): Promise<Post[]> { function normalizePermalink(href: string): string {
const posts = new Map<string, Post>(); const absolute = href.startsWith("http") ? href : `https://www.threads.com${href}`;
const links = page.locator('a[href*="/post/"]'); try {
const count = Math.min(await links.count(), 50); const u = new URL(absolute);
for (let i = 0; i < count && posts.size < limit; i++) { u.hash = "";
const link = links.nth(i); u.search = "";
const href = await link.getAttribute("href").catch(() => null); u.pathname = u.pathname.replace(/\/+$/, "");
if (!href) continue; return u.toString();
const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`; } catch {
if (!isThreadsURL(permalink)) continue; return absolute.split("?")[0]!.replace(/\/+$/, "");
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
const scope = link.locator("xpath=ancestor::div[position()<=6]").first();
const text = (await scope.innerText().catch(() => "")).trim();
if (text.length < 5) continue;
posts.set(permalink, { permalink, author, text: text.slice(0, 2000) });
} }
return [...posts.values()]; }
/** 口語錨點:不能單獨代表主題相關 */
const ANCHORS = new Set(["求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論", "有人知道", "請問"]);
/**
*
*
*/
function textMatchesQuery(text: string, query: string): boolean {
const body = text.replace(/\s+/g, "").toLowerCase();
if (!body) return false;
const q = query.replace(/\u3000/g, " ").trim();
if (!q) return true;
const parts = q.split(/\s+/).filter((p) => [...p].length >= 2);
const tokens = parts.length ? parts : [...q].length >= 2 ? [q] : [];
const content: string[] = [];
const anchors: string[] = [];
for (const p of tokens) {
const t = p.toLowerCase();
if (ANCHORS.has(t)) anchors.push(t.replace(/\s+/g, ""));
else content.push(t.replace(/\s+/g, ""));
}
if (content.length > 0) {
return content.some((c) => body.includes(c));
}
if (anchors.length > 0) {
return anchors.some((a) => body.includes(a));
}
return true;
}
function parsePublishedFromCardText(text: string): { iso?: string; label?: string } {
const raw = text.replace(/\s+/g, " ").trim();
if (!raw) return {};
const head = raw.slice(0, 100);
const now = Date.now();
const relPatterns: Array<{ re: RegExp; ms: (n: number) => number; label: (n: number) => string }> = [
{ re: /剛剛|just now/i, ms: () => 0, label: () => "剛剛" },
{ re: /(\d+)\s*(秒|s|sec)/i, ms: (n) => n * 1000, label: (n) => `${n}` },
{ re: /(\d+)\s*(分|分鐘|m|min)/i, ms: (n) => n * 60_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(小時|時|h|hr)/i, ms: (n) => n * 3_600_000, label: (n) => `${n}小時` },
{ re: /(\d+)\s*(天|日|d|day)/i, ms: (n) => n * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(週|周|w|week)/i, ms: (n) => n * 7 * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*(月|mo|month)/i, ms: (n) => n * 30 * 86_400_000, label: (n) => `${n}` },
{ re: /(\d+)\s*年前/i, ms: (n) => n * 365 * 86_400_000, label: (n) => `${n}` },
];
for (const p of relPatterns) {
const m = head.match(p.re);
if (!m) continue;
const n = m[1] ? Number(m[1]) : 0;
if (!Number.isFinite(n) && !/剛剛|just now/i.test(m[0])) continue;
return { iso: new Date(now - p.ms(Number.isFinite(n) ? n : 0)).toISOString(), label: p.label(Number.isFinite(n) ? n : 0) };
}
return {};
}
function isHardTooOld(iso?: string): boolean {
if (!iso || hardMaxAgeDays <= 0) return false;
const t = Date.parse(iso);
if (!Number.isFinite(t)) return false;
return Date.now() - t > hardMaxAgeDays * 86_400_000;
}
/**
* page.evaluate SERP locator query
* document /post/ nav 100% query
*/
async function readPosts(page: Page, query: string, limit: number): Promise<Post[]> {
type Raw = { href: string; author: string; text: string };
const raws = await page.evaluate((maxScan: number) => {
const out: Raw[] = [];
const seen = new Set<string>();
const anchors = Array.from(document.querySelectorAll('a[href*="/post/"]')) as HTMLAnchorElement[];
for (const a of anchors) {
if (out.length >= maxScan) break;
const href = a.getAttribute("href") || "";
if (!href.includes("/post/")) continue;
// 略過 nav / header 內連結
if (a.closest("nav, header, [role='navigation']")) continue;
const key = href.split("?")[0] || href;
if (seen.has(key)) continue;
seen.add(key);
// 找小範圍卡片:往上最多 8 層,取文字長度 20800 的最近祖先
let el: HTMLElement | null = a;
let best = "";
for (let depth = 0; depth < 8 && el; depth++) {
const t = (el.innerText || "").replace(/\s+/g, " ").trim();
if (t.length >= 20 && t.length <= 1200) {
best = t;
// 再往上若突然暴衝(整欄 feed就停在 best
const parent = el.parentElement;
if (parent) {
const pt = (parent.innerText || "").replace(/\s+/g, " ").trim();
if (pt.length > t.length * 3 && pt.length > 1500) break;
}
}
el = el.parentElement;
}
if (best.length < 12) {
best = (a.innerText || "").replace(/\s+/g, " ").trim();
}
if (best.length < 8) continue;
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
out.push({ href, author, text: best.slice(0, 2000) });
}
return out;
}, Math.min(limit * 4, 80));
const posts: Post[] = [];
let rank = 0;
for (const r of raws) {
if (posts.length >= limit) break;
const permalink = normalizePermalink(r.href);
if (!isThreadsURL(permalink)) continue;
if (!textMatchesQuery(r.text, query)) continue;
const { iso, label } = parsePublishedFromCardText(r.text);
if (isHardTooOld(iso)) continue;
rank += 1;
posts.push({
permalink,
author: r.author,
text: r.text,
serp_rank: rank,
published_at: iso,
published_label: label,
});
}
return posts;
}
function searchURL(query: string, track: "top" | "recent"): string {
const q = encodeURIComponent(query);
// Top = defaultRecent = filter=recent與官方 web 一致)
if (track === "recent") {
return `https://www.threads.com/search?q=${q}&serp_type=default&filter=recent`;
}
return `https://www.threads.com/search?q=${q}&serp_type=default`;
}
async function searchTrack(page: Page, query: string, track: "top" | "recent", limit: number): Promise<Post[]> {
const url = searchURL(query, track);
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });
// 必須仍在搜尋頁,否則 captcha導向首頁會抓到無關貼文
const landed = page.url();
if (landed.includes("/login") || !landed.includes("/search")) {
const body = await page.locator("body").innerText().catch(() => "");
if (body.includes("登入") || landed.includes("/login")) throw new Error("crawler session expired");
// 不在 search直接空結果寧可少抓也不亂抓推薦流
return [];
}
const body = await page.locator("body").innerText().catch(() => "");
if (body.includes("登入") && body.length < 400) throw new Error("crawler session expired");
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
await page.waitForTimeout(900);
// 溫和捲動 2 次即可;過度捲動容易混進「為你推薦」
await page.mouse.wheel(0, 1000);
await page.waitForTimeout(700);
await page.mouse.wheel(0, 1000);
await page.waitForTimeout(600);
const posts = await readPosts(page, query, limit);
return posts.map((p) => ({ ...p, track }));
}
/** Recent 原序為主Top 獨有補後both 僅標記。合併後再做一次 query 相關性過濾。 */
function mergeRecentPrimary(top: Post[], recent: Post[], query: string, limit: number): Post[] {
const topMap = new Map<string, Post>();
for (const p of top) topMap.set(normalizePermalink(p.permalink), p);
const out: Post[] = [];
const seen = new Set<string>();
for (const p of recent) {
if (!textMatchesQuery(p.text, query)) continue;
const key = normalizePermalink(p.permalink);
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...p, track: topMap.has(key) ? "both" : "recent" });
if (out.length >= limit) return out;
}
for (const p of top) {
if (!textMatchesQuery(p.text, query)) continue;
const key = normalizePermalink(p.permalink);
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...p, track: "top" });
if (out.length >= limit) break;
}
return out;
} }
async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> { async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> {
const state = JSON.parse(storageState) as { cookies?: unknown[] }; const state = JSON.parse(storageState) as { cookies?: unknown[] };
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid"); if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
// 上層 fan-out 一詞一搜;若誤傳多詞只取第一組
const query = (terms.map((t) => t.trim()).filter(Boolean)[0] || "").slice(0, 180);
if (!query) return [];
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
try { try {
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" }); const context: BrowserContext = await browser.newContext({
const page = await context.newPage(); storageState: state,
const query = terms.filter(Boolean).join(" ").slice(0, 180); locale: "zh-TW",
await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 }); timezoneId: "Asia/Taipei",
const body = await page.locator("body").innerText().catch(() => ""); userAgent:
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired"); "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined); });
await page.mouse.wheel(0, 900); const perTrack = Math.min(Math.max(limit, 10), 24);
await page.waitForTimeout(1000); const pageTop = await context.newPage();
const posts = await readPosts(page, limit); const pageRecent = await context.newPage();
let top: Post[] = [];
let recent: Post[] = [];
try {
[top, recent] = await Promise.all([
searchTrack(pageTop, query, "top", perTrack),
searchTrack(pageRecent, query, "recent", perTrack),
]);
} catch (e) {
// 一軌失敗仍用另一軌
if (top.length === 0) {
try {
top = await searchTrack(pageTop, query, "top", perTrack);
} catch {
/* keep empty */
}
}
if (recent.length === 0 && !(e instanceof Error && e.message.includes("session"))) {
try {
recent = await searchTrack(pageRecent, query, "recent", perTrack);
} catch {
/* keep empty */
}
}
if (top.length === 0 && recent.length === 0) throw e;
}
await pageTop.close().catch(() => undefined);
await pageRecent.close().catch(() => undefined);
const merged = mergeRecentPrimary(top, recent, query, limit);
await context.close(); await context.close();
return posts; return merged;
} finally { } finally {
await browser.close(); await browser.close();
} }
@ -64,12 +299,21 @@ function shortcodeFromPermalink(permalink: string): string {
function findMediaID(value: unknown, shortcode: string): string { function findMediaID(value: unknown, shortcode: string): string {
if (!value || typeof value !== "object") return ""; if (!value || typeof value !== "object") return "";
if (Array.isArray(value)) { for (const item of value) { const id = findMediaID(item, shortcode); if (id) return id; } return ""; } if (Array.isArray(value)) {
for (const item of value) {
const id = findMediaID(item, shortcode);
if (id) return id;
}
return "";
}
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
const code = String(record.code || record.shortcode || ""); const code = String(record.code || record.shortcode || "");
const id = String(record.id || record.pk || record.media_id || ""); const id = String(record.id || record.pk || record.media_id || "");
if (code === shortcode && /^\d{10,}$/.test(id)) return id; if (code === shortcode && /^\d{10,}$/.test(id)) return id;
for (const child of Object.values(record)) { const found = findMediaID(child, shortcode); if (found) return found; } for (const child of Object.values(record)) {
const found = findMediaID(child, shortcode);
if (found) return found;
}
return ""; return "";
} }
@ -100,28 +344,36 @@ async function resolve(storageState: string, permalink: string): Promise<string>
const responseReads: Promise<void>[] = []; const responseReads: Promise<void>[] = [];
page.on("response", async (response) => { page.on("response", async (response) => {
if (mediaID || !/graphql|threads|instagram/.test(response.url())) return; if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
responseReads.push((async () => { responseReads.push(
(async () => {
try { try {
const raw = await response.text(); const raw = await response.text();
if (!mediaID) { if (!mediaID) {
try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ } try {
mediaID = findMediaID(JSON.parse(raw), shortcode);
} catch {
/* non-JSON */
}
} }
if (!mediaID) mediaID = findMediaIDInText(raw, shortcode); if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
} catch { /* ignored */ } } catch {
})()); /* ignored */
}
})(),
);
}); });
await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 }); await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => ""); const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired"); if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForTimeout(2500); await page.waitForTimeout(2500);
await Promise.allSettled(responseReads); await Promise.allSettled(responseReads);
if (!mediaID) { if (!mediaID) mediaID = findMediaIDInText(await page.content(), shortcode);
mediaID = findMediaIDInText(await page.content(), shortcode);
}
await context.close(); await context.close();
if (!mediaID) throw new Error("Threads media ID could not be resolved") if (!mediaID) throw new Error("Threads media ID could not be resolved");
return mediaID; return mediaID;
} finally { await browser.close(); } } finally {
await browser.close();
}
} }
if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required"); if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");
@ -139,7 +391,10 @@ createServer(async (req, res) => {
const body = await new Promise<string>((resolve, reject) => { const body = await new Promise<string>((resolve, reject) => {
let raw = ""; let raw = "";
req.setEncoding("utf8"); req.setEncoding("utf8");
req.on("data", (chunk) => { raw += chunk; if (raw.length > 300_000) req.destroy(); }); req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 300_000) req.destroy();
});
req.on("end", () => resolve(raw)); req.on("end", () => resolve(raw));
req.on("error", reject); req.on("error", reject);
}); });
@ -150,10 +405,16 @@ createServer(async (req, res) => {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID })); res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
} else { } else {
const input = JSON.parse(body) as SearchRequest; const input = JSON.parse(body) as SearchRequest;
const posts = await search(String(input.storage_state || ""), Array.isArray(input.terms) ? input.terms.slice(0, 12) : [], Math.min(Math.max(Number(input.limit) || 10, 1), 30)); const posts = await search(
String(input.storage_state || ""),
Array.isArray(input.terms) ? input.terms.slice(0, 12) : [],
Math.min(Math.max(Number(input.limit) || 10, 1), 30),
);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts })); res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts }));
} }
} catch (error) { } catch (error) {
res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" })); res.writeHead(422, { "content-type": "application/json" }).end(
JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }),
);
} }
}).listen(port, "127.0.0.1"); }).listen(port, "127.0.0.1");

View File

@ -247,6 +247,8 @@ type (
ThemeKey string `json:"theme_key,optional"` ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"` ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"` ProductContext string `json:"product_context,optional"`
// TargetCount話題今日目標。主路徑不足時會加碼再搜次路徑補抓上限 40。
TargetCount int `json:"target_count,optional"`
} }
ScoutScanReq { ScoutScanReq {
Brief ScoutBriefPublic `json:"brief"` Brief ScoutBriefPublic `json:"brief"`

View File

@ -223,6 +223,8 @@ type (
Text string `json:"text"` Text string `json:"text"`
UsedAt int64 `json:"used_at,optional"` UsedAt int64 `json:"used_at,optional"`
SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy
// 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值。
OutboxId string `json:"outbox_id,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@ -244,6 +246,8 @@ type (
Id string `path:"id"` Id string `path:"id"`
ReplyId string `path:"replyId"` ReplyId string `path:"replyId"`
Channel string `json:"channel"` // outbox | manual_copy Channel string `json:"channel"` // outbox | manual_copy
// channel=outbox 時必填:要用哪個 Threads 帳號送出。
AccountId string `json:"account_id,optional"`
} }
MarkReplyUsedData { MarkReplyUsedData {
@ -279,6 +283,50 @@ type (
List []RadarSweepPublic `json:"list"` List []RadarSweepPublic `json:"list"`
Pagination Pagination `json:"pagination"` Pagination Pagination `json:"pagination"`
} }
// ---------- Manual ImportP1不承諾全平台自動抓取的合規補位 ----------
ImportOpportunityItem {
Url string `json:"url"`
// 貼文內文;沒有官方 API爬蟲可讀任意網址判定一律吃使用者貼上的文字。
Text string `json:"text"`
// 缺省時從 threads 網址猜 @handle猜不到就留空。
Author string `json:"author,optional"`
// unix nanoseconds UTC缺省用匯入當下時間新鮮度以匯入時間計
PostedAt int64 `json:"posted_at,optional"`
}
ImportOpportunitiesReq {
Items []ImportOpportunityItem `json:"items"`
}
ImportedOpportunityResult {
Url string `json:"url"`
OpportunityId string `json:"opportunity_id,optional"`
Status string `json:"status"` // qualified | rejected | skipped | failed
IntentBand string `json:"intent_band,optional"`
IntentScore int `json:"intent_score,optional"`
// skippedfailed 時的人話原因qualifiedrejected 時通常留空。
Error string `json:"error,optional"`
}
ImportOpportunitiesData {
Results []ImportedOpportunityResult `json:"results"`
}
// ---------- Explore商機頁立即探索短詞 fan-out → 五問判定) ----------
ExploreOpportunitiesReq {
// 16 組 Threads 短詞(契約 A不合規整組擋下不默默修正。
Terms []string `json:"terms"`
}
ExploreOpportunitiesData {
SweepId string `json:"sweep_id"`
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
TruncatedCount int `json:"truncated_count"`
CreditsUsed int `json:"credits_used"`
}
) )
@server ( @server (
@ -349,4 +397,10 @@ service gateway {
@handler ListSweeps @handler ListSweeps
get /sweeps (ListSweepsReq) returns (SweepListData) get /sweeps (ListSweepsReq) returns (SweepListData)
@handler ImportOpportunities
post /import (ImportOpportunitiesReq) returns (ImportOpportunitiesData)
@handler ExploreOpportunities
post /explore (ExploreOpportunitiesReq) returns (ExploreOpportunitiesData)
} }

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ExploreOpportunitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ExploreOpportunitiesReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewExploreOpportunitiesLogic(r.Context(), svcCtx)
data, err := l.ExploreOpportunities(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,28 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package radar
import (
"net/http"
"apps/backend/internal/logic/radar"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ImportOpportunitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ImportOpportunitiesReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := radar.NewImportOpportunitiesLogic(r.Context(), svcCtx)
data, err := l.ImportOpportunities(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1031,6 +1031,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{ []rest.Route{
{
Method: http.MethodPost,
Path: "/explore",
Handler: radar.ExploreOpportunitiesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/import",
Handler: radar.ImportOpportunitiesHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/opportunities", Path: "/opportunities",

View File

@ -0,0 +1,43 @@
package radar
import (
"context"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ExploreOpportunitiesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewExploreOpportunitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExploreOpportunitiesLogic {
return &ExploreOpportunitiesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ExploreOpportunitiesLogic) ExploreOpportunities(req *types.ExploreOpportunitiesReq) (*types.ExploreOpportunitiesData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
res, err := l.svcCtx.Radar.ExploreOpportunities(l.ctx, uid, req.Terms)
if err != nil {
return nil, err
}
return &types.ExploreOpportunitiesData{
SweepId: res.SweepID,
HitCount: res.HitCount,
JudgedCount: res.JudgedCount,
CreatedCount: res.CreatedCount,
TruncatedCount: res.TruncatedCount,
CreditsUsed: res.CreditsUsed,
}, nil
}

View File

@ -0,0 +1,50 @@
package radar
import (
"context"
"apps/backend/internal/module/radar/usecase"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ImportOpportunitiesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewImportOpportunitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportOpportunitiesLogic {
return &ImportOpportunitiesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ImportOpportunitiesLogic) ImportOpportunities(req *types.ImportOpportunitiesReq) (*types.ImportOpportunitiesData, error) {
uid, err := ownerUID(l.ctx)
if err != nil {
return nil, err
}
items := make([]usecase.ManualImportItem, 0, len(req.Items))
for _, it := range req.Items {
items = append(items, usecase.ManualImportItem{
URL: it.Url, Text: it.Text, Author: it.Author, PostedAt: it.PostedAt,
})
}
results, err := l.svcCtx.Radar.ImportManualOpportunities(l.ctx, uid, items)
if err != nil {
return nil, err
}
out := make([]types.ImportedOpportunityResult, 0, len(results))
for _, r := range results {
out = append(out, types.ImportedOpportunityResult{
Url: r.URL, OpportunityId: r.OpportunityID, Status: r.Status,
IntentBand: r.IntentBand, IntentScore: r.IntentScore, Error: r.Error,
})
}
return &types.ImportOpportunitiesData{Results: out}, nil
}

View File

@ -44,10 +44,12 @@ func (f *fakeSuggestAI) CompleteStream(ctx context.Context, apiKey, model, promp
func (f *fakeSuggestAI) ListModels(context.Context, string) ([]string, error) { return nil, nil } func (f *fakeSuggestAI) ListModels(context.Context, string) ([]string, error) { return nil, nil }
// 假 AI 回傳必須通過 Threads 短詞規則IsThreadsSearchable
// include ≤2 token、中文每詞 24 字exclude 仍走較寬長度界線。
const fakeSuggestReply = `[ const fakeSuggestReply = `[
{"term":"台北 婚攝 推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"}, {"term":"婚攝 推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"},
{"term":"影 價格","reason":"問價格的人通常已經在比較廠商","usage":"include"}, {"term":"台北 婚攝","reason":"問價格的人通常已經在比較廠商","usage":"include"},
{"term":"徵 婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"} {"term":"徵婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"}
]` ]`
type m1Env struct { type m1Env struct {

View File

@ -25,7 +25,7 @@ func (l *MarkOpportunityReplyUsedLogic) MarkOpportunityReplyUsed(req *types.Mark
if err != nil { if err != nil {
return nil, err return nil, err
} }
r, advice, err := l.svcCtx.Radar.MarkReplyUsed(l.ctx, uid, req.Id, req.ReplyId, req.Channel) r, advice, err := l.svcCtx.Radar.MarkReplyUsed(l.ctx, uid, req.Id, req.ReplyId, req.Channel, req.AccountId)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -214,7 +214,7 @@ func Reply(r *domain.ReplyVariant) *types.ReplyVariantPublic {
} }
return &types.ReplyVariantPublic{ return &types.ReplyVariantPublic{
Id: r.ID, OpportunityId: r.OpportunityID, Variant: r.Variant, Text: r.Text, Id: r.ID, OpportunityId: r.OpportunityID, Variant: r.Variant, Text: r.Text,
UsedAt: r.UsedAt, SentChannel: r.SentChannel, CreatedAt: r.CreatedAt, UsedAt: r.UsedAt, SentChannel: r.SentChannel, OutboxId: r.OutboxID, CreatedAt: r.CreatedAt,
} }
} }

View File

@ -35,7 +35,8 @@ const (
// Opportunity sources. // Opportunity sources.
const ( const (
OppSourceThreads = "threads" OppSourceThreads = "threads"
OppSourceManual = "manual" // OppSourceManualImport使用者貼 ThreadsFacebook 貼文網址或 CSV 批次匯入spec §4.11 P1
OppSourceManualImport = "manual_import"
OppSourceScoutPromote = "scout_promote" OppSourceScoutPromote = "scout_promote"
) )
@ -157,7 +158,7 @@ func IsRegionMatch(s string) bool {
func IsOppSource(s string) bool { func IsOppSource(s string) bool {
switch s { switch s {
case OppSourceThreads, OppSourceManual, OppSourceScoutPromote: case OppSourceThreads, OppSourceManualImport, OppSourceScoutPromote:
return true return true
} }
return false return false

View File

@ -22,6 +22,8 @@ type ReplyVariant struct {
Text string `bson:"text" json:"text"` Text string `bson:"text" json:"text"`
UsedAt int64 `bson:"used_at,omitempty" json:"used_at,omitempty"` UsedAt int64 `bson:"used_at,omitempty" json:"used_at,omitempty"`
SentChannel string `bson:"sent_channel,omitempty" json:"sent_channel,omitempty"` SentChannel string `bson:"sent_channel,omitempty" json:"sent_channel,omitempty"`
// OutboxID 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值T550 真送出)。
OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"`
CreatedAt int64 `bson:"created_at" json:"created_at"` CreatedAt int64 `bson:"created_at" json:"created_at"`
} }

View File

@ -34,6 +34,8 @@ func NormalizeSuggestUsage(s string) string {
CleanSuggestions 收掉空白與重複丟掉沒有理由的項目並套用數量上限 CleanSuggestions 收掉空白與重複丟掉沒有理由的項目並套用數量上限
沒有理由的項目直接丟補一句AI 建議等於假裝有理由比少一則更糟 沒有理由的項目直接丟補一句AI 建議等於假裝有理由比少一則更糟
include 關鍵字必須通過 Threads 短詞規則IsThreadsSearchable不合規整條丟掉不截短
避免產出半截怪詞exclude 仍用較寬的長度界線訂閱排除詞可能較長
*/ */
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion { func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
if limit <= 0 || limit > MaxSuggestions { if limit <= 0 || limit > MaxSuggestions {
@ -42,19 +44,29 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
out := make([]WatchTermSuggestion, 0, len(in)) out := make([]WatchTermSuggestion, 0, len(in))
seen := map[string]bool{} seen := map[string]bool{}
for _, s := range in { for _, s := range in {
term := strings.ToLower(strings.Join(strings.Fields(strings.ReplaceAll(s.Term, "\u3000", " ")), " ")) term := NormalizeSearchTerm(s.Term)
reason := strings.TrimSpace(s.Reason) reason := strings.TrimSpace(s.Reason)
if term == "" || reason == "" { if term == "" || reason == "" {
continue continue
} }
if len([]rune(term)) < MinTermLen || len([]rune(term)) > MaxTermLen { usage := NormalizeSuggestUsage(s.Usage)
if usage == SuggestUsageInclude {
if !IsThreadsSearchable(term) {
continue continue
} }
if seen[term] { } else {
// exclude: keep broader length; still reject empty after normalize
n := len([]rune(term))
if n < MinTermLen || n > MaxTermLen {
continue continue
} }
seen[term] = true }
out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: NormalizeSuggestUsage(s.Usage)}) key := strings.ToLower(term)
if seen[key] {
continue
}
seen[key] = true
out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
if len(out) >= limit { if len(out) >= limit {
break break
} }

View File

@ -65,9 +65,8 @@ func (s *RadarSweep) Normalize() error {
if s.OwnerUID <= 0 { if s.OwnerUID <= 0 {
return fmt.Errorf("%w: owner_uid required", ErrValidation) return fmt.Errorf("%w: owner_uid required", ErrValidation)
} }
if strings.TrimSpace(s.WatchID) == "" { // WatchID may be empty for on-demand explore (no subscription); required for scheduled sweeps.
return fmt.Errorf("%w: watch_id required", ErrValidation) s.WatchID = strings.TrimSpace(s.WatchID)
}
if s.Path == "" { if s.Path == "" {
s.Path = SweepPathAPI s.Path = SweepPathAPI
} }

View File

@ -0,0 +1,111 @@
package domain
import (
"strings"
"unicode"
"unicode/utf8"
)
// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
// 一組查詢 = 最多 2 個 token半形空格分隔中文 token 24 字;整組去掉空格後 ≤12 字元。
const (
MaxThreadsTokens = 2
MinCJKTokenRunes = 2
MaxCJKTokenRunes = 4
MaxThreadsTermRunes = 12 // 去掉空白後
MaxExploreTerms = 6
)
// NormalizeSearchTerm trims, converts full-width spaces to half-width, and collapses whitespace.
func NormalizeSearchTerm(raw string) string {
s := strings.ReplaceAll(raw, "\u3000", " ")
return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
}
// IsThreadsSearchable reports whether a (already-normalized or raw) term meets Threads short-term rules.
//
// Rules:
// - after normalize: non-empty
// - ≤2 tokens (space-separated)
// - CJK tokens: 24 runes; Latin tokens: 212 runes (single token)
// - total runes without spaces ≤12
// - no punctuation, quotes, boolean operators, emoji, or #
func IsThreadsSearchable(term string) bool {
term = NormalizeSearchTerm(term)
if term == "" {
return false
}
// total runes without spaces
compact := strings.ReplaceAll(term, " ", "")
if utf8.RuneCountInString(compact) > MaxThreadsTermRunes {
return false
}
tokens := strings.Fields(term)
if len(tokens) == 0 || len(tokens) > MaxThreadsTokens {
return false
}
for _, tok := range tokens {
if !isAllowedToken(tok) {
return false
}
}
return true
}
func isAllowedToken(tok string) bool {
if tok == "" {
return false
}
// reject common boolean / operators whole-token
upper := strings.ToUpper(tok)
switch upper {
case "AND", "OR", "NOT":
return false
}
hasCJK := false
hasLetter := false
for _, r := range tok {
if r == '#' || r == '"' || r == '\'' || r == '「' || r == '」' || r == '『' || r == '』' {
return false
}
if r == '-' || r == '+' || r == '*' || r == '(' || r == ')' || r == '|' || r == '&' {
return false
}
if unicode.IsPunct(r) || unicode.IsSymbol(r) {
return false
}
// emoji / other symbols often in Symbol or So; also catch common ranges
if r >= 0x1F300 && r <= 0x1FAFF {
return false
}
if r >= 0x2600 && r <= 0x27BF {
return false
}
if isCJK(r) {
hasCJK = true
continue
}
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
hasLetter = true
continue
}
// allow middle dot / common CJK connector? no — keep strict
return false
}
n := utf8.RuneCountInString(tok)
if hasCJK {
// mixed CJK+latin in one token: count as CJK rule by total length
return n >= MinCJKTokenRunes && n <= MaxCJKTokenRunes
}
if hasLetter {
return n >= 2 && n <= MaxThreadsTermRunes
}
return false
}
func isCJK(r rune) bool {
return unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hiragana, r) ||
unicode.Is(unicode.Katakana, r) ||
(r >= 0x3000 && r <= 0x303F) // CJK punctuation block — treated as CJK char class but punct rejected above
}

View File

@ -0,0 +1,52 @@
package domain
import "testing"
func TestNormalizeSearchTerm(t *testing.T) {
cases := []struct {
in, want string
}{
{" 保母 求推薦 ", "保母 求推薦"},
{"到府 保母", "到府 保母"},
{"\t ", ""},
}
for _, c := range cases {
if got := NormalizeSearchTerm(c.in); got != c.want {
t.Errorf("NormalizeSearchTerm(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestIsThreadsSearchable(t *testing.T) {
ok := []string{
"保母 求推薦",
"到府保母",
"求推薦",
"有人知道",
"wedding photo",
"台北 保母",
}
for _, s := range ok {
if !IsThreadsSearchable(s) {
t.Errorf("IsThreadsSearchable(%q) = false, want true", s)
}
}
bad := []string{
"",
"a",
"台北 婚攝 推薦", // 3 tokens
"這是一個超長關鍵字超過十二字", // too long
"保母 AND 求推薦",
"保母#推薦",
"保母!",
"🎉 保母",
"\"保母\"",
" ",
"一", // 1 CJK rune
}
for _, s := range bad {
if IsThreadsSearchable(s) {
t.Errorf("IsThreadsSearchable(%q) = true, want false", s)
}
}
}

View File

@ -0,0 +1,136 @@
package usecase
import (
"context"
"fmt"
"strings"
"apps/backend/internal/module/radar/domain"
)
// ExploreResult is the outcome of an on-demand explore run (no watch subscription).
type ExploreResult struct {
SweepID string
HitCount int
JudgedCount int
CreatedCount int
TruncatedCount int
CreditsUsed int
}
/*
ExploreOpportunities runs an immediate search five-question judge pipeline
for a short list of Threads-searchable terms (contract B / plan T3).
Reuses FetchCandidates path (HitFetch fan-out) and ProcessCandidates (quota,
dedupe, reasons). Does not bypass daily opportunity caps. Not async.
*/
func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string) (*ExploreResult, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
terms, err := validateExploreTerms(rawTerms)
if err != nil {
return nil, err
}
// Profile optional at gate — judge tolerates nil profile (same as manual import).
profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID)
// Synthetic watch so FetchCandidates / ProcessCandidates can carry terms.
w := &domain.RadarWatch{
ID: "",
OwnerUID: ownerUID,
Terms: terms,
Status: domain.WatchActive,
}
// Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates.
const exploreHitLimit = 20
cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, exploreHitLimit)
if ferr != nil {
return nil, ferr
}
sw, err := s.BeginSweepRecord(ctx, ownerUID, "", "", path)
if err != nil {
return nil, err
}
if path != "" {
_ = s.setSweepPath(ctx, sw.ID, path)
}
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
HitCount: len(cands),
CreditsUsed: fetchCredits,
})
created, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
ctx, ownerUID, w, profile, sw.ID, cands, nil,
)
if perr != nil {
reason := "判定流程失敗:" + perr.Error()
end := domain.NowNano()
_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end})
return nil, perr
}
end := domain.NowNano()
var failPtr *string
if failed > 0 && created == 0 && judged > 0 {
r := fmt.Sprintf("%d 筆判定失敗", failed)
failPtr = &r
}
sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
CreditsUsed: judgeCredits,
EndedAt: end,
FailedReason: failPtr,
})
credits := fetchCredits + judgeCredits
if sw != nil {
// UpdateSweep may have accumulated credits differently; prefer stored total when available.
if sw.CreditsUsed > 0 {
credits = sw.CreditsUsed
}
}
return &ExploreResult{
SweepID: sw.ID,
HitCount: len(cands),
JudgedCount: judged,
CreatedCount: created,
TruncatedCount: truncated,
CreditsUsed: credits,
}, nil
}
func validateExploreTerms(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: terms required (1%d)", domain.ErrValidation, domain.MaxExploreTerms)
}
if len(raw) > domain.MaxExploreTerms {
return nil, fmt.Errorf("%w: at most %d terms", domain.ErrValidation, domain.MaxExploreTerms)
}
out := make([]string, 0, len(raw))
seen := map[string]bool{}
for _, r := range raw {
term := domain.NormalizeSearchTerm(r)
if term == "" {
return nil, fmt.Errorf("%w: empty term", domain.ErrValidation)
}
if !domain.IsThreadsSearchable(term) {
return nil, fmt.Errorf("%w: term %q is not Threads-searchable (≤2 tokens, CJK 24 chars, ≤12 total, no punctuation)", domain.ErrValidation, term)
}
key := strings.ToLower(term)
if seen[key] {
continue
}
seen[key] = true
out = append(out, term)
}
if len(out) == 0 {
return nil, fmt.Errorf("%w: terms required", domain.ErrValidation)
}
return out, nil
}

View File

@ -0,0 +1,150 @@
package usecase
import (
"context"
"errors"
"testing"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/module/radar/repository"
)
func exploreSvc(t *testing.T) (*Service, context.Context) {
t.Helper()
svc := New(repository.NewMemory())
svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
ctx := context.Background()
if _, err := svc.UpsertServiceProfile(ctx, 7, sampleProfile()); err != nil {
t.Fatalf("seed profile: %v", err)
}
return svc, ctx
}
func TestExploreRejectsBadTerms(t *testing.T) {
svc, ctx := exploreSvc(t)
cases := [][]string{
nil,
{},
{"台北 婚攝 推薦 價格"}, // too many tokens
{"ok", "ok2", "ok3", "ok4", "ok5", "ok6", "ok7"}, // >6
{"a"},
}
for _, terms := range cases {
_, err := svc.ExploreOpportunities(ctx, 7, terms)
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("terms=%v err=%v, want ErrValidation", terms, err)
}
}
}
func TestExploreFanOutHitsAndCreates(t *testing.T) {
svc, ctx := exploreSvc(t)
calls := 0
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, terms []string, limit int) ([]ThreadHit, string, error) {
calls++
var hits []ThreadHit
for i, term := range terms {
// unique URLs so each term can produce a create
hits = append(hits, ThreadHit{
URL: "https://www.threads.net/@u/post/e" + string(rune('a'+i)),
Title: term,
Snippet: "求推薦 " + term + ",有人知道嗎?怎麼辦",
})
}
if len(hits) > limit {
hits = hits[:limit]
}
return hits, domain.SweepPathAPI, nil
})
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦", "到府保母"})
if err != nil {
t.Fatalf("explore: %v", err)
}
if calls != 1 {
t.Fatalf("HitFetch calls=%d want 1", calls)
}
if res.HitCount != 2 {
t.Fatalf("hit_count=%d want 2", res.HitCount)
}
if res.SweepID == "" {
t.Fatal("missing sweep_id")
}
if res.JudgedCount < 1 {
t.Fatalf("judged=%d want >=1", res.JudgedCount)
}
if res.CreatedCount < 1 {
t.Fatalf("created=%d want >=1", res.CreatedCount)
}
}
func TestExploreTruncatesWhenQuotaZero(t *testing.T) {
svc, ctx := exploreSvc(t)
max, err := svc.MaxDailyOpportunities(ctx, 7)
if err != nil {
t.Fatalf("max: %v", err)
}
now := domain.NowNano()
for i := 0; i < max; i++ {
id := domain.NewID()
o := &domain.Opportunity{
ID: id, OwnerUID: 7, Source: domain.OppSourceThreads,
ExternalID: "https://x/" + id, Permalink: "https://x/" + id,
AuthorHandle: "u", Text: "dummy", PostedAt: now,
Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh,
Reasons: sampleExploreReasons(), RegionMatch: domain.RegionUnknown, FreshnessHours: 1,
CreatedAt: now, UpdatedAt: now,
}
if _, err := svc.Repo.UpsertByExternalID(ctx, o); err != nil {
t.Fatalf("seed opp: %v", err)
}
}
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, _ []string, _ int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@u/post/new1", Title: "t",
Snippet: "求推薦 到府保母,有人知道嗎?",
}}, domain.SweepPathAPI, nil
})
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦"})
if err != nil {
t.Fatalf("explore: %v", err)
}
if res.CreatedCount != 0 {
t.Fatalf("created=%d want 0 when quota full", res.CreatedCount)
}
if res.TruncatedCount < 1 {
t.Fatalf("truncated=%d want >=1", res.TruncatedCount)
}
}
func TestExploreJudgeFailureTolerated(t *testing.T) {
// Even when AI is down, heuristic judge should still work and explore must not crash.
svc, ctx := exploreSvc(t)
svc.HitFetch = HitFetcherFunc(func(_ context.Context, _ int64, _ []string, _ int) ([]ThreadHit, string, error) {
return []ThreadHit{{
URL: "https://www.threads.net/@u/post/jfail", Title: "t",
Snippet: "求推薦 保母 有人知道嗎",
}}, domain.SweepPathAPI, nil
})
svc.AI = &stubAI{err: errors.New("ai down")}
res, err := svc.ExploreOpportunities(ctx, 7, []string{"保母 求推薦"})
if err != nil {
t.Fatalf("explore must tolerate AI failure via heuristic: %v", err)
}
if res.JudgedCount < 1 {
t.Fatalf("judged=%d want >=1", res.JudgedCount)
}
}
func sampleExploreReasons() []domain.OpportunityReason {
return []domain.OpportunityReason{
{Dimension: domain.DimAuthenticity, Score: 20, Reason: "ok"},
{Dimension: domain.DimIntent, Score: 20, Reason: "ok"},
{Dimension: domain.DimRegion, Score: 10, Reason: "ok"},
{Dimension: domain.DimFreshness, Score: 15, Reason: "ok"},
{Dimension: domain.DimFit, Score: 15, Reason: "ok"},
}
}

View File

@ -0,0 +1,133 @@
package usecase
import (
"context"
"fmt"
"net/url"
"strings"
"apps/backend/internal/module/radar/domain"
)
// MaxManualImportBatch caps one CSV/paste batch so a single request can't force
// an unbounded number of AI judge calls (spec §4.11 P1: 手動匯入不繞過五問判定成本).
const MaxManualImportBatch = 20
// ManualImportItem is one row from a pasted URL or CSV batch import.
type ManualImportItem struct {
URL string
Text string
Author string
PostedAt int64 // unix ns; <=0 => now (import time)
}
// ManualImportResult reports the outcome of one row so the caller can show a
// per-row status even when some rows in the batch fail or are duplicates.
type ManualImportResult struct {
URL string
OpportunityID string
Status string // qualified | rejected | skipped | failed
IntentBand string
IntentScore int
Error string
}
const (
ImportStatusSkipped = "skipped"
ImportStatusFailed = "failed"
)
/*
ImportManualOpportunities 讓使用者貼 ThreadsFacebook 貼文網址 CSV 批次貼上多筆
建立商機跑同一套五問判定來源標 manual_importspec §4.11 P1
不吃每日商機上限這是使用者主動指定的單筆不是自動巡的噪音不該被截斷邏輯擋掉
同一 owner 下同網址已匯入過 略過不重判 sweep dedupe 邏輯一致
*/
func (s *Service) ImportManualOpportunities(ctx context.Context, ownerUID int64, items []ManualImportItem) ([]ManualImportResult, error) {
if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner required", domain.ErrValidation)
}
if len(items) == 0 {
return nil, fmt.Errorf("%w: at least one item required", domain.ErrValidation)
}
if len(items) > MaxManualImportBatch {
return nil, fmt.Errorf("%w: at most %d rows per import", domain.ErrValidation, MaxManualImportBatch)
}
profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID)
now := domain.NowNano()
results := make([]ManualImportResult, 0, len(items))
for _, item := range items {
rawURL := strings.TrimSpace(item.URL)
text := strings.TrimSpace(item.Text)
if rawURL == "" || text == "" {
results = append(results, ManualImportResult{
URL: rawURL, Status: ImportStatusFailed, Error: "網址與貼文內文皆必填",
})
continue
}
if !isImportableURL(rawURL) {
results = append(results, ManualImportResult{
URL: rawURL, Status: ImportStatusFailed, Error: "網址格式不正確,需為 http(s) 開頭",
})
continue
}
if existing, gerr := s.Repo.GetByExternalID(ctx, ownerUID, rawURL); gerr == nil && existing != nil {
results = append(results, ManualImportResult{
URL: rawURL, OpportunityID: existing.ID, Status: ImportStatusSkipped,
IntentBand: existing.IntentBand, IntentScore: existing.IntentScore,
Error: "這個網址已經匯入過,略過重複判定",
})
continue
}
postedAt := item.PostedAt
if postedAt <= 0 {
postedAt = now
}
author := strings.TrimSpace(item.Author)
if author == "" {
author = authorFromURL(rawURL)
}
cand := &domain.CandidatePost{
ExternalID: rawURL, Permalink: rawURL, AuthorHandle: author, Text: text, PostedAt: postedAt,
MatchedTerm: "manual_import", Classification: classifyCandidate(strings.ToLower(text)),
}
res, _, jerr := s.JudgeCandidate(ctx, ownerUID, profile, nil, cand)
if jerr != nil || res == nil {
results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "判定失敗,請稍後再試"})
continue
}
o := &domain.Opportunity{
ID: domain.NewID(), OwnerUID: ownerUID, Source: domain.OppSourceManualImport,
ExternalID: rawURL, Permalink: rawURL, AuthorHandle: author, Text: text, PostedAt: postedAt,
Status: res.Status, IntentScore: res.IntentScore, IntentBand: res.IntentBand,
Reasons: res.Reasons, RegionDetected: res.RegionDetected, RegionMatch: res.RegionMatch,
FreshnessHours: res.FreshnessHours, MatchedService: res.MatchedService,
MatchedTerms: []string{"manual_import"}, RejectReason: res.RejectReason,
}
saved, perr := s.Repo.UpsertByExternalID(ctx, o)
if perr != nil {
results = append(results, ManualImportResult{URL: rawURL, Status: ImportStatusFailed, Error: "儲存失敗,請稍後再試"})
continue
}
results = append(results, ManualImportResult{
URL: rawURL, OpportunityID: saved.ID, Status: saved.Status,
IntentBand: saved.IntentBand, IntentScore: saved.IntentScore,
})
}
return results, nil
}
func isImportableURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}

View File

@ -0,0 +1,138 @@
package usecase
import (
"context"
"strings"
"testing"
"apps/backend/internal/module/radar/domain"
"apps/backend/internal/module/radar/repository"
)
func TestImportManualOpportunities_CreatesWithManualImportSource(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
results, err := svc.ImportManualOpportunities(ctx, 1, []ManualImportItem{
{URL: "https://www.threads.net/@seeker/post/abc123", Text: "台北找廚房翻修師傅,請問多少錢?急!"},
})
if err != nil {
t.Fatalf("import: %v", err)
}
if len(results) != 1 {
t.Fatalf("results len = %d, want 1", len(results))
}
r := results[0]
if r.Status != domain.OppQualified && r.Status != domain.OppRejected {
t.Fatalf("status = %q, want qualified or rejected", r.Status)
}
if r.OpportunityID == "" {
t.Fatalf("expected opportunity id to be set: %+v", r)
}
saved, err := mem.GetOpportunity(ctx, r.OpportunityID)
if err != nil {
t.Fatalf("get saved: %v", err)
}
if saved.Source != domain.OppSourceManualImport {
t.Fatalf("source = %q, want %q", saved.Source, domain.OppSourceManualImport)
}
if saved.OwnerUID != 1 {
t.Fatalf("owner_uid = %d, want 1", saved.OwnerUID)
}
if saved.PostedAt == 0 {
t.Fatal("expected posted_at to default to import time")
}
if err := domain.ValidateReasons(saved.Reasons); err != nil {
t.Fatalf("reasons incomplete: %v", err)
}
}
func TestImportManualOpportunities_DedupSameURLSkipsSecondCall(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
item := ManualImportItem{URL: "https://www.threads.net/@a/post/dup1", Text: "需要找人幫忙裝潢,預算五萬"}
first, err := svc.ImportManualOpportunities(ctx, 2, []ManualImportItem{item})
if err != nil {
t.Fatalf("first import: %v", err)
}
firstID := first[0].OpportunityID
second, err := svc.ImportManualOpportunities(ctx, 2, []ManualImportItem{item})
if err != nil {
t.Fatalf("second import: %v", err)
}
if second[0].Status != ImportStatusSkipped {
t.Fatalf("status = %q, want skipped", second[0].Status)
}
if second[0].OpportunityID != firstID {
t.Fatalf("opportunity id changed on dedupe: got %q want %q", second[0].OpportunityID, firstID)
}
// Different owner importing the same URL is not a duplicate — each owner has their own pipeline.
third, err := svc.ImportManualOpportunities(ctx, 3, []ManualImportItem{item})
if err != nil {
t.Fatalf("third import: %v", err)
}
if third[0].Status == ImportStatusSkipped {
t.Fatalf("cross-owner import should not be treated as duplicate: %+v", third[0])
}
}
func TestImportManualOpportunities_RowLevelValidationDoesNotFailWholeBatch(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
results, err := svc.ImportManualOpportunities(ctx, 4, []ManualImportItem{
{URL: "", Text: "缺網址"},
{URL: "not-a-url", Text: "網址格式錯"},
{URL: "https://www.threads.net/@ok/post/1", Text: "台中需要清潔服務,這週可以嗎?"},
})
if err != nil {
t.Fatalf("import: %v", err)
}
if len(results) != 3 {
t.Fatalf("results len = %d, want 3", len(results))
}
if results[0].Status != ImportStatusFailed || results[0].Error == "" {
t.Fatalf("row 0 = %+v, want failed with reason", results[0])
}
if results[1].Status != ImportStatusFailed || results[1].Error == "" {
t.Fatalf("row 1 = %+v, want failed with reason", results[1])
}
if results[2].OpportunityID == "" {
t.Fatalf("row 2 should have imported successfully: %+v", results[2])
}
}
func TestImportManualOpportunities_RejectsEmptyOrOversizedBatch(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
if _, err := svc.ImportManualOpportunities(ctx, 1, nil); err == nil {
t.Fatal("expected error for empty batch")
}
items := make([]ManualImportItem, MaxManualImportBatch+1)
for i := range items {
items[i] = ManualImportItem{URL: "https://www.threads.net/@x/post/" + string(rune('a'+i)), Text: "測試"}
}
if _, err := svc.ImportManualOpportunities(ctx, 1, items); err == nil || !strings.Contains(err.Error(), "rows") {
t.Fatalf("expected batch size error, got %v", err)
}
}
func TestImportManualOpportunities_RequiresOwner(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
if _, err := svc.ImportManualOpportunities(ctx, 0, []ManualImportItem{{URL: "https://x.com/a", Text: "t"}}); err == nil {
t.Fatal("expected error for missing owner")
}
}

View File

@ -15,11 +15,30 @@ type HealthGate interface {
WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error)
} }
// MarkReplyUsed records that a draft was sent or copied (T550). // ReplyQueue puts a reply into the shared Outbox pipeline (same path as scout's
// channel: outbox | manual_copy // outreach send). Implemented by an adapter over studio.Service.QueueExternalReply.
// - dm variants: only manual_copy type ReplyQueue interface {
// - outbox: rejects when health=throttle; warn still allowed (caller may surface advice) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunityID, replyID, channel string) (*domain.ReplyVariant, string, error) { }
// MediaResolver turns a Threads permalink into a numeric Graph media ID.
// Opportunity.ExternalID/Permalink is a permalink, not a media id — implemented
// by an adapter that reuses scout's crawler session + resolver.
type MediaResolver interface {
ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error)
}
/*
MarkReplyUsed records that a draft was sent or copied (T550).
channel: outbox | manual_copy
- dm variants: only manual_copy
- outbox: rejects when health=throttle; warn still allowed (caller may surface advice).
accountID selects which usable Threads account sends; required when ReplyQueue is
configured (production). When ReplyQueue is nil (offline tests/demo) marking still
succeeds without a real send, matching the previous behaviour.
*/
func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunityID, replyID, channel, accountID string) (*domain.ReplyVariant, string, error) {
channel = strings.TrimSpace(channel) channel = strings.TrimSpace(channel)
if channel != domain.SentOutbox && channel != domain.SentManualCopy { if channel != domain.SentOutbox && channel != domain.SentManualCopy {
return nil, "", fmt.Errorf("%w: channel must be outbox or manual_copy", domain.ErrValidation) return nil, "", fmt.Errorf("%w: channel must be outbox or manual_copy", domain.ErrValidation)
@ -28,7 +47,6 @@ func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunity
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
_ = o
r, err := s.Repo.GetReply(ctx, replyID) r, err := s.Repo.GetReply(ctx, replyID)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
@ -59,6 +77,30 @@ func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunity
} }
} }
} }
if s.ReplyQueue != nil {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, "", fmt.Errorf("%w: account_id required to send via outbox", domain.ErrValidation)
}
mediaID := o.ExternalID
if s.MediaResolver != nil {
resolved, rerr := s.MediaResolver.ResolveMediaID(ctx, ownerUID, o.Permalink)
if rerr != nil {
return nil, "", rerr
}
mediaID = resolved
}
title := "商機回覆"
if o.AuthorHandle != "" {
title += " · @" + o.AuthorHandle
}
outboxID, serr := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, r.Text, title)
if serr != nil {
return nil, "", serr
}
r.OutboxID = outboxID
}
} }
r.UsedAt = domain.NowNano() r.UsedAt = domain.NowNano()

View File

@ -2,6 +2,7 @@ package usecase
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"testing" "testing"
@ -53,7 +54,7 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
got, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentManualCopy) got, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentManualCopy, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -61,19 +62,19 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
t.Fatalf("manual mark: %+v", got) t.Fatalf("manual mark: %+v", got)
} }
if _, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r2", domain.SentOutbox); err == nil { if _, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r2", domain.SentOutbox, ""); err == nil {
t.Fatal("dm outbox should fail") t.Fatal("dm outbox should fail")
} }
svc.Health = fakeHealth{level: "throttle", advice: "慢一點"} svc.Health = fakeHealth{level: "throttle", advice: "慢一點"}
_, _, err = svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentOutbox) _, _, err = svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentOutbox, "")
// r1 already used; still exercise throttle on a fresh reply // r1 already used; still exercise throttle on a fresh reply
pub2 := &domain.ReplyVariant{ pub2 := &domain.ReplyVariant{
ID: "r3", OwnerUID: uid, OpportunityID: "o1", Variant: domain.ReplyPublicComment, ID: "r3", OwnerUID: uid, OpportunityID: "o1", Variant: domain.ReplyPublicComment,
Text: "再一則", CreatedAt: now, Text: "再一則", CreatedAt: now,
} }
_ = mem.SaveReply(ctx, pub2) _ = mem.SaveReply(ctx, pub2)
_, advice, err := svc.MarkReplyUsed(ctx, uid, "o1", "r3", domain.SentOutbox) _, advice, err := svc.MarkReplyUsed(ctx, uid, "o1", "r3", domain.SentOutbox, "")
if err == nil { if err == nil {
t.Fatal("expected throttle block") t.Fatal("expected throttle block")
} }
@ -84,3 +85,116 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
t.Fatalf("advice = %q", advice) t.Fatalf("advice = %q", advice)
} }
} }
type fakeReplyQueue struct {
outboxID string
err error
calls []queuedReply
}
type queuedReply struct {
ownerUID int64
accountID string
mediaID string
text string
title string
}
func (f *fakeReplyQueue) QueueExternalReply(_ context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (string, error) {
f.calls = append(f.calls, queuedReply{ownerUID, accountID, replyToMediaID, text, title})
if f.err != nil {
return "", f.err
}
if f.outboxID != "" {
return f.outboxID, nil
}
return "outbox-1", nil
}
type fakeMediaResolver struct {
mediaID string
err error
}
func (f fakeMediaResolver) ResolveMediaID(_ context.Context, _ int64, _ string) (string, error) {
if f.err != nil {
return "", f.err
}
return f.mediaID, nil
}
// 接上 ReplyQueue 後outbox 標記要真的排入既有 Outbox用解析後的 media id、
// 帳號來自呼叫端),且把回傳的 outbox_id 存回 reply 供前端顯示。
func TestMarkReplyUsed_OutboxRealSend(t *testing.T) {
ctx := context.Background()
mem := repository.NewMemory()
svc := New(mem)
uid := int64(3)
now := domain.NowNano()
o := &domain.Opportunity{
ID: "o1", OwnerUID: uid, ExternalID: "https://threads.net/@a/post/e1", Permalink: "https://threads.net/@a/post/e1",
AuthorHandle: "a", Text: "需要幫忙", PostedAt: now, Status: domain.OppQualified,
IntentScore: 80, IntentBand: domain.BandHigh,
Reasons: []domain.OpportunityReason{
{Dimension: domain.DimAuthenticity, Score: 20, Reason: "a"},
{Dimension: domain.DimIntent, Score: 20, Reason: "i"},
{Dimension: domain.DimRegion, Score: 10, Reason: "r"},
{Dimension: domain.DimFreshness, Score: 15, Reason: "f"},
{Dimension: domain.DimFit, Score: 15, Reason: "fit"},
},
RegionMatch: domain.RegionUnknown, MatchedTerms: []string{"x"}, CreatedAt: now, UpdatedAt: now,
}
if _, err := mem.UpsertByExternalID(ctx, o); err != nil {
t.Fatal(err)
}
pub := &domain.ReplyVariant{
ID: "r1", OwnerUID: uid, OpportunityID: "o1", Variant: domain.ReplyPublicComment,
Text: "嗨", CreatedAt: now,
}
if err := mem.SaveReply(ctx, pub); err != nil {
t.Fatal(err)
}
queue := &fakeReplyQueue{outboxID: "outbox-42"}
svc.ReplyQueue = queue
svc.MediaResolver = fakeMediaResolver{mediaID: "999888777"}
// 沒帶 account_id 要明確拒絕,不能默默送到不存在的帳號。
if _, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentOutbox, ""); !strings.Contains(err.Error(), "account_id") {
t.Fatalf("err = %v, want account_id required", err)
}
got, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r1", domain.SentOutbox, "acc-1")
if err != nil {
t.Fatalf("mark used: %v", err)
}
if got.OutboxID != "outbox-42" {
t.Fatalf("outbox_id = %q, want outbox-42", got.OutboxID)
}
if len(queue.calls) != 1 {
t.Fatalf("QueueExternalReply calls = %d, want 1", len(queue.calls))
}
call := queue.calls[0]
if call.accountID != "acc-1" || call.mediaID != "999888777" || call.text != "嗨" {
t.Fatalf("queued call = %+v", call)
}
// 佇列失敗要整體失敗,不能標記已用卻沒真的送出。
pub2 := &domain.ReplyVariant{
ID: "r2", OwnerUID: uid, OpportunityID: "o1", Variant: domain.ReplyPublicComment,
Text: "再一則", CreatedAt: now,
}
_ = mem.SaveReply(ctx, pub2)
queue.err = fmt.Errorf("threads api down")
if _, _, err := svc.MarkReplyUsed(ctx, uid, "o1", "r2", domain.SentOutbox, "acc-1"); err == nil {
t.Fatal("expected queue failure to propagate")
}
after, gerr := mem.GetReply(ctx, "r2")
if gerr != nil {
t.Fatalf("get reply: %v", gerr)
}
if after.UsedAt != 0 {
t.Fatalf("reply should not be marked used when the queue call failed: %+v", after)
}
}

View File

@ -37,6 +37,12 @@ type Service struct {
CRM ContactBinder CRM ContactBinder
// Health gates auto-send of public replies (AccountHealth throttle). // Health gates auto-send of public replies (AccountHealth throttle).
Health HealthGate Health HealthGate
// ReplyQueue 是既有 Outbox 佇列studio.QueueExternalReplynil 時 outbox 標記
// 只記錄不真送讓離線測試demo 環境仍能跑(見 MarkReplyUsed
ReplyQueue ReplyQueue
// MediaResolver 把商機的 permalink 解成可送出的 Threads 數字 media id
// (商機 external_id 本身是 permalink不是 media id借用海巡既有 crawler 解析,不重造第二套)。
MediaResolver MediaResolver
} }
// ContactBinder creates or binds a CRM contact when accepting an opportunity. // ContactBinder creates or binds a CRM contact when accepting an opportunity.

View File

@ -78,9 +78,12 @@ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
b.WriteString(`[{"term":"關鍵字","reason":"為什麼這個詞能找到有需求的人(一句話)","usage":"include 或 exclude"}]`) b.WriteString(`[{"term":"關鍵字","reason":"為什麼這個詞能找到有需求的人(一句話)","usage":"include 或 exclude"}]`)
b.WriteString("\n規則\n") b.WriteString("\n規則\n")
b.WriteString("1. include 是要搜尋的詞exclude 是要排除的詞(例如同業叫賣、徵才、二手轉讓)。\n") b.WriteString("1. include 是要搜尋的詞exclude 是要排除的詞(例如同業叫賣、徵才、二手轉讓)。\n")
b.WriteString("2. 用台灣的實際說法,包含口語問法(例如「有人推薦嗎」)。\n") b.WriteString("2. 用台灣的實際說法,包含口語求助句式:求推薦、有人知道、請問、怎麼辦、哪裡買。\n")
b.WriteString("3. 每則都要有理由,理由講人話,不要覆述關鍵字本身。\n") b.WriteString("3. 每則都要有理由,理由講人話,不要覆述關鍵字本身。\n")
b.WriteString("4. 不要輸出價格數字或聯絡方式。\n") b.WriteString("4. 不要輸出價格數字或聯絡方式。\n")
b.WriteString("5. 【Threads 短詞硬約束include 必守】每則 term 最多 2 個詞(半形空格分隔);")
b.WriteString("中文每詞 24 字;整組去掉空格後 ≤12 字禁止標點、引號、AND/OR、-、emoji、#。\n")
b.WriteString("6. 每個服務意圖給 35 組短變體(例:「保母 求推薦」「到府保母」「台北 保母」),不要長句。\n")
return b.String() return b.String()
} }

View File

@ -64,9 +64,9 @@ func suggestService(t *testing.T, reply string) (*Service, *stubAI, context.Cont
} }
const suggestReply = `[ const suggestReply = `[
{"term":"台北 婚攝 推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"}, {"term":"婚攝 推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
{"term":"影 價格","reason":"問價格通常已經在比較廠商","usage":"include"}, {"term":"台北 婚攝","reason":"帶地區的人通常已在比較廠商","usage":"include"},
{"term":"徵 婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"} {"term":"徵婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
]` ]`
// RW-03有服務檔案就回得出建議每則都要有理由。 // RW-03有服務檔案就回得出建議每則都要有理由。
@ -160,19 +160,20 @@ func TestSuggestRespectsLimit(t *testing.T) {
} }
func TestSuggestDropsUnusableItems(t *testing.T) { func TestSuggestDropsUnusableItems(t *testing.T) {
// 沒理由、太短、重複的項目都要丟掉,而不是補一句假理由湊數 // 沒理由、太短、重複、超過 Threads 短詞規則的 include 都要丟掉
svc, _, ctx := suggestService(t, `[ svc, _, ctx := suggestService(t, `[
{"term":"婚攝 推薦","reason":"在找攝影師的人常這樣問","usage":"include"}, {"term":"婚攝 推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
{"term":"沒有理由的詞","reason":" ","usage":"include"}, {"term":"沒有理由的詞","reason":" ","usage":"include"},
{"term":"a","reason":"太短","usage":"include"}, {"term":"a","reason":"太短","usage":"include"},
{"term":"婚攝 推薦","reason":"重複","usage":"include"} {"term":"台北 婚攝 推薦 價格","reason":"三詞以上不合 Threads 規則","usage":"include"},
{"term":"婚攝 求推薦","reason":"重複","usage":"include"}
]`) ]`)
list, err := svc.SuggestWatchTerms(ctx, 42, 0) list, err := svc.SuggestWatchTerms(ctx, 42, 0)
if err != nil { if err != nil {
t.Fatalf("suggest: %v", err) t.Fatalf("suggest: %v", err)
} }
if len(list) != 1 || list[0].Term != "婚攝 推薦" { if len(list) != 1 || list[0].Term != "婚攝 推薦" {
t.Fatalf("got %+v, want only the one usable suggestion", list) t.Fatalf("got %+v, want only the one usable suggestion", list)
} }
} }

View File

@ -3,6 +3,7 @@ package usecase
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"apps/backend/internal/module/radar/domain" "apps/backend/internal/module/radar/domain"
@ -78,16 +79,17 @@ func DailySweepRunAt(now time.Time) int64 {
// BeginSweepRecord creates the RadarSweep shell for a claimed job (T527). // BeginSweepRecord creates the RadarSweep shell for a claimed job (T527).
// Fetch / judge (T528T530) attach progress onto the same record via UpdateSweep. // Fetch / judge (T528T530) attach progress onto the same record via UpdateSweep.
func (s *Service) BeginSweepRecord(ctx context.Context, ownerUID int64, watchID, jobID, path string) (*domain.RadarSweep, error) { func (s *Service) BeginSweepRecord(ctx context.Context, ownerUID int64, watchID, jobID, path string) (*domain.RadarSweep, error) {
if ownerUID <= 0 || watchID == "" { if ownerUID <= 0 {
return nil, fmt.Errorf("%w: owner_uid and watch_id required", domain.ErrValidation) return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
} }
// watchID may be empty for on-demand explore (no subscription).
if path == "" { if path == "" {
path = domain.SweepPathAPI path = domain.SweepPathAPI
} }
sw := &domain.RadarSweep{ sw := &domain.RadarSweep{
ID: domain.NewID(), ID: domain.NewID(),
OwnerUID: ownerUID, OwnerUID: ownerUID,
WatchID: watchID, WatchID: strings.TrimSpace(watchID),
JobID: jobID, JobID: jobID,
Path: path, Path: path,
StartedAt: domain.NowNano(), StartedAt: domain.NowNano(),

View File

@ -95,6 +95,9 @@ type RunBrief struct {
ThemeKey string `json:"theme_key,omitempty"` ThemeKey string `json:"theme_key,omitempty"`
ThemeLabel string `json:"theme_label,omitempty"` ThemeLabel string `json:"theme_label,omitempty"`
ProductContext string `json:"product_context,omitempty"` ProductContext string `json:"product_context,omitempty"`
// TargetCount is the desired hit count for this scan (topic daily goal).
// When >0, RunScanFromBrief may top up (boost per-query, then secondary path).
TargetCount int `json:"target_count,omitempty"`
} }
type Post struct { type Post struct {

View File

@ -0,0 +1,290 @@
package usecase
import (
"encoding/json"
"strings"
"unicode"
"unicode/utf8"
radarDomain "apps/backend/internal/module/radar/domain"
)
// 話題activity口語錨點與 Threads 上常見「可跟/可討論」貼文語感對齊。
// 每詞 24 字,方便與主題核組 2-token 短查詢。
var activityAnchors = []string{
"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
}
// 台灣常見地名(作前綴 token23 字)
var activityRegions = []string{
"台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆",
"嘉義", "宜蘭", "花蓮", "台東", "屏東", "彰化", "雲林", "南投",
"苗栗", "金門", "澎湖", "板橋", "中和", "三重", "淡水", "竹北",
}
/*
planActivityTerms 依使用者意圖**產生**可搜的短詞變體不是只把原文拆開
策略
1. 抽出主題核24 CJK 2+ 英數與地區
2. 核本身 + ×口語錨點 + 地區×
3. 一律過 Threads 短詞規則與商機 suggest/explore 同一契約
*/
func planActivityTerms(intent string) []string {
intent = radarDomain.NormalizeSearchTerm(intent)
if intent == "" {
return nil
}
cores := extractTopicCores(intent)
regions := extractRegions(intent)
var candidates []string
// 原句若已合規,保留(使用者自己打的短詞)
if radarDomain.IsThreadsSearchable(intent) {
candidates = append(candidates, intent)
}
for _, core := range cores {
if radarDomain.IsThreadsSearchable(core) {
candidates = append(candidates, core)
}
for _, anchor := range activityAnchors {
pair := core + " " + anchor
if radarDomain.IsThreadsSearchable(pair) {
candidates = append(candidates, pair)
}
}
for _, region := range regions {
pair := region + " " + core
if radarDomain.IsThreadsSearchable(pair) {
candidates = append(candidates, pair)
}
}
}
// 地區 + 錨點(例:台北 活動)— 主題核不足時的備援
if len(cores) == 0 {
for _, region := range regions {
for _, anchor := range []string{"活動", "推薦", "分享"} {
pair := region + " " + anchor
if radarDomain.IsThreadsSearchable(pair) {
candidates = append(candidates, pair)
}
}
}
}
return capTerms(dedupeTerms(candidates), 8)
}
// extractTopicCores 從意圖抽出可當搜尋主詞的 24 字核。
func extractTopicCores(intent string) []string {
intent = radarDomain.NormalizeSearchTerm(intent)
if intent == "" {
return nil
}
var cores []string
// 已有空白:每段當候選(再壓成 24 字)
if strings.Contains(intent, " ") {
for _, tok := range strings.Fields(intent) {
if c := compactCore(tok); c != "" {
cores = append(cores, c)
}
}
return dedupeTerms(cores)
}
// 連續中文:用標點/空白切完後,對每段取 24 字滑窗(優先較長有意義核)
segments := tokenizeIntent(intent)
if len(segments) == 0 {
segments = []string{intent}
}
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
// 去掉地區再抽核,避免「台北」佔掉唯一核
stripped := stripRegions(seg)
if stripped == "" {
stripped = seg
}
if c := compactCore(stripped); c != "" {
cores = append(cores, c)
}
// 滑窗 4→3→2取不重疊優先
cores = append(cores, slidingCJKCores(stripped)...)
}
return dedupeTerms(cores)
}
func compactCore(tok string) string {
tok = strings.TrimSpace(tok)
if tok == "" {
return ""
}
// 去掉常見口語虛詞尾巴
for _, filler := range []string{
"怎麼辦", "求推薦", "有沒有人", "有人知道", "請問一下", "請問",
"想問", "想找", "有沒有", "可以嗎", "好不好",
} {
tok = strings.ReplaceAll(tok, filler, "")
}
tok = strings.Trim(tok, " ,,、。!?!?:的了嗎呢啊喔唷")
if tok == "" {
return ""
}
n := utf8.RuneCountInString(tok)
// 單 token 24 CJK 或 212 英數才當核
if radarDomain.IsThreadsSearchable(tok) {
return radarDomain.NormalizeSearchTerm(tok)
}
// 過長:取前 4 字當核
if n > 4 {
r := []rune(tok)
cand := string(r[:4])
if radarDomain.IsThreadsSearchable(cand) {
return cand
}
cand = string(r[:3])
if radarDomain.IsThreadsSearchable(cand) {
return cand
}
cand = string(r[:2])
if radarDomain.IsThreadsSearchable(cand) {
return cand
}
}
return ""
}
func slidingCJKCores(seg string) []string {
runes := []rune(seg)
// 只保留 CJK字母數字
var cleaned []rune
for _, r := range runes {
if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) || unicode.IsDigit(r) {
cleaned = append(cleaned, r)
}
}
if len(cleaned) < 2 {
return nil
}
var out []string
// 優先較長窗
for size := 4; size >= 2; size-- {
if len(cleaned) < size {
continue
}
for i := 0; i+size <= len(cleaned); i++ {
cand := string(cleaned[i : i+size])
if radarDomain.IsThreadsSearchable(cand) {
out = append(out, cand)
}
// 限制數量,避免長句爆炸
if len(out) >= 6 {
return dedupeTerms(out)
}
}
}
return dedupeTerms(out)
}
func extractRegions(intent string) []string {
var out []string
for _, r := range activityRegions {
if strings.Contains(intent, r) {
out = append(out, r)
}
}
return out
}
func stripRegions(s string) string {
for _, r := range activityRegions {
s = strings.ReplaceAll(s, r, "")
}
return strings.TrimSpace(s)
}
// parseActivityAITerms 解析模型回傳的 JSON 字串陣列或 {term} 物件陣列。
func parseActivityAITerms(raw string) []string {
start := strings.Index(raw, "[")
end := strings.LastIndex(raw, "]")
if start < 0 || end <= start {
return nil
}
chunk := raw[start : end+1]
var asStrings []string
if err := json.Unmarshal([]byte(chunk), &asStrings); err == nil {
return asStrings
}
var asObjs []struct {
Term string `json:"term"`
}
if err := json.Unmarshal([]byte(chunk), &asObjs); err == nil {
out := make([]string, 0, len(asObjs))
for _, o := range asObjs {
if t := strings.TrimSpace(o.Term); t != "" {
out = append(out, t)
}
}
return out
}
return nil
}
func filterThreadsSearchableTerms(in []string) []string {
var out []string
for _, t := range in {
t = radarDomain.NormalizeSearchTerm(t)
if radarDomain.IsThreadsSearchable(t) {
out = append(out, t)
}
}
return dedupeTerms(out)
}
// mergeActivityTerms 優先 AI 產詞,規則變體補足到 max。
func mergeActivityTerms(aiTerms, ruleTerms []string, max int) []string {
ai := filterThreadsSearchableTerms(aiTerms)
rules := filterThreadsSearchableTerms(ruleTerms)
return capTerms(dedupeTerms(ai, rules), max)
}
func activityTermsPrompt(intent string, limit int) string {
var b strings.Builder
b.WriteString("你是台灣 Threads 話題搜尋助理。使用者想找「可以跟風討論/留言的活躍話題」貼文,不是找客戶。\n")
b.WriteString("主題意圖:")
b.WriteString(intent)
b.WriteString("\n\n")
b.WriteString("請產出搜尋關鍵字,幫助在 Threads 找到正在討論這個主題的貼文。\n")
b.WriteString("只輸出 JSON 字串陣列,不要其他文字。格式:\n")
b.WriteString(`["關鍵字1","關鍵字2"]`)
b.WriteString("\n規則\n")
b.WriteString("1. 最多 ")
b.WriteString(itoaASCII(limit))
b.WriteString(" 組。\n")
b.WriteString("2. 【Threads 短詞硬約束】每組最多 2 個詞(半形空格分隔);中文每詞 24 字;整組去掉空格後 ≤12 字禁止標點、引號、AND/OR、emoji、#。\n")
b.WriteString("3. 用台灣口語:求推薦、分享、心得、活動、怎麼辦、有人知道、討論。\n")
b.WriteString("4. 每個意圖給多組短變體(例:「市集 分享」「週末 市集」「文青 市集」),不要只拆使用者原句。\n")
b.WriteString("5. 不要輸出價格、連結、帳號。\n")
return b.String()
}
func itoaASCII(n int) string {
if n <= 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}

View File

@ -0,0 +1,113 @@
package usecase
import (
"context"
"strings"
"testing"
"apps/backend/internal/module/scout/domain"
radarDomain "apps/backend/internal/module/radar/domain"
)
func TestPrepareActivityBriefUsesGeneratedTerms(t *testing.T) {
svc := &Service{}
brief, err := svc.PrepareBrief(context.Background(), 1, "市集", "", "", "activity", true)
if err != nil {
t.Fatalf("PrepareBrief: %v", err)
}
if brief.Mode != domain.ModeActivity {
t.Fatalf("mode=%q want %q", brief.Mode, domain.ModeActivity)
}
if len(brief.ScanTerms) < 2 {
t.Fatalf("ScanTerms=%v want generated variants", brief.ScanTerms)
}
}
func TestPlanActivityTermsGeneratesVariantsNotJustSplit(t *testing.T) {
// 單一字主題:應產出「核 + 口語錨點」,不是只有原字
got := planActivityTerms("市集")
if len(got) < 3 {
t.Fatalf("市集 variants=%v want >=3 generated terms", got)
}
hasPair := false
for _, term := range got {
if !radarDomain.IsThreadsSearchable(term) {
t.Fatalf("term %q not Threads-searchable", term)
}
if strings.Contains(term, " ") {
hasPair = true
}
}
if !hasPair {
t.Fatalf("expected multi-token variants, got %v", got)
}
// 長句:不應只回整句,要抽出核並產變體
got2 := planActivityTerms("想找台北週末市集可以拍什麼")
if len(got2) < 2 {
t.Fatalf("long intent variants=%v want >=2", got2)
}
for _, term := range got2 {
if !radarDomain.IsThreadsSearchable(term) {
t.Fatalf("long-intent term %q not searchable", term)
}
// 不應留下整句長字串
if utf8Count(term) > 12 {
t.Fatalf("term too long: %q", term)
}
}
// 應含地區或市集相關
joined := strings.Join(got2, "|")
if !strings.Contains(joined, "市集") && !strings.Contains(joined, "台北") {
t.Fatalf("expected 市集/台北 in variants, got %v", got2)
}
}
func TestPlanActivityTermsNotJustTokenizeIntent(t *testing.T) {
// 舊行為tokenizeIntent("週末市集") → ["週末市集"] 或空拆
// 新行為:至少有錨點變體
got := planActivityTerms("週末市集")
if len(got) < 2 {
t.Fatalf("got %v, want multiple generated variants", got)
}
// 若只有把四字當一詞,也要再有「週末 市集」類或「市集 推薦」
onlyExact := len(got) == 1 && got[0] == "週末市集"
if onlyExact {
t.Fatal("still only returning exact intent — not generating")
}
}
func TestMergeActivityTermsPrefersAI(t *testing.T) {
ai := []string{"市集 分享", "文青 市集", "太長了吧這整句不行"}
rules := []string{"市集 推薦", "市集"}
got := mergeActivityTerms(ai, rules, 6)
if len(got) == 0 {
t.Fatal("empty merge")
}
if got[0] != "市集 分享" {
t.Fatalf("AI term should come first, got %v", got)
}
// 長詞應被濾掉
for _, t2 := range got {
if strings.Contains(t2, "太長") {
t.Fatalf("unssearchable term leaked: %v", got)
}
}
}
func TestParseActivityAITerms(t *testing.T) {
raw := "這裡是開場\n[\"市集 分享\", \"週末 市集\"]\n結尾"
got := parseActivityAITerms(raw)
if len(got) != 2 {
t.Fatalf("parse strings: %v", got)
}
raw2 := `[{"term":"市集 心得","reason":"x"},{"term":"活動 市集"}]`
got2 := parseActivityAITerms(raw2)
if len(got2) != 2 {
t.Fatalf("parse objects: %v", got2)
}
}
func utf8Count(s string) int {
return len([]rune(strings.ReplaceAll(s, " ", "")))
}

View File

@ -84,7 +84,8 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
if err != nil { if err != nil {
return nil, err return nil, err
} }
runCtx, cancel := context.WithTimeout(ctx, 90*time.Second) // 雙軌(熱門+最新)約兩倍時間
runCtx, cancel := context.WithTimeout(ctx, 150*time.Second)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(runCtx, http.MethodPost, p.Endpoint+"/v1/threads/search", bytes.NewReader(body)) req, err := http.NewRequestWithContext(runCtx, http.MethodPost, p.Endpoint+"/v1/threads/search", bytes.NewReader(body))
if err != nil { if err != nil {
@ -94,7 +95,7 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
req.Header.Set("Authorization", "Bearer "+p.Token) req.Header.Set("Authorization", "Bearer "+p.Token)
client := p.HTTP client := p.HTTP
if client == nil { if client == nil {
client = &http.Client{Timeout: 95 * time.Second} client = &http.Client{Timeout: 160 * time.Second}
} }
res, err := client.Do(req) res, err := client.Do(req)
if err != nil { if err != nil {
@ -110,6 +111,10 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
Permalink string `json:"permalink"` Permalink string `json:"permalink"`
Author string `json:"author"` Author string `json:"author"`
Text string `json:"text"` Text string `json:"text"`
Track string `json:"track"`
SerpRank int `json:"serp_rank"`
PublishedAt string `json:"published_at"`
PublishedLabel string `json:"published_label"`
} `json:"posts"` } `json:"posts"`
} }
if err := json.Unmarshal(raw, &out); err != nil { if err := json.Unmarshal(raw, &out); err != nil {
@ -120,7 +125,58 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
if !isThreadsURL(post.Permalink) || strings.TrimSpace(post.Text) == "" { if !isThreadsURL(post.Permalink) || strings.TrimSpace(post.Text) == "" {
continue continue
} }
results = append(results, ThreadSearchResult{URL: post.Permalink, Title: post.Author, Snippet: post.Text}) track := normalizeSearchTrack(post.Track)
pub := parsePublishedDateNano(post.PublishedAt)
// 硬擋僅極舊(預設 180 天45 天軟降權在 persist 做,避免誤殺
if pub > 0 && isStalePublished(pub, defaultScoutHardMaxAgeDays) {
continue
}
results = append(results, ThreadSearchResult{
URL: post.Permalink,
Title: post.Author,
Snippet: post.Text,
PublishedAt: pub,
Track: track,
SerpRank: post.SerpRank,
})
} }
return results, nil return results, nil
} }
func normalizeSearchTrack(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "both", "top+recent", "top_recent":
return "both"
case "recent", "latest", "new":
return "recent"
case "top", "hot", "default":
return "top"
default:
return ""
}
}
// defaultScoutHardMaxAgeDays僅極舊文硬擋對齊 crawler SCOUT_MAX_AGE_DAYS 預設 180
const defaultScoutHardMaxAgeDays = 180
// defaultScoutSoftAgeDays超過此天數降權但不丟對齊「防 2024」與「別砍稍舊好文」
const defaultScoutSoftAgeDays = 45
// minCrediblePublishedNano早於 2020-01-01 的時間戳視為假資料/測試 stub不套用時效過濾。
var minCrediblePublishedNano = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano()
func isStalePublished(publishedAtNano int64, maxAgeDays int) bool {
if publishedAtNano < minCrediblePublishedNano || maxAgeDays <= 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays).UnixNano()
return publishedAtNano < cutoff
}
func isSoftAged(publishedAtNano int64, softDays int) bool {
if publishedAtNano < minCrediblePublishedNano || softDays <= 0 {
return false
}
cutoff := time.Now().UTC().AddDate(0, 0, -softDays).UnixNano()
return publishedAtNano < cutoff
}

View File

@ -0,0 +1,58 @@
package usecase
import (
"testing"
"time"
)
func TestIsStalePublishedHardVsSoft(t *testing.T) {
now := time.Now().UTC()
fresh := now.Add(-10 * 24 * time.Hour).UnixNano()
mid := now.Add(-60 * 24 * time.Hour).UnixNano()
veryOld := now.Add(-200 * 24 * time.Hour).UnixNano()
if isStalePublished(fresh, defaultScoutHardMaxAgeDays) {
t.Fatal("10d should not be hard-stale")
}
if isStalePublished(mid, defaultScoutHardMaxAgeDays) {
t.Fatal("60d should not be hard-stale at 180d window")
}
if !isStalePublished(veryOld, defaultScoutHardMaxAgeDays) {
t.Fatal("200d should be hard-stale")
}
if !isSoftAged(mid, defaultScoutSoftAgeDays) {
t.Fatal("60d should be soft-aged at 45d")
}
if isSoftAged(fresh, defaultScoutSoftAgeDays) {
t.Fatal("10d should not be soft-aged")
}
if isStalePublished(0, 45) {
t.Fatal("unknown time is not stale")
}
}
func TestNormalizeSearchTrack(t *testing.T) {
if normalizeSearchTrack("both") != "both" {
t.Fatal("both")
}
if normalizeSearchTrack("recent") != "recent" {
t.Fatal("recent")
}
if normalizeSearchTrack("TOP") != "top" {
t.Fatal("top")
}
}
func TestSortHitsByTrackAndPostedAt(t *testing.T) {
hits := []ThreadSearchResult{
{URL: "a", Track: "top", PublishedAt: 300},
{URL: "b", Track: "both", PublishedAt: 100},
{URL: "c", Track: "recent", PublishedAt: 200},
}
sortHitsByTrackAndPostedAt(hits)
if hits[0].Track != "both" {
t.Fatalf("first=%s want both", hits[0].Track)
}
if hits[1].Track != "recent" {
t.Fatalf("second=%s want recent", hits[1].Track)
}
}

View File

@ -24,10 +24,14 @@ type ThreadSearchResult struct {
URL string URL string
Title string Title string
Snippet string Snippet string
// PublishedAt unix nanoseconds when known (Exa publishedDate). // PublishedAt unix nanoseconds when known (Exa publishedDate / crawler parse).
PublishedAt int64 PublishedAt int64
// MatchedQuery is set by fan-out search to the query that found this hit. // MatchedQuery is set by fan-out search to the query that found this hit.
MatchedQuery string MatchedQuery string
// Track: top | recent | both — crawler dual-track; empty for Exa/API.
Track string
// SerpRank is 1-based rank within the source SERP track (crawler); 0 = unknown.
SerpRank int
} }
// ExaThreadsProvider searches only Threads-owned domains through Exa. // ExaThreadsProvider searches only Threads-owned domains through Exa.

View File

@ -1,4 +1,5 @@
package usecase
ㄇㄠpackage usecase
import ( import (
"crypto/sha256" "crypto/sha256"
@ -25,7 +26,8 @@ func planScanTerms(brief *domain.RunBrief) []string {
return nil return nil
} }
if brief.Mode == domain.ModeActivity { if brief.Mode == domain.ModeActivity {
return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6) // 規則式變體(不依賴 AIAI 擴充在 PrepareBrief 另外合併。
return planActivityTerms(brief.Intent)
} }
if brief.Mode == domain.ModeProvider { if brief.Mode == domain.ModeProvider {
return planProviderTerms(brief.Pains, brief.Tags) return planProviderTerms(brief.Pains, brief.Tags)

View File

@ -0,0 +1,63 @@
package usecase
import (
"context"
"testing"
"apps/backend/internal/module/scout/domain"
"apps/backend/internal/module/scout/repository"
)
func TestTextMatchesSearchTerm(t *testing.T) {
if !textMatchesSearchTerm("最近在找外包做 app", "外包 求推薦") {
t.Fatal("should match 外包")
}
if textMatchesSearchTerm("今天天氣真好去爬山", "外包 求推薦") {
t.Fatal("unrelated text must not match")
}
if !textMatchesSearchTerm("求推薦好用的保母", "保母") {
t.Fatal("should match 保母")
}
// 只有「求推薦」命中、沒主題核 → 最長核是求推薦(3字) 會要求含求推薦
if !textMatchesSearchTerm("有人求推薦嗎", "求推薦") {
t.Fatal("exact anchor term")
}
}
func TestPersistSearchHitsRejectsIrrelevantAPIResult(t *testing.T) {
svc := New(repository.NewMemory())
posts, err := svc.persistSearchHits(context.Background(), 1, &domain.RunBrief{
Mode: domain.ModeActivity, ScanTerms: []string{"後端工程師"},
}, domain.PathAPI, []ThreadSearchResult{
{URL: "https://www.threads.net/@dev/post/1", Snippet: "正在找後端工程師一起做產品"},
{URL: "https://www.threads.net/@film/post/2", Snippet: "劇組徵拍電影的夥伴"},
})
if err != nil {
t.Fatalf("persistSearchHits: %v", err)
}
if len(posts) != 1 {
t.Fatalf("posts=%d want 1", len(posts))
}
if posts[0].Permalink != "https://www.threads.net/@dev/post/1" {
t.Fatalf("unexpected post %q", posts[0].Permalink)
}
}
func TestPersistActivityHitsPrioritizesDiscussionMomentum(t *testing.T) {
svc := New(repository.NewMemory())
posts, err := svc.persistSearchHits(context.Background(), 1, &domain.RunBrief{
Mode: domain.ModeActivity, ScanTerms: []string{"外包"},
}, domain.PathAPI, []ThreadSearchResult{
{URL: "https://www.threads.net/@chat/post/1", Snippet: "分享外包合作的心得", PublishedAt: 200},
{URL: "https://www.threads.net/@ask/post/2", Snippet: "請問外包工程師怎麼找?", PublishedAt: 100},
})
if err != nil {
t.Fatalf("persistSearchHits: %v", err)
}
if len(posts) != 2 {
t.Fatalf("posts=%d want 2", len(posts))
}
if posts[0].Permalink != "https://www.threads.net/@ask/post/2" {
t.Fatalf("first post=%q want asking post", posts[0].Permalink)
}
}

View File

@ -0,0 +1,130 @@
package usecase
import (
"context"
"testing"
"apps/backend/internal/module/scout/domain"
)
type recordingProvider struct {
calls [][]string
}
func (p *recordingProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
cp := append([]string{}, terms...)
p.calls = append(p.calls, cp)
out := make([]ThreadSearchResult, 0, len(terms))
for _, term := range terms {
out = append(out, ThreadSearchResult{
URL: "https://www.threads.net/@t/post/" + term,
Title: term,
Snippet: "hit for " + term,
})
}
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func TestSearchHitsOnlyFansOutPerTermAndDedupes(t *testing.T) {
prov := &recordingProvider{}
svc := &Service{Provider: prov}
// two terms → two provider calls (one term each), not one joined query
hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
if err != nil {
t.Fatalf("SearchHitsOnly: %v", err)
}
if path != domain.PathAPI {
t.Fatalf("path=%q want %q", path, domain.PathAPI)
}
if len(prov.calls) != 2 {
t.Fatalf("provider calls=%d want 2 (fan-out), calls=%v", len(prov.calls), prov.calls)
}
for _, c := range prov.calls {
if len(c) != 1 {
t.Fatalf("each fan-out call must pass a single term, got %v", c)
}
}
if len(hits) != 2 {
t.Fatalf("hits=%d want 2", len(hits))
}
// same URL from both terms → one result after dedupe
prov2 := &recordingProvider{}
// override SearchThreads to return same URL
svc2 := &Service{Provider: &sameURLProvider{inner: prov2}}
hits2, _, err := svc2.SearchHitsOnly(context.Background(), 1, []string{"甲", "乙"}, 20)
if err != nil {
t.Fatalf("SearchHitsOnly dedupe: %v", err)
}
if len(hits2) != 1 {
t.Fatalf("deduped hits=%d want 1", len(hits2))
}
}
type sameURLProvider struct {
inner *recordingProvider
}
func (p *sameURLProvider) SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
p.inner.calls = append(p.inner.calls, append([]string{}, terms...))
return []ThreadSearchResult{{
URL: "https://www.threads.net/@t/post/same", Title: "t", Snippet: "shared",
}}, nil
}
// countingProvider returns unique URLs per call (for top-up tests).
type countingProvider struct {
calls int
}
func (p *countingProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
p.calls++
term := "x"
if len(terms) > 0 {
term = terms[0]
}
n := 3
if limit > 0 && limit < n {
n = limit
}
out := make([]ThreadSearchResult, 0, n)
for i := 0; i < n; i++ {
out = append(out, ThreadSearchResult{
URL: "https://www.threads.net/@t/post/" + term + "-c" + string(rune('0'+p.calls)) + "-i" + string(rune('0'+i)),
Title: term,
Snippet: "hit",
})
}
return out, nil
}
func TestFillSearchHitsToTargetBoostsSamePath(t *testing.T) {
prov := &countingProvider{}
svc := &Service{Provider: prov}
// seed with one hit; target 5 → fill should call provider and merge unique URLs
hits := []ThreadSearchResult{{
URL: "https://www.threads.net/@t/post/seed", Title: "市集", Snippet: "seed",
}}
got := fillSearchHitsToTarget(context.Background(), svc, []string{"市集"}, hits, 5, domain.PathAPI, "")
if len(got) < 2 {
t.Fatalf("after top-up hits=%d want >=2 (got more unique URLs)", len(got))
}
if prov.calls < 1 {
t.Fatalf("provider calls=%d want >=1 (boost pass)", prov.calls)
}
}
func TestMergeHitsDedupe(t *testing.T) {
a := []ThreadSearchResult{{URL: "https://www.threads.net/@a/post/1", Title: "a"}}
b := []ThreadSearchResult{
{URL: "https://www.threads.net/@a/post/1", Title: "dup"},
{URL: "https://www.threads.net/@b/post/2", Title: "b"},
}
got := mergeHitsDedupe(a, b)
if len(got) != 2 {
t.Fatalf("merge len=%d want 2", len(got))
}
}

View File

@ -7,13 +7,17 @@ import (
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode/utf8"
"apps/backend/internal/module/ai" "apps/backend/internal/module/ai"
"apps/backend/internal/module/scout/domain" "apps/backend/internal/module/scout/domain"
studioPublish "apps/backend/internal/module/studio/publish" studioPublish "apps/backend/internal/module/studio/publish"
threadsDomain "apps/backend/internal/module/threads/domain" threadsDomain "apps/backend/internal/module/threads/domain"
usageDomain "apps/backend/internal/module/usage/domain"
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
) )
// SettingsReader for dev_mode // SettingsReader for dev_mode
@ -33,7 +37,10 @@ type Service struct {
Settings SettingsReader Settings SettingsReader
// Transport is retained only for test construction compatibility. Scout never publishes directly. // Transport is retained only for test construction compatibility. Scout never publishes directly.
Transport studioPublish.Transport Transport studioPublish.Transport
AI ai.Client // Retained for service wiring; Scout drafts never call AI. AI ai.Client // drafts 不用;話題關鍵字 AI 擴充可走此 fallback
AIRegistry *ai.Registry
ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
Usage *usageUC.Service // 可空:單元測試不扣點
ReplyQueue ReplyQueue ReplyQueue ReplyQueue
Provider ThreadSearchProvider Provider ThreadSearchProvider
Crawler ChromeCrawlerProvider Crawler ChromeCrawlerProvider
@ -221,15 +228,17 @@ func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.I
} }
func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) { func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) {
_ = deep
intent = strings.TrimSpace(intent) intent = strings.TrimSpace(intent)
if intent == "" && purpose != "provider" && purpose != "demand" { if intent == "" && purpose != "provider" && purpose != "demand" {
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation) return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
} }
mode := domain.ModeTheme
// 話題靈感:真正「產」關鍵字(規則變體 + 可選 AI不再只拆使用者原句。
if purpose == "activity" { if purpose == "activity" {
mode = domain.ModeActivity return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
} }
mode := domain.ModeTheme
brief := &domain.RunBrief{ brief := &domain.RunBrief{
Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID, Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID,
Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{}, Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{},
@ -241,9 +250,6 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
} }
if err == nil { if err == nil {
mode = domain.ModeProduct mode = domain.ModeProduct
if purpose == "activity" {
mode = domain.ModeActivity
}
brief.Mode = mode brief.Mode = mode
brief.ProductLabel = p.Label brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext brief.ProductContext = p.ProductContext
@ -314,6 +320,108 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
return brief, nil return brief, nil
} }
func (s *Service) prepareActivityBrief(ctx context.Context, ownerUID int64, intent string, deep bool) (*domain.RunBrief, error) {
ruleTerms := planActivityTerms(intent)
// 預設就試 AI 產詞deep 預留給前端「再想一輪」等同義,不額外收費路徑分叉。
_ = deep
aiTerms, err := s.suggestActivityTermsAI(ctx, ownerUID, intent, 8)
if err != nil {
// AI 不可用只降級規則,不擋話題工坊。
logx.Infof("scout activity terms AI unavailable uid=%d: %v", ownerUID, err)
}
terms := mergeActivityTerms(aiTerms, ruleTerms, 8)
if len(terms) == 0 {
// 最後防線:至少給可搜的核或原句截斷
if c := compactCore(intent); c != "" {
terms = []string{c}
} else {
terms = []string{truncateRunes(intent, 4)}
}
}
return &domain.RunBrief{
Intent: intent,
Mode: domain.ModeActivity,
Pains: []string{intent},
Tags: extractTopicCores(intent),
Periphery: []string{},
ScanTerms: terms,
ThemeLabel: truncate(intent, 36),
ThemeKey: domain.ModeActivity + "||" + truncate(intent, 48),
ResponseStance: "先共鳴再給建議",
}, nil
}
/*
suggestActivityTermsAI 請模型依意圖產生 Threads 短詞失敗回 error呼叫端降級規則
計費 Usage 時走 ai_copysource=scout.topic.suggest Usage測試不扣點
*/
func (s *Service) suggestActivityTermsAI(ctx context.Context, ownerUID int64, intent string, limit int) (_ []string, err error) {
if s == nil || (s.ResolveAI == nil && s.AI == nil) {
return nil, fmt.Errorf("ai not configured")
}
if limit <= 0 {
limit = 8
}
// 預留點數Usage 可空)
var charged bool
var mode string
if s.Usage != nil {
m, berr := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
if berr != nil {
return nil, berr
}
mode = m
charged = true
defer func() {
if !charged {
return
}
if err != nil {
_ = s.Usage.ReleaseCall(ctx, ownerUID, usageDomain.MeterAICopy, mode)
return
}
if _, rerr := s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, "話題關鍵字建議", "scout.topic.suggest"); rerr != nil {
logx.Errorf("scout topic suggest record uid=%d: %v", ownerUID, rerr)
}
}()
}
raw, cerr := s.completeActivityAI(ctx, ownerUID, activityTermsPrompt(intent, limit))
if cerr != nil {
err = cerr
return nil, err
}
parsed := filterThreadsSearchableTerms(parseActivityAITerms(raw))
if len(parsed) == 0 {
err = fmt.Errorf("ai returned no searchable terms")
return nil, err
}
return capTerms(parsed, limit), nil
}
func (s *Service) completeActivityAI(ctx context.Context, ownerUID int64, prompt string) (string, error) {
if s.ResolveAI != nil && s.AIRegistry != nil {
provider, model, apiKey, rerr := s.ResolveAI(ctx, ownerUID)
if rerr != nil {
return "", rerr
}
if strings.TrimSpace(apiKey) == "" || strings.HasPrefix(strings.ToLower(apiKey), "fake") {
return "", fmt.Errorf("ai key empty")
}
c, cerr := s.AIRegistry.Client(provider)
if cerr != nil {
return "", cerr
}
return c.Complete(ctx, apiKey, model, prompt)
}
// 單元測試路徑:未接 ResolveAI 時才用注入的 AI client
if s.AI != nil {
return s.AI.Complete(ctx, "test-key", "grok-3", prompt)
}
return "", fmt.Errorf("ai not configured")
}
// providerMatchingFields keeps existing products usable: their established match // providerMatchingFields keeps existing products usable: their established match
// tags become matching terms until the more specific provider terms are added. // tags become matching terms until the more specific provider terms are added.
func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) { func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) {
@ -363,6 +471,9 @@ func demandMatchingFields(p *domain.Product) (pains, excludes []string) {
// SearchHitsOnly runs the dual-path Threads search without persisting Scout posts. // SearchHitsOnly runs the dual-path Threads search without persisting Scout posts.
// Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01). // Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01).
//
// Terms are searched one-by-one (fan-out) then deduped by canonical permalink — joining all
// terms into a single query dilutes Threads/Exa recall (daily radar sweep bug).
func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadSearchResult, path string, err error) { func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadSearchResult, path string, err error) {
terms = nonEmptyTerms(terms) terms = nonEmptyTerms(terms)
if len(terms) == 0 { if len(terms) == 0 {
@ -374,6 +485,16 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
if limit > 40 { if limit > 40 {
limit = 40 limit = 40
} }
// per-query budget mirrors RunScanFromBrief / fanOutSearch.
perQuery := 8
if len(terms) == 1 {
perQuery = 20
} else if len(terms) >= 6 {
perQuery = 5
}
if perQuery > limit {
perQuery = limit
}
path = domain.PathAPI path = domain.PathAPI
devMode := false devMode := false
if s.Settings != nil { if s.Settings != nil {
@ -390,14 +511,31 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
if s.Crawler == nil { if s.Crawler == nil {
return nil, path, fmt.Errorf("Chrome crawler is not configured") return nil, path, fmt.Errorf("Chrome crawler is not configured")
} }
hits, err = s.Crawler.SearchChrome(ctx, storageState, terms, limit) hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return hits, path, err return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
} }
if s.Provider == nil { if s.Provider == nil {
return nil, path, fmt.Errorf("scout search provider is not configured") return nil, path, fmt.Errorf("scout search provider is not configured")
} }
hits, err = s.Provider.SearchThreads(ctx, terms, limit) hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
return hits, path, err return s.Provider.SearchThreads(ctx, []string{q}, n)
})
if err != nil {
return nil, path, err
}
return capHits(hits, limit), path, nil
}
func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult {
if limit > 0 && len(hits) > limit {
return hits[:limit]
}
return hits
} }
func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) { func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
@ -447,11 +585,30 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
} else if len(terms) >= 6 { } else if len(terms) >= 6 {
perQuery = 5 perQuery = 5
} }
target := brief.TargetCount
if target < 0 {
target = 0
}
if target > 40 {
target = 40
}
// 有目標時略抬高 per-query減少第一輪就差很多則。
if target > 0 {
needPerTerm := (target + len(terms) - 1) / len(terms)
if needPerTerm > perQuery {
perQuery = needPerTerm
}
if perQuery > 20 {
perQuery = 20
}
}
var hits []ThreadSearchResult var hits []ThreadSearchResult
var err error var err error
var storageState string
if devMode { if devMode {
storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID) storageState, err = s.GetCrawlerSessionToken(ctx, ownerUID)
if serr != nil { if err != nil {
return nil, domain.ErrNoCrawlerSession return nil, domain.ErrNoCrawlerSession
} }
path = domain.PathCrawler path = domain.PathCrawler
@ -472,9 +629,101 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 今日目標補抓:主路徑不足 → 同路徑加碼 → 次路徑crawler 不足時用 search/Exa只補缺口。
if target > 0 && len(hits) < target {
hits = fillSearchHitsToTarget(ctx, s, terms, hits, target, path, storageState)
}
if target > 0 {
hits = capHits(hits, target)
}
return s.persistSearchHits(ctx, ownerUID, brief, path, hits) return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
} }
// fillSearchHitsToTarget tops up hits when the first fan-out misses the daily goal.
// 1) Same path with higher per-query 2) If primary is crawler, secondary is search Provider.
func fillSearchHitsToTarget(
ctx context.Context,
s *Service,
terms []string,
hits []ThreadSearchResult,
target int,
primaryPath string,
crawlerState string,
) []ThreadSearchResult {
if target <= 0 || len(hits) >= target {
return hits
}
// Stage A: same path, boost per-query (cap 20).
boost := 20
need := target - len(hits)
if need < boost {
// still request up to boost so sparse terms can contribute
_ = need
}
var more []ThreadSearchResult
var err error
if primaryPath == domain.PathCrawler && s.Crawler != nil && crawlerState != "" {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Crawler.SearchChrome(ctx, crawlerState, []string{q}, limit)
})
} else if s.Provider != nil {
more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
}
if err == nil && len(more) > 0 {
hits = mergeHitsDedupe(hits, more)
}
if len(hits) >= target {
return hits
}
// Stage B: crawler primary → top up with search provider (Exa / Threads-domain search).
if primaryPath == domain.PathCrawler && s.Provider != nil {
topup, terr := fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
return s.Provider.SearchThreads(ctx, []string{q}, limit)
})
if terr == nil && len(topup) > 0 {
hits = mergeHitsDedupe(hits, topup)
}
}
return hits
}
func mergeHitsDedupe(base, extra []ThreadSearchResult) []ThreadSearchResult {
seen := make(map[string]struct{}, len(base)+len(extra))
out := make([]ThreadSearchResult, 0, len(base)+len(extra))
for _, h := range base {
key := canonicalPermalink(h.URL)
if key == "" {
key = strings.TrimSpace(h.URL)
}
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, h)
}
for _, h := range extra {
key := canonicalPermalink(h.URL)
if key == "" {
key = strings.TrimSpace(h.URL)
}
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, h)
}
return out
}
func filterProviderScanTerms(terms []string, p *domain.Product) []string { func filterProviderScanTerms(terms []string, p *domain.Product) []string {
var out []string var out []string
for _, term := range terms { for _, term := range terms {
@ -528,8 +777,11 @@ func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func
func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) { func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
now := domain.NowNano() now := domain.NowNano()
// 先依原文發文時間新→舊;無時間的排後面,再以陣列序 // 保真crawler 已依 Recent 主序回傳,不再 both-first 重排蓋掉平台相關性。
// 無 track 的 Exa 結果仍依發文時間新→舊。
if !hitsHaveTrack(hits) {
sortHitsByPostedAt(hits) sortHitsByPostedAt(hits)
}
out := make([]*domain.Post, 0, len(hits)) out := make([]*domain.Post, 0, len(hits))
for i, hit := range hits { for i, hit := range hits {
text := strings.TrimSpace(hit.Snippet) text := strings.TrimSpace(hit.Snippet)
@ -540,9 +792,22 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
if permalink == "" { if permalink == "" {
continue continue
} }
postedAt := hit.PublishedAt
// 硬擋僅極舊180 天);測試 stub 時間戳不套用
if postedAt > 0 && isStalePublished(postedAt, defaultScoutHardMaxAgeDays) {
continue
}
term := strings.TrimSpace(hit.MatchedQuery) term := strings.TrimSpace(hit.MatchedQuery)
if term == "" { // 相關性硬閘:所有來源的正文都必須含查詢主題核,避免 provider
term = matchingTerm(text+" "+hit.Title, brief.ScanTerms) // 回傳的語意擴展/空 SERP 雜訊混進話題佇列。
matchedTerm := matchingSearchTerm(text+" "+hit.Title, brief.ScanTerms)
if matchedTerm == "" {
continue
}
// fan-out records the query that returned a hit. Prefer the term actually
// found in its text so a result from a different query is not mislabeled.
if term == "" || !textMatchesSearchTerm(text+" "+hit.Title, term) {
term = matchedTerm
} }
classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms) classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms)
if brief.Mode == domain.ModeProvider { if brief.Mode == domain.ModeProvider {
@ -562,18 +827,39 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) { if brief.Mode == domain.ModeProvider && (classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) {
continue continue
} }
postedAt := hit.PublishedAt score := classified.score
// created_at有發文時間則對齊發文序否則用掃入時間並微調保序 reason := classified.reason
// track 只標記輕加分不重排both 表示熱門+最新都有,可信度稍高)
switch hit.Track {
case "both":
score = minInt(100, score+6)
reason = reason + "; track: both"
case "recent":
score = minInt(100, score+3)
reason = reason + "; track: recent"
case "top":
reason = reason + "; track: top"
}
if hit.SerpRank > 0 {
reason = reason + fmt.Sprintf("; serp_rank: %d", hit.SerpRank)
}
// 軟時效:>45 天降權並標註,不刪(避免「準的稍舊文」消失)
if postedAt > 0 && isSoftAged(postedAt, defaultScoutSoftAgeDays) {
score = maxInt(1, score-12)
reason = reason + "; soft_aged"
}
// created_at保 crawler 回傳序i 越小越前);有發文時間仍寫 PostedAt 供 UI
createdAt := now - int64(i)*1000 createdAt := now - int64(i)*1000
if postedAt > 0 { if postedAt > 0 && !hitsHaveTrack(hits) {
// 非 crawler 路徑仍用發文時間當 created 序
createdAt = postedAt createdAt = postedAt
} }
p := &domain.Post{ p := &domain.Post{
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink, ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text, OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew, SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew,
Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel, Score: score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent, MatchReason: reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path, ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
PostedAt: postedAt, CreatedAt: createdAt, PostedAt: postedAt, CreatedAt: createdAt,
} }
@ -582,11 +868,134 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
} }
out = append(out, p) out = append(out, p)
} }
// 回傳列表:待處理優先不在這裡做,純按發文時間新→舊 // 話題優先顯示近期討論動能track問答訊號已計入分數再以發文時間決勝。
// 其他模式維持既有的時間排序,避免改變商機/解法媒合的行為。
if brief.Mode == domain.ModeActivity {
sortActivityPostsByMomentum(out)
} else if !hitsHaveTrack(hits) {
sortPostsByPostedAt(out) sortPostsByPostedAt(out)
}
return out, nil return out, nil
} }
func hitsHaveTrack(hits []ThreadSearchResult) bool {
for _, h := range hits {
if h.Track != "" {
return true
}
}
return false
}
// searchAnchors 是產詞常用的口語後綴,不能單獨當「主題相關」依據。
var searchAnchors = map[string]bool{
"求推薦": true, "推薦": true, "分享": true, "心得": true,
"活動": true, "怎麼辦": true, "詢問": true, "討論": true,
"有人知道": true, "請問": true,
}
// textMatchesSearchTerm正文須含查詢的主題核非口語錨點
// 「外包 求推薦」→ 必須含「外包」;純「求推薦」才允許只命中錨點。
func textMatchesSearchTerm(text, term string) bool {
body := normalizeSignal(text)
if body == "" || strings.TrimSpace(term) == "" {
return false
}
tokens := strings.Fields(strings.TrimSpace(term))
if len(tokens) == 0 {
tokens = []string{strings.TrimSpace(term)}
}
var content, anchors []string
for _, tok := range tokens {
t := strings.ToLower(strings.TrimSpace(tok))
if t == "" || utf8.RuneCountInString(t) < 2 {
continue
}
if searchAnchors[t] {
anchors = append(anchors, t)
} else {
content = append(content, strings.ReplaceAll(t, " ", ""))
}
}
// 有主題核:任一主題核命中即可(不要求錨點)
if len(content) > 0 {
for _, c := range content {
if strings.Contains(body, c) {
return true
}
}
return false
}
// 只有錨點:放寬(使用者刻意只搜「求推薦」)
for _, a := range anchors {
if strings.Contains(body, strings.ReplaceAll(a, " ", "")) {
return true
}
}
// 無可用 token不誤殺
return true
}
func matchingSearchTerm(text string, terms []string) string {
for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && textMatchesSearchTerm(text, term) {
return term
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func sortHitsByTrackAndPostedAt(hits []ThreadSearchResult) {
trackRank := func(t string) int {
switch t {
case "both":
return 0
case "recent":
return 1
case "top":
return 2
default:
return 3
}
}
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track)
if rj < ri {
hits[i], hits[j] = hits[j], hits[i]
continue
}
if rj > ri {
continue
}
ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
if ai == 0 && aj == 0 {
continue
}
if ai == 0 || (aj > 0 && aj > ai) {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
// sortPostsByTrackAndPostedAt 保留相容:目前先 track 再時間在 sortHits 已做;此函式 no-op 佔位避免誤用。
func sortPostsByTrackAndPostedAt(_ []*domain.Post, _ []ThreadSearchResult) {}
func sortHitsByPostedAt(hits []ThreadSearchResult) { func sortHitsByPostedAt(hits []ThreadSearchResult) {
// newest first; unknown published time last // newest first; unknown published time last
for i := 0; i < len(hits); i++ { for i := 0; i < len(hits); i++ {
@ -620,6 +1029,24 @@ func sortPostsByPostedAt(posts []*domain.Post) {
} }
} }
func sortActivityPostsByMomentum(posts []*domain.Post) {
for i := 0; i < len(posts); i++ {
for j := i + 1; j < len(posts); j++ {
if posts[j].Score > posts[i].Score ||
(posts[j].Score == posts[i].Score && postTime(posts[j]) > postTime(posts[i])) {
posts[i], posts[j] = posts[j], posts[i]
}
}
}
}
func postTime(post *domain.Post) int64 {
if post.PostedAt > 0 {
return post.PostedAt
}
return post.CreatedAt
}
func matchingTerm(text string, terms []string) string { func matchingTerm(text string, terms []string) string {
for _, term := range terms { for _, term := range terms {
if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) { if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
@ -767,6 +1194,31 @@ func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text
return p, nil return p, nil
} }
/*
ResolveMediaID turns a Threads permalink into a numeric Graph media ID via the
configured Chrome crawler. Already-numeric input is returned unchanged.
Exported so other modules (radar 商機回覆一鍵送出) can reuse the same resolver
instead of re-implementing crawler session handling.
*/
func (s *Service) ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error) {
if isNumericMediaID(permalink) {
return permalink, nil
}
if s.Crawler == nil {
return "", fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
}
state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
if err != nil {
return "", domain.ErrNoCrawlerSession
}
mediaID, err := s.Crawler.ResolveMediaID(ctx, state, permalink)
if err != nil {
return "", fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
}
return mediaID, nil
}
func isNumericMediaID(value string) bool { func isNumericMediaID(value string) bool {
if value == "" { if value == "" {
return false return false

View File

@ -193,6 +193,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)) scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
scoutSvc.Settings = &devModeFromMembers{Members: repo} scoutSvc.Settings = &devModeFromMembers{Members: repo}
scoutSvc.AI = aiClient scoutSvc.AI = aiClient
scoutSvc.AIRegistry = aiRegistry
scoutSvc.Usage = usageSvc
scoutSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) {
return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid)
}
scoutSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc} scoutSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey) scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
scoutSvc.SessionSecret = c.Scout.SessionSecret scoutSvc.SessionSecret = c.Scout.SessionSecret
@ -237,6 +242,9 @@ func NewServiceContext(c config.Config) *ServiceContext {
radarSvc.HitFetch = &scoutHitAdapter{Scout: scoutSvc} radarSvc.HitFetch = &scoutHitAdapter{Scout: scoutSvc}
radarSvc.Notifier = radarUC.NotifierFromAppNotif(&radarSystemNotif{App: appN}) radarSvc.Notifier = radarUC.NotifierFromAppNotif(&radarSystemNotif{App: appN})
radarSvc.Health = &radarHealthBridge{Growth: growthSvc} radarSvc.Health = &radarHealthBridge{Growth: growthSvc}
// 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
radarSvc.MediaResolver = scoutSvc
// 每日巡與手動觸發共用 job.ScheduleRadarSweep同 template、同日去重 // 每日巡與手動觸發共用 job.ScheduleRadarSweep同 template、同日去重
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) { radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt) j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)

View File

@ -94,6 +94,7 @@ func BriefFromDomain(b *scoutDomain.RunBrief) ScoutBriefPublic {
ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery, ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery,
ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance, ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance,
ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext, ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext,
TargetCount: b.TargetCount,
} }
} }
@ -106,6 +107,7 @@ func BriefToDomain(b *ScoutBriefPublic) *scoutDomain.RunBrief {
ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery, ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery,
ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance, ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance,
ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext, ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext,
TargetCount: b.TargetCount,
} }
} }

View File

@ -541,6 +541,19 @@ type DraftReviewPublic struct {
type Empty struct { type Empty struct {
} }
type ExploreOpportunitiesData struct {
SweepId string `json:"sweep_id"`
HitCount int `json:"hit_count"`
JudgedCount int `json:"judged_count"`
CreatedCount int `json:"created_count"`
TruncatedCount int `json:"truncated_count"`
CreditsUsed int `json:"credits_used"`
}
type ExploreOpportunitiesReq struct {
Terms []string `json:"terms"`
}
type ExportReportData struct { type ExportReportData struct {
Url string `json:"url"` Url string `json:"url"`
ExpiresAt int64 `json:"expires_at,optional"` ExpiresAt int64 `json:"expires_at,optional"`
@ -641,6 +654,21 @@ type IdentityPublic struct {
CreatedAt int64 `json:"created_at,optional"` CreatedAt int64 `json:"created_at,optional"`
} }
type ImportOpportunitiesData struct {
Results []ImportedOpportunityResult `json:"results"`
}
type ImportOpportunitiesReq struct {
Items []ImportOpportunityItem `json:"items"`
}
type ImportOpportunityItem struct {
Url string `json:"url"`
Text string `json:"text"`
Author string `json:"author,optional"`
PostedAt int64 `json:"posted_at,optional"`
}
type ImportPlaybookData struct { type ImportPlaybookData struct {
Id string `json:"id"` Id string `json:"id"`
Copied bool `json:"copied"` Copied bool `json:"copied"`
@ -675,6 +703,15 @@ type ImportThreadsAccountSessionReq struct {
StorageState string `json:"storageState"` StorageState string `json:"storageState"`
} }
type ImportedOpportunityResult struct {
Url string `json:"url"`
OpportunityId string `json:"opportunity_id,optional"`
Status string `json:"status"` // qualified | rejected | skipped | failed
IntentBand string `json:"intent_band,optional"`
IntentScore int `json:"intent_score,optional"`
Error string `json:"error,optional"`
}
type InsightsMonthBucket struct { type InsightsMonthBucket struct {
MonthKey string `json:"month_key"` MonthKey string `json:"month_key"`
Posts int `json:"posts"` Posts int `json:"posts"`
@ -1018,6 +1055,7 @@ type MarkReplyUsedReq struct {
Id string `path:"id"` Id string `path:"id"`
ReplyId string `path:"replyId"` ReplyId string `path:"replyId"`
Channel string `json:"channel"` // outbox | manual_copy Channel string `json:"channel"` // outbox | manual_copy
AccountId string `json:"account_id,optional"`
} }
type MediaUploadData struct { type MediaUploadData struct {
@ -1693,6 +1731,7 @@ type ReplyVariantPublic struct {
Text string `json:"text"` Text string `json:"text"`
UsedAt int64 `json:"used_at,optional"` UsedAt int64 `json:"used_at,optional"`
SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy
OutboxId string `json:"outbox_id,optional"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
@ -1760,6 +1799,7 @@ type ScoutBriefPublic struct {
ThemeKey string `json:"theme_key,optional"` ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"` ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"` ProductContext string `json:"product_context,optional"`
TargetCount int `json:"target_count,optional"`
} }
type ScoutBriefReq struct { type ScoutBriefReq struct {

View File

@ -0,0 +1,90 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../../data/mock/keys";
import type { ExploreResult, WatchTermSuggestion } from "../../domain/types";
import { I18nProvider } from "../../i18n/I18nContext";
import { ExplorePanel } from "./ExplorePanel";
const backend = vi.hoisted(() => ({
suggestWatchTerms: vi.fn<(limit?: number) => Promise<WatchTermSuggestion[]>>(),
exploreOpportunities: vi.fn<(terms: string[]) => Promise<ExploreResult>>(),
}));
vi.mock("../../data/DataContext", () => ({
useRepos: () => ({
radar: {
suggestWatchTerms: backend.suggestWatchTerms,
exploreOpportunities: backend.exploreOpportunities,
},
}),
}));
function renderPanel(onExplored = vi.fn()) {
return render(
<I18nProvider>
<ExplorePanel onExplored={onExplored} />
</I18nProvider>,
);
}
describe("ExplorePanel", () => {
beforeEach(() => {
backend.suggestWatchTerms.mockReset();
backend.exploreOpportunities.mockReset();
localStorage.setItem(
KEYS.uiPrefs,
JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }),
);
backend.suggestWatchTerms.mockResolvedValue([
{ term: "保母 求推薦", reason: "常見求助", usage: "include" },
{ term: "到府保母", reason: "到府需求", usage: "include" },
]);
});
it("loads suggestions as chips and runs explore", async () => {
backend.exploreOpportunities.mockResolvedValue({
sweep_id: "s1",
hit_count: 5,
judged_count: 4,
created_count: 2,
truncated_count: 0,
credits_used: 3,
});
const onExplored = vi.fn();
renderPanel(onExplored);
await waitFor(() => expect(backend.suggestWatchTerms).toHaveBeenCalled());
// pre-filled selected chips (title = remove)
expect(await screen.findAllByTitle("點一下移除")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: "開始探索" }));
await waitFor(() => expect(backend.exploreOpportunities).toHaveBeenCalledTimes(1));
expect(backend.exploreOpportunities).toHaveBeenCalledWith(
expect.arrayContaining(["保母 求推薦", "到府保母"]),
);
expect(await screen.findByText(/找到 5 則/)).toBeTruthy();
expect(onExplored).toHaveBeenCalledTimes(1);
});
it("blocks long non-Threads terms", async () => {
backend.suggestWatchTerms.mockResolvedValue([]);
renderPanel();
await waitFor(() => expect(backend.suggestWatchTerms).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("自己加一組短詞"), {
target: { value: "台北 婚攝 推薦 價格" },
});
fireEvent.click(screen.getByRole("button", { name: "加入" }));
expect(screen.getByRole("alert").textContent).toMatch(/最多 2 個詞/);
expect(backend.exploreOpportunities).not.toHaveBeenCalled();
});
it("can remove a chip before submit", async () => {
renderPanel();
await waitFor(() => expect(screen.getAllByTitle("點一下移除").length).toBe(2));
fireEvent.click(screen.getAllByTitle("點一下移除")[0]!);
await waitFor(() => {
expect(screen.getAllByTitle("點一下移除").length).toBe(1);
});
});
});

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { useRepos } from "../../data/DataContext";
import type { ExploreResult, WatchTermSuggestion } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../../lib/threadsTerm";
import { Badge, Button, Input } from "../ui";
const MAX_TERMS = 6;
const SUGGEST_LIMIT = 12;
/**
* chips 使 POST /radar/explore
* B
*/
export function ExplorePanel({ onExplored }: { onExplored: () => void }) {
const { t } = useI18n();
const repos = useRepos();
const formatError = useFormatApiError();
const [terms, setTerms] = useState<string[]>([]);
const [draft, setDraft] = useState("");
const [draftErr, setDraftErr] = useState("");
const [suggestions, setSuggestions] = useState<WatchTermSuggestion[]>([]);
const [loadingSuggest, setLoadingSuggest] = useState(true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [result, setResult] = useState<ExploreResult | null>(null);
useEffect(() => {
let alive = true;
setLoadingSuggest(true);
repos.radar
.suggestWatchTerms(SUGGEST_LIMIT)
.then((list) => {
if (!alive) return;
const includes = list.filter((s) => s.usage === "include" && isThreadsSearchable(s.term));
setSuggestions(includes);
// pre-fill up to 4 searchable suggestions if user has none yet
setTerms((prev) =>
prev.length > 0 ? prev : includes.slice(0, 4).map((s) => normalizeSearchTerm(s.term)),
);
})
.catch((e) => {
if (alive) setErr(formatError(e));
})
.finally(() => {
if (alive) setLoadingSuggest(false);
});
return () => {
alive = false;
};
// 只在掛載時拉一次建議repos 在 mock 裡每次 render 是新物件,不能當 dependency。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function addTerm(raw: string) {
const term = normalizeSearchTerm(raw);
const check = checkThreadsTerm(term);
if (!check.ok) {
setDraftErr(t(`radar.explore.termError.${check.reason}`));
return;
}
if (terms.some((x) => x.toLowerCase() === term.toLowerCase())) {
setDraftErr(t("radar.explore.termError.duplicate"));
return;
}
if (terms.length >= MAX_TERMS) {
setDraftErr(t("radar.explore.termError.max"));
return;
}
setTerms((prev) => [...prev, term]);
setDraft("");
setDraftErr("");
}
function removeTerm(term: string) {
setTerms((prev) => prev.filter((x) => x !== term));
}
async function submit() {
if (terms.length === 0) {
setErr(t("radar.explore.needTerms"));
return;
}
setBusy(true);
setErr("");
setResult(null);
try {
const res = await repos.radar.exploreOpportunities(terms);
setResult(res);
onExplored();
} catch (e) {
setErr(formatError(e));
} finally {
setBusy(false);
}
}
return (
<div className="hb-radar-section hb-radar-explore">
<h3 className="hb-radar-section__title">{t("radar.explore.title")}</h3>
<p className="hb-radar-section__hint">{t("radar.explore.hint")}</p>
{loadingSuggest ? (
<p className="hb-radar-section__hint">{t("radar.explore.loadingSuggest")}</p>
) : null}
{suggestions.length > 0 ? (
<div className="hb-radar-explore__suggest">
<p className="hb-field__label">{t("radar.explore.suggestions")}</p>
<div className="hb-radar-actions">
{suggestions.map((s) => {
const already = terms.some((x) => x.toLowerCase() === s.term.toLowerCase());
return (
<Button
key={s.term}
type="button"
variant={already ? "ghost" : "secondary"}
disabled={already || terms.length >= MAX_TERMS || busy}
onClick={() => addTerm(s.term)}
title={s.reason}
>
{s.term}
</Button>
);
})}
</div>
</div>
) : null}
<div className="hb-radar-explore__chips" aria-label={t("radar.explore.selected")}>
{terms.length === 0 ? (
<p className="hb-radar-section__hint">{t("radar.explore.emptyTerms")}</p>
) : (
terms.map((term) => (
<button
key={term}
type="button"
className="hb-radar-explore__chip"
onClick={() => removeTerm(term)}
disabled={busy}
title={t("radar.explore.removeChip")}
>
<Badge tone="brand">{term}</Badge>
<span className="hb-radar-explore__chip-x" aria-hidden>
×
</span>
</button>
))
)}
</div>
<div className="hb-radar-explore__add">
<Input
label={t("radar.explore.addLabel")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDraftErr("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTerm(draft);
}
}}
placeholder={t("radar.explore.addPh")}
disabled={busy || terms.length >= MAX_TERMS}
/>
<Button type="button" variant="secondary" disabled={busy} onClick={() => addTerm(draft)}>
{t("radar.explore.add")}
</Button>
</div>
{draftErr ? (
<p className="hb-banner-error" role="alert">
{draftErr}
</p>
) : null}
<div className="hb-radar-actions">
<Button type="button" disabled={busy || terms.length === 0} onClick={() => void submit()}>
{busy ? t("radar.explore.running") : t("radar.explore.run")}
</Button>
</div>
{err ? (
<p className="hb-banner-error" role="alert">
{err}
</p>
) : null}
{result ? (
<div className="hb-radar-explore__result" role="status">
<p>
{t("radar.explore.result", {
hits: result.hit_count,
created: result.created_count,
judged: result.judged_count,
})}
</p>
{result.created_count === 0 ? (
<p className="hb-radar-section__hint">{t("radar.explore.resultZeroHint")}</p>
) : null}
{result.truncated_count > 0 ? (
<p className="hb-radar-section__hint">
{t("radar.explore.resultTruncated", { n: result.truncated_count })}
</p>
) : null}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,83 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { KEYS } from "../../data/mock/keys";
import { I18nProvider } from "../../i18n/I18nContext";
import type { ManualImportItem, ManualImportResult } from "../../domain/types";
import { ManualImportPanel } from "./ManualImportPanel";
const backend = vi.hoisted(() => ({
importOpportunities: vi.fn<(items: ManualImportItem[]) => Promise<{ results: ManualImportResult[] }>>(),
}));
vi.mock("../../data/DataContext", () => ({
useRepos: () => ({
radar: {
importOpportunities: backend.importOpportunities,
},
}),
}));
function renderPanel(onImported = vi.fn()) {
return render(
<I18nProvider>
<ManualImportPanel onImported={onImported} />
</I18nProvider>,
);
}
describe("ManualImportPanel", () => {
beforeEach(() => {
backend.importOpportunities.mockReset();
localStorage.setItem(
KEYS.uiPrefs,
JSON.stringify({ locale: "zh-TW", currency: "TWD", theme: "system" }),
);
});
it("submits the filled row and shows the returned status", async () => {
backend.importOpportunities.mockResolvedValue({
results: [
{ url: "https://www.threads.net/@a/post/1", opportunity_id: "o1", status: "qualified", intent_band: "high", intent_score: 88 },
],
});
const onImported = vi.fn();
renderPanel(onImported);
fireEvent.change(screen.getByLabelText("貼文網址"), {
target: { value: "https://www.threads.net/@a/post/1" },
});
fireEvent.change(screen.getByLabelText("貼文內文"), {
target: { value: "台北找水電師傅,急!" },
});
fireEvent.click(screen.getByRole("button", { name: "送出匯入" }));
await waitFor(() => expect(backend.importOpportunities).toHaveBeenCalledTimes(1));
expect(backend.importOpportunities).toHaveBeenCalledWith([
{ url: "https://www.threads.net/@a/post/1", text: "台北找水電師傅,急!", author: undefined },
]);
expect(await screen.findByText("已收進今日商機")).toBeTruthy();
expect(onImported).toHaveBeenCalledTimes(1);
});
it("blocks submit with a hint when every row is empty", async () => {
renderPanel();
fireEvent.click(screen.getByRole("button", { name: "送出匯入" }));
expect(await screen.findByText("至少填一列網址與內文")).toBeTruthy();
expect(backend.importOpportunities).not.toHaveBeenCalled();
});
it("parses pasted CSV into rows", async () => {
renderPanel();
fireEvent.click(screen.getByRole("button", { name: "改用貼上 CSV" }));
fireEvent.change(screen.getByLabelText(/^貼上 CSV/), {
target: {
value: "url,text,author\nhttps://www.threads.net/@a/post/1,台北找設計師,@a",
},
});
fireEvent.click(screen.getByRole("button", { name: "套用到下方列表" }));
expect(await screen.findByDisplayValue("https://www.threads.net/@a/post/1")).toBeTruthy();
expect(screen.getByDisplayValue("台北找設計師")).toBeTruthy();
expect(screen.getByDisplayValue("@a")).toBeTruthy();
});
});

View File

@ -0,0 +1,204 @@
import { useState } from "react";
import { useRepos } from "../../data/DataContext";
import type { ManualImportItem, ManualImportResult } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { useFormatApiError } from "../../lib/apiErrors";
import { parseCsvRows } from "../../lib/csv";
import { Badge, Button, Input, Textarea } from "../ui";
import type { BadgeTone } from "../ui";
type Row = { url: string; text: string; author: string };
const MAX_ROWS = 20;
const emptyRow = (): Row => ({ url: "", text: "", author: "" });
function statusTone(status: ManualImportResult["status"]): BadgeTone {
switch (status) {
case "qualified":
return "success";
case "rejected":
return "danger";
case "skipped":
return "neutral";
default:
return "danger";
}
}
/** 把貼上的 CSV 文字解析成匯入列;有 url/text 表頭就照表頭找欄,沒有就假設 url,text,author 順序。 */
function parsePastedCsv(raw: string): Row[] {
const rows = parseCsvRows(raw);
if (rows.length === 0) return [];
const header = rows[0].map((h) => h.trim().toLowerCase());
const hasHeader = header.includes("url");
const colUrl = hasHeader ? header.indexOf("url") : 0;
const colText = hasHeader ? header.indexOf("text") : 1;
const colAuthor = hasHeader ? header.indexOf("author") : 2;
const dataRows = hasHeader ? rows.slice(1) : rows;
return dataRows
.map((r) => ({
url: (r[colUrl] || "").trim(),
text: (colText >= 0 ? r[colText] : "")?.trim() || "",
author: (colAuthor >= 0 ? r[colAuthor] : "")?.trim() || "",
}))
.filter((r) => r.url || r.text);
}
/**
* demand-radar spec §4.11 P1 ThreadsFacebook
* CSV
* 使
*/
export function ManualImportPanel({ onImported }: { onImported: () => void }) {
const { t } = useI18n();
const repos = useRepos();
const formatError = useFormatApiError();
const [rows, setRows] = useState<Row[]>([emptyRow()]);
const [csvOpen, setCsvOpen] = useState(false);
const [csvText, setCsvText] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [results, setResults] = useState<ManualImportResult[] | null>(null);
function updateRow(i: number, patch: Partial<Row>) {
setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function addRow() {
setRows((prev) => (prev.length >= MAX_ROWS ? prev : [...prev, emptyRow()]));
}
function removeRow(i: number) {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((_, idx) => idx !== i)));
}
function applyCsv() {
const parsed = parsePastedCsv(csvText);
if (parsed.length === 0) {
setErr(t("radar.import.csvEmpty"));
return;
}
setRows(parsed.slice(0, MAX_ROWS).map((r) => ({ url: r.url, text: r.text, author: r.author })));
setErr("");
setCsvOpen(false);
setCsvText("");
}
async function submit() {
const items: ManualImportItem[] = rows
.map((r) => ({ url: r.url.trim(), text: r.text.trim(), author: r.author.trim() || undefined }))
.filter((it) => it.url || it.text);
if (items.length === 0) {
setErr(t("radar.import.needRow"));
return;
}
setBusy(true);
setErr("");
setResults(null);
try {
const res = await repos.radar.importOpportunities(items);
setResults(res.results);
if (res.results.some((r) => r.status === "qualified" || r.status === "rejected")) {
onImported();
}
} catch (e) {
setErr(formatError(e));
} finally {
setBusy(false);
}
}
return (
<div className="hb-radar-section hb-radar-import">
<h3 className="hb-radar-section__title">{t("radar.import.title")}</h3>
<p className="hb-radar-section__hint">{t("radar.import.hint")}</p>
<div className="hb-radar-actions">
<Button type="button" variant="ghost" onClick={() => setCsvOpen((v) => !v)}>
{csvOpen ? t("radar.import.csvClose") : t("radar.import.csvOpen")}
</Button>
</div>
{csvOpen ? (
<div className="hb-radar-import__csv">
<Textarea
name="radar-import-csv"
label={t("radar.import.csvLabel")}
hint={t("radar.import.csvHint")}
rows={5}
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
/>
<div className="hb-radar-actions">
<Button type="button" variant="secondary" onClick={applyCsv}>
{t("radar.import.csvApply")}
</Button>
</div>
</div>
) : null}
{rows.map((row, i) => (
<div className="hb-radar-import__row" key={i}>
<Input
name={`radar-import-url-${i}`}
label={t("radar.import.url")}
placeholder="https://www.threads.net/@handle/post/..."
value={row.url}
onChange={(e) => updateRow(i, { url: e.target.value })}
/>
<Textarea
name={`radar-import-text-${i}`}
label={t("radar.import.text")}
rows={2}
value={row.text}
onChange={(e) => updateRow(i, { text: e.target.value })}
/>
<Input
name={`radar-import-author-${i}`}
label={t("radar.import.author")}
placeholder="@handle"
value={row.author}
onChange={(e) => updateRow(i, { author: e.target.value })}
/>
{rows.length > 1 ? (
<Button type="button" variant="ghost" onClick={() => removeRow(i)}>
{t("radar.import.removeRow")}
</Button>
) : null}
</div>
))}
<div className="hb-radar-actions">
<Button type="button" variant="ghost" onClick={addRow} disabled={rows.length >= MAX_ROWS}>
{t("radar.import.addRow")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? t("radar.import.submitting") : t("radar.import.submit")}
</Button>
</div>
{err ? (
<p className="hb-banner-error" role="alert">
{err}
</p>
) : null}
{results ? (
<ul className="hb-radar-import__results">
{results.map((r, i) => (
<li key={`${r.url}-${i}`} className="hb-radar-import__result">
<Badge tone={statusTone(r.status)}>{t(`radar.import.status.${r.status}`)}</Badge>
<span className="hb-radar-import__resultUrl">{r.url}</span>
{r.intent_band ? (
<span className="hb-radar-section__hint">
{t(`radar.today.band.${r.intent_band}`)} · {r.intent_score}
</span>
) : null}
{r.error ? <span className="hb-radar-section__hint">{r.error}</span> : null}
</li>
))}
</ul>
) : null}
</div>
);
}

View File

@ -148,6 +148,10 @@ function mapBrief(raw: Record<string, unknown>): ScoutRunBrief {
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined, theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined, theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
product_context: raw.product_context != null ? String(raw.product_context) : undefined, product_context: raw.product_context != null ? String(raw.product_context) : undefined,
target_count:
raw.target_count != null && Number(raw.target_count) > 0
? Math.min(40, Math.floor(Number(raw.target_count)))
: undefined,
}; };
} }
@ -167,6 +171,7 @@ function briefToBody(b: ScoutRunBrief): Record<string, unknown> {
theme_key: b.theme_key, theme_key: b.theme_key,
theme_label: b.theme_label, theme_label: b.theme_label,
product_context: b.product_context, product_context: b.product_context,
target_count: b.target_count && b.target_count > 0 ? b.target_count : undefined,
}; };
} }

View File

@ -109,4 +109,34 @@ describe("demand-radar live repositories", () => {
await crm.listContacts({ follow_up: false }); await crm.listContacts({ follow_up: false });
expect(lastUrl).toContain("follow_up=false"); expect(lastUrl).toContain("follow_up=false");
}); });
it("posts import rows and maps per-row status back", async () => {
let lastBody: unknown;
globalThis.fetch = (async (url: string, init?: RequestInit) => {
lastUrl = String(url);
lastBody = init?.body ? JSON.parse(String(init.body)) : undefined;
return envelope(102000, {
results: [
{ url: "https://www.threads.net/@a/post/1", opportunity_id: "o1", status: "qualified", intent_band: "high", intent_score: 88 },
{ url: "https://www.threads.net/@a/post/2", status: "failed", error: "網址格式不正確" },
],
});
}) as unknown as typeof fetch;
const res = await createLiveRadarRepo().importOpportunities([
{ url: "https://www.threads.net/@a/post/1", text: "找水電師傅" },
{ url: "bad", text: "" },
]);
expect(lastUrl).toContain("/api/v1/radar/import");
expect(lastBody).toMatchObject({
items: [
{ url: "https://www.threads.net/@a/post/1", text: "找水電師傅" },
{ url: "bad", text: "" },
],
});
expect(res.results).toHaveLength(2);
expect(res.results[0]).toMatchObject({ opportunity_id: "o1", status: "qualified", intent_band: "high", intent_score: 88 });
expect(res.results[1]).toMatchObject({ status: "failed", error: "網址格式不正確" });
});
}); });

View File

@ -11,6 +11,7 @@ import type {
ContactTouch, ContactTouch,
CrmStats, CrmStats,
FollowUp, FollowUp,
ManualImportResult,
Opportunity, Opportunity,
RadarSweep, RadarSweep,
RadarToday, RadarToday,
@ -111,6 +112,7 @@ function mapReply(raw: Raw): ReplyVariant {
text: str(raw.text), text: str(raw.text),
used_at: optNum(raw.used_at), used_at: optNum(raw.used_at),
sent_channel: optStr(raw.sent_channel) as ReplyVariant["sent_channel"], sent_channel: optStr(raw.sent_channel) as ReplyVariant["sent_channel"],
outbox_id: optStr(raw.outbox_id),
created_at: num(raw.created_at), created_at: num(raw.created_at),
}; };
} }
@ -378,10 +380,10 @@ export function createLiveRadarRepo(): RadarRepo {
); );
return mapReply(raw); return mapReply(raw);
}, },
async markReplyUsed(opportunityId, replyId, channel) { async markReplyUsed(opportunityId, replyId, channel, accountId) {
const raw = await apiRequest<Raw>( const raw = await apiRequest<Raw>(
`${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies/${encodeURIComponent(replyId)}/mark-used`, `${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies/${encodeURIComponent(replyId)}/mark-used`,
{ method: "POST", body: { channel } }, { method: "POST", body: { channel, account_id: accountId } },
); );
const replyRaw = (raw.reply ?? raw) as Raw; const replyRaw = (raw.reply ?? raw) as Raw;
return { return {
@ -395,6 +397,44 @@ export function createLiveRadarRepo(): RadarRepo {
); );
return { list: rawList(raw.list).map(mapSweep), total: total(raw) }; return { list: rawList(raw.list).map(mapSweep), total: total(raw) };
}, },
async importOpportunities(items) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/import`, {
method: "POST",
body: {
items: items.map((it) => ({
url: it.url,
text: it.text,
author: it.author,
posted_at: it.posted_at,
})),
},
});
const results = rawList(raw.results).map(
(r): ManualImportResult => ({
url: str(r.url),
opportunity_id: optStr(r.opportunity_id),
status: str(r.status) as ManualImportResult["status"],
intent_band: optStr(r.intent_band) as ManualImportResult["intent_band"],
intent_score: optNum(r.intent_score),
error: optStr(r.error),
}),
);
return { results };
},
async exploreOpportunities(terms) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/explore`, {
method: "POST",
body: { terms },
});
return {
sweep_id: str(raw.sweep_id),
hit_count: num(raw.hit_count),
judged_count: num(raw.judged_count),
created_count: num(raw.created_count),
truncated_count: num(raw.truncated_count),
credits_used: num(raw.credits_used),
};
},
}; };
} }

View File

@ -693,11 +693,15 @@ export type RadarRepo = {
opportunityId: string, opportunityId: string,
variant: import("../domain/types").ReplyVariantKind, variant: import("../domain/types").ReplyVariantKind,
): Promise<import("../domain/types").ReplyVariant>; ): Promise<import("../domain/types").ReplyVariant>;
/** channel: outbox | manual_copydm 僅 manual_copy */ /**
* channel: outbox | manual_copydm manual_copy
* accountId channel=outbox Threads
*/
markReplyUsed( markReplyUsed(
opportunityId: string, opportunityId: string,
replyId: string, replyId: string,
channel: "outbox" | "manual_copy", channel: "outbox" | "manual_copy",
accountId?: string,
): Promise<{ ): Promise<{
reply: import("../domain/types").ReplyVariant; reply: import("../domain/types").ReplyVariant;
health_advice?: string; health_advice?: string;
@ -707,6 +711,14 @@ export type RadarRepo = {
pageSize?: number, pageSize?: number,
watchId?: string, watchId?: string,
): Promise<{ list: import("../domain/types").RadarSweep[]; total: number }>; ): Promise<{ list: import("../domain/types").RadarSweep[]; total: number }>;
/** 手動匯入商機P1貼 ThreadsFacebook 貼文網址+內文或 CSV 批次,走同一套五問判定 */
importOpportunities(
items: import("../domain/types").ManualImportItem[],
): Promise<{ results: import("../domain/types").ManualImportResult[] }>;
/** 立即探索:短詞 fan-out 搜尋 → 五問判定,寫入今日商機 */
exploreOpportunities(
terms: string[],
): Promise<import("../domain/types").ExploreResult>;
}; };
export type CrmRepo = { export type CrmRepo = {

View File

@ -765,6 +765,8 @@ export type ScoutRunBrief = {
/** 分組/持久化用(與命中 theme_key 對齊) */ /** 分組/持久化用(與命中 theme_key 對齊) */
theme_key?: string; theme_key?: string;
theme_label?: string; theme_label?: string;
/** 話題今日目標(則);後端主路徑不足時會加碼/次路徑補抓 */
target_count?: number;
}; };
/** 已完成的功課(可依主題找回,不消失) */ /** 已完成的功課(可依主題找回,不消失) */
@ -911,14 +913,44 @@ export type ReplyVariant = {
text: string; text: string;
used_at?: number; used_at?: number;
sent_channel?: "outbox" | "manual_copy"; sent_channel?: "outbox" | "manual_copy";
/** 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值 */
outbox_id?: string;
created_at: number; created_at: number;
}; };
/** 手動匯入一列:貼 ThreadsFacebook 貼文網址+內文,走同一套五問判定 */
export type ManualImportItem = {
url: string;
text: string;
author?: string;
/** unix nanoseconds UTC不填就用匯入當下時間 */
posted_at?: number;
};
export type ManualImportResult = {
url: string;
opportunity_id?: string;
status: "qualified" | "rejected" | "skipped" | "failed";
intent_band?: IntentBand;
intent_score?: number;
error?: string;
};
/** 立即探索結果POST /radar/explore */
export type ExploreResult = {
sweep_id: string;
hit_count: number;
judged_count: number;
created_count: number;
truncated_count: number;
credits_used: number;
};
/** 商機:經五問判定、值得跟進的需求訊號 */ /** 商機:經五問判定、值得跟進的需求訊號 */
export type Opportunity = { export type Opportunity = {
id: string; id: string;
watch_id?: string; watch_id?: string;
source: "threads" | "manual" | "scout_promote"; source: "threads" | "manual_import" | "scout_promote";
source_scout_post_id?: string; source_scout_post_id?: string;
external_id: string; external_id: string;
permalink: string; permalink: string;

View File

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { parseCsvRows } from "./csv";
describe("parseCsvRows", () => {
it("splits simple comma-separated rows", () => {
expect(parseCsvRows("url,text,author\na,b,c")).toEqual([
["url", "text", "author"],
["a", "b", "c"],
]);
});
it("keeps commas inside quoted fields intact", () => {
expect(parseCsvRows('u,"你好,世界",author')).toEqual([["u", "你好,世界", "author"]]);
});
it("unescapes doubled quotes inside a quoted field", () => {
expect(parseCsvRows('u,"she said ""hi""",a')).toEqual([["u", 'she said "hi"', "a"]]);
});
it("drops blank lines", () => {
expect(parseCsvRows("a,b\n\n\nc,d")).toEqual([
["a", "b"],
["c", "d"],
]);
});
it("returns an empty array for empty input", () => {
expect(parseCsvRows("")).toEqual([]);
});
});

50
apps/web/src/lib/csv.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* CSV
* RFC4180 CSV
*/
export function parseCsvRows(input: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
const text = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += c;
}
continue;
}
if (c === '"') {
inQuotes = true;
continue;
}
if (c === ",") {
row.push(field);
field = "";
continue;
}
if (c === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
continue;
}
field += c;
}
if (field !== "" || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((f) => f.trim() !== ""));
}

View File

@ -16,7 +16,7 @@ export const zhTW: MessageDict = {
"nav.radar": "商機", "nav.radar": "商機",
"nav.crm": "名單", "nav.crm": "名單",
/** 手動掃場外展vs 商機=訂閱後每天自動) */ /** 手動掃場外展vs 商機=訂閱後每天自動) */
"nav.scout": "探索", "nav.scout": "話題",
"nav.outbox": "發送", "nav.outbox": "發送",
"nav.jobs": "任務", "nav.jobs": "任務",
"nav.brands": "品牌", "nav.brands": "品牌",
@ -96,19 +96,19 @@ export const zhTW: MessageDict = {
"help.page.studio.step3": "送出到發送匣,到 Outbox 確認排程。", "help.page.studio.step3": "送出到發送匣,到 Outbox 確認排程。",
"help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。", "help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。",
"help.page.scout.title": "海巡(手動掃場)", "help.page.scout.title": "話題靈感",
"help.page.scout.what": "先練關鍵字、確認後再搜:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。", "help.page.scout.what": "用關鍵字找 Threads 上可跟的活躍話題,產出草稿、回完就結。找「正在找你的人」請用側欄「商機」的每日巡或立即探索。",
"help.page.scout.step1": "寫意圖(可選產品),按「產出關鍵字」檢視/增刪 query。", "help.page.scout.step1": "寫話題關鍵字,按「產出關鍵字」檢視/增刪 query。",
"help.page.scout.step2": "確認後「用這些詞開始搜」,在佇列依發文時間處理。", "help.page.scout.step2": "確認後「用這些詞開始搜」,在佇列依發文時間處理。",
"help.page.scout.step3": "值得長期跟進的,按「收進商機」複製到每日商機/名單(不影響這則海巡狀態)。", "help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
"help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。關鍵字要寫「對方的困擾」,不要寫產品賣點。", "help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
"help.page.radar_today.title": "今日商機(自動名單)", "help.page.radar_today.title": "今日商機(自動名單)",
"help.page.radar_today.what": "依你訂的關鍵字,系統每天自動整理的需求名單(高/中/低意向)。和海巡不同:不用每次手動掃。", "help.page.radar_today.what": "依你訂的關鍵字每天自動整理的需求名單(高/中/低意向)。也可「立即探索」臨時搜,或手動匯入貼文。",
"help.page.radar_today.step1": "先看統計0 筆時依原因去「商機訂閱」或服務檔案。", "help.page.radar_today.step1": "先看統計0 筆時用立即探索、訂閱管理或服務檔案。",
"help.page.radar_today.step2": "從高意向開始:開原文 → 產生回覆 → 加入名單或略過。", "help.page.radar_today.step2": "從高意向開始:開原文 → 產生回覆 → 加入名單或略過。",
"help.page.radar_today.step3": "低意向預設收合;需要長期跟進的進「名單」看板。", "help.page.radar_today.step3": "低意向預設收合;需要長期跟進的進「名單」看板。",
"help.page.radar_today.tips": "想臨時掃痛點/話題用「海巡」;想每天穩收需求用這裡的訂閱。", "help.page.radar_today.tips": "找客戶=這裡(訂閱/立即探索/匯入)。找內容話題用側欄「話題」。",
"help.page.radar_watches.title": "商機訂閱", "help.page.radar_watches.title": "商機訂閱",
"help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。", "help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。",
@ -1392,7 +1392,16 @@ export const zhTW: MessageDict = {
"common.listSep": "、", "common.listSep": "、",
"common.dash": "—", "common.dash": "—",
"scout.title": "手動探索", "scout.title": "話題靈感",
"scout.topic.intro": "找內容靈感與可跟的話題。找客戶需求請到「商機」用立即探索或訂閱每日巡。",
"scout.topic.termHint": "預設只搜主查詢(與你在 Threads 打同一詞最接近);變體可勾選,最多 3 組。",
"scout.topic.termsNeedShort": "有 {n} 組關鍵字不合 Threads 短詞規則,請改短後再搜。",
"scout.topic.noTerms": "沒產出可用關鍵字,請換個更具體的主題再試(例:台北 市集、保母 求推薦)。",
"scout.topic.termsReadyPrimary": "已產出主查詢,另有 {n} 組變體可勾選。建議先只搜主查詢,精準度最接近 Threads。",
"scout.topic.workshopHintSelect": "勾選要送進 Threads 搜尋的詞。預設只勾主查詢——和你在 App 打同一組最接近。變體可加勾,最多 3 組。",
"scout.topic.primaryTerm": "主查詢(建議)",
"scout.topic.variantTerm": "變體 {n}",
"scout.topic.useTerm": "使用此關鍵字搜尋",
"scout.today": "今日出擊", "scout.today": "今日出擊",
"scout.purposeValue": "痛點回覆", "scout.purposeValue": "痛點回覆",
"scout.purposeDemand": "找需求痛點", "scout.purposeDemand": "找需求痛點",
@ -2151,6 +2160,7 @@ export const zhTW: MessageDict = {
"radar.watches.editTitle": "編輯商機訂閱", "radar.watches.editTitle": "編輯商機訂閱",
"radar.watches.terms": "關鍵字", "radar.watches.terms": "關鍵字",
"radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。", "radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。",
"radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 24 字、整組 ≤12 字、勿用標點/#emoji否則常搜不到。",
"radar.watches.termsPh": "推薦室內設計\n找設計師", "radar.watches.termsPh": "推薦室內設計\n找設計師",
"radar.watches.excludeTerms": "排除詞", "radar.watches.excludeTerms": "排除詞",
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。", "radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
@ -2278,10 +2288,62 @@ export const zhTW: MessageDict = {
"radar.today.msg.copied": "已複製到剪貼簿", "radar.today.msg.copied": "已複製到剪貼簿",
"radar.today.msg.copyFail": "無法複製,請手動選取文字", "radar.today.msg.copyFail": "無法複製,請手動選取文字",
"radar.today.msg.marked": "已標記為已送出/已複製", "radar.today.msg.marked": "已標記為已送出/已複製",
"radar.today.msg.sent": "已送出,稍後可在發送佇列查看進度",
"radar.today.msg.needReply": "請先產生回覆草稿", "radar.today.msg.needReply": "請先產生回覆草稿",
"radar.today.sendAccount": "送出帳號",
"radar.today.reply.markCopy": "標記已複製送出", "radar.today.reply.markCopy": "標記已複製送出",
"radar.today.reply.markOutbox": "標記經發送管道", "radar.today.reply.markOutbox": "一鍵送出Outbox",
"radar.today.reply.needAccount": "先連一個 Threads 帳號才能一鍵送出",
"radar.today.reply.used": "已標記使用", "radar.today.reply.used": "已標記使用",
"radar.today.reply.usedOutbox": "已送出(可在發送佇列查看)",
"radar.import.open": "手動匯入",
"radar.import.close": "收起手動匯入",
"radar.import.title": "手動匯入商機",
"radar.import.hint": "貼 ThreadsFacebook 貼文網址與內文,跑同一套五問判定;沒有爬蟲能讀任意網址,內文請直接貼上。量體不足時可用這個補足每日名單。",
"radar.import.url": "貼文網址",
"radar.import.text": "貼文內文",
"radar.import.author": "作者(選填)",
"radar.import.addRow": "+新增一列",
"radar.import.removeRow": "移除",
"radar.import.submit": "送出匯入",
"radar.import.submitting": "匯入中…",
"radar.import.needRow": "至少填一列網址與內文",
"radar.import.csvOpen": "改用貼上 CSV",
"radar.import.csvClose": "收起 CSV",
"radar.import.csvLabel": "貼上 CSV",
"radar.import.csvHint": "格式url,text,authorauthor 選填)。第一列若含 urltext 表頭會自動辨識,沒有就照 url,text,author 順序。",
"radar.import.csvApply": "套用到下方列表",
"radar.import.csvEmpty": "沒有解析出任何一列,請確認格式",
"radar.import.status.qualified": "已收進今日商機",
"radar.import.status.rejected": "已判定不符(仍留存)",
"radar.import.status.skipped": "已匯入過,略過",
"radar.import.status.failed": "匯入失敗",
"radar.explore.open": "立即探索",
"radar.explore.close": "收起探索",
"radar.explore.title": "立即探索",
"radar.explore.hint": "用短關鍵字立刻搜 Threads 上「正在找你」的人,結果走同一套五問判定後進今日商機。每組最多 2 個詞、中文每詞 24 字。",
"radar.explore.loadingSuggest": "載入建議關鍵字…",
"radar.explore.suggestions": "建議短詞(點一下加入)",
"radar.explore.selected": "已選關鍵字",
"radar.explore.emptyTerms": "還沒有關鍵字,從上方建議點選或自己加一組。",
"radar.explore.removeChip": "點一下移除",
"radar.explore.addLabel": "自己加一組短詞",
"radar.explore.addPh": "例:保母 求推薦",
"radar.explore.add": "加入",
"radar.explore.run": "開始探索",
"radar.explore.running": "探索中…",
"radar.explore.needTerms": "至少選一組關鍵字",
"radar.explore.result": "找到 {hits} 則、判定 {judged} 則、收進 {created} 則",
"radar.explore.resultZeroHint": "這次沒有新商機。可換更口語的短詞(求推薦、有人知道),或到訂閱管理調整每日監控。",
"radar.explore.resultTruncated": "有 {n} 則因每日上限未收進,明天再來或升級方案。",
"radar.explore.termError.empty": "請輸入關鍵字",
"radar.explore.termError.tooLong": "整組去掉空格後最多 12 字Threads 長字串常搜不到)",
"radar.explore.termError.tooManyTokens": "最多 2 個詞(用半形空格分隔)",
"radar.explore.termError.invalidToken": "不要標點、emoji、AND/OR 或過短/過長的詞",
"radar.explore.termError.duplicate": "這組詞已經加入了",
"radar.explore.termError.max": "一次最多 6 組關鍵字",
"scout.promote": "收進商機", "scout.promote": "收進商機",
"scout.promoted": "已複製進今日商機({band} · {score}),可到側欄「商機」跟進", "scout.promoted": "已複製進今日商機({band} · {score}),可到側欄「商機」跟進",
@ -2369,7 +2431,7 @@ export const en: MessageDict = {
"nav.studio": "Studio", "nav.studio": "Studio",
"nav.radar": "Demand", "nav.radar": "Demand",
"nav.crm": "CRM", "nav.crm": "CRM",
"nav.scout": "Discover", "nav.scout": "Topics",
"nav.outbox": "Outbox", "nav.outbox": "Outbox",
"nav.jobs": "Jobs", "nav.jobs": "Jobs",
"nav.brands": "Brands", "nav.brands": "Brands",
@ -2448,19 +2510,19 @@ export const en: MessageDict = {
"help.page.studio.step3": "Send to Outbox and confirm the schedule there.", "help.page.studio.step3": "Send to Outbox and confirm the schedule there.",
"help.page.studio.tips": "Studio drafts are not published until Outbox succeeds.", "help.page.studio.tips": "Studio drafts are not published until Outbox succeeds.",
"help.page.scout.title": "Patrol (manual sweep)", "help.page.scout.title": "Topic ideas",
"help.page.scout.what": "Plan keywords first, confirm, then search: find pain/topic threads, draft, reply, done. Not the daily auto list — that is Demand in the nav.", "help.page.scout.what": "Find lively Threads topics to join, draft a reply, and mark done. To find people looking for your service, use Demand (daily watch or Explore now).",
"help.page.scout.step1": "Write your intent (optional product), generate keywords, then edit the queries.", "help.page.scout.step1": "Enter topic keywords, generate queries, then edit them.",
"help.page.scout.step2": "Confirm search, then work the queue sorted by post time.", "help.page.scout.step2": "Confirm search, then work the queue sorted by post time.",
"help.page.scout.step3": "Worth long-term follow-up? Use “Save to Demand” to copy into todays opportunities (scout status stays unchanged).", "help.page.scout.step3": "Draft, open Threads to reply, mark done.",
"help.page.scout.tips": "Patrol = one-off sortie. Demand = subscribed keywords, refreshed every day. Keywords should describe their problem, not your product pitch.", "help.page.scout.tips": "Topics = content ideas. Demand = finding customers. Use the Demand page for leads.",
"help.page.radar_today.title": "Today's demand (auto list)", "help.page.radar_today.title": "Today's demand (auto list)",
"help.page.radar_today.what": "A daily demand list from your keyword watches, scored high/mid/low. Unlike Patrol, you dont re-scan by hand each time.", "help.page.radar_today.what": "A daily demand list from your keyword watches, scored high/mid/low. You can also Explore now or import posts manually.",
"help.page.radar_today.step1": "Check stats; if empty, open Demand watches or the service profile.", "help.page.radar_today.step1": "Check stats; if empty, try Explore now, watches, or the service profile.",
"help.page.radar_today.step2": "Start high intent: open original → draft reply → add to CRM or dismiss.", "help.page.radar_today.step2": "Start high intent: open original → draft reply → add to CRM or dismiss.",
"help.page.radar_today.step3": "Low intent is collapsed; long-term follow-up lives on the CRM board.", "help.page.radar_today.step3": "Low intent is collapsed; long-term follow-up lives on the CRM board.",
"help.page.radar_today.tips": "Need a one-off pain/topic sweep? Use Patrol. Want a steady daily list? Stay on Demand watches.", "help.page.radar_today.tips": "Finding customers = this page (watches / Explore / import). Content topics live under Topics.",
"help.page.radar_watches.title": "Demand watches", "help.page.radar_watches.title": "Demand watches",
"help.page.radar_watches.what": "Always-on keywords swept daily into Todays demand. This is not Patrols “run once” scan.", "help.page.radar_watches.what": "Always-on keywords swept daily into Todays demand. This is not Patrols “run once” scan.",
@ -3743,7 +3805,16 @@ export const en: MessageDict = {
"common.listSep": ", ", "common.listSep": ", ",
"common.dash": "—", "common.dash": "—",
"scout.title": "Manual discovery", "scout.title": "Topic ideas",
"scout.topic.intro": "Find content ideas and joinable topics. For customer demand, use Demand → Explore now or daily watches.",
"scout.topic.termHint": "Default: search the primary query only (closest to typing it on Threads). Optionally check up to 2 more variants.",
"scout.topic.termsNeedShort": "{n} term(s) break Threads short-query rules — shorten them before searching.",
"scout.topic.noTerms": "No usable terms — try a clearer topic (e.g. Taipei market, nanny recs).",
"scout.topic.termsReadyPrimary": "Primary query ready, plus {n} optional variants. Search primary only first for Threads-like precision.",
"scout.topic.workshopHintSelect": "Check terms to search. Primary is selected by default — closest to searching on Threads. Max 3 terms.",
"scout.topic.primaryTerm": "Primary (recommended)",
"scout.topic.variantTerm": "Variant {n}",
"scout.topic.useTerm": "Include this term in search",
"scout.today": "Today's sortie", "scout.today": "Today's sortie",
"scout.purposeValue": "Pain-point replies", "scout.purposeValue": "Pain-point replies",
"scout.purposeDemand": "Find demand pains", "scout.purposeDemand": "Find demand pains",
@ -4506,6 +4577,7 @@ export const en: MessageDict = {
"radar.watches.editTitle": "Edit demand watch", "radar.watches.editTitle": "Edit demand watch",
"radar.watches.terms": "Terms", "radar.watches.terms": "Terms",
"radar.watches.termsHint": "One per line, commas work too. Phrases buyers type; hits go to intent scoring.", "radar.watches.termsHint": "One per line, commas work too. Phrases buyers type; hits go to intent scoring.",
"radar.watches.threadsWarn": "Some terms break Threads short-query rules: max 2 words, CJK 24 chars each, ≤12 chars total, no punctuation/#/emoji — long queries often return nothing.",
"radar.watches.termsPh": "interior designer recommendation\nlooking for a designer", "radar.watches.termsPh": "interior designer recommendation\nlooking for a designer",
"radar.watches.excludeTerms": "Exclude terms", "radar.watches.excludeTerms": "Exclude terms",
"radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.", "radar.watches.excludeHint": "A hit here skips the post entirely, e.g. job ads or giveaways.",
@ -4635,10 +4707,62 @@ export const en: MessageDict = {
"radar.today.msg.copied": "Copied to clipboard", "radar.today.msg.copied": "Copied to clipboard",
"radar.today.msg.copyFail": "Could not copy — select the text manually", "radar.today.msg.copyFail": "Could not copy — select the text manually",
"radar.today.msg.marked": "Marked as sent/copied", "radar.today.msg.marked": "Marked as sent/copied",
"radar.today.msg.sent": "Sent — check progress in the outbox queue",
"radar.today.msg.needReply": "Generate a reply draft first", "radar.today.msg.needReply": "Generate a reply draft first",
"radar.today.sendAccount": "Send from",
"radar.today.reply.markCopy": "Mark as copied & sent", "radar.today.reply.markCopy": "Mark as copied & sent",
"radar.today.reply.markOutbox": "Mark via outbox path", "radar.today.reply.markOutbox": "Send now (Outbox)",
"radar.today.reply.needAccount": "Connect a Threads account first to send",
"radar.today.reply.used": "Already marked used", "radar.today.reply.used": "Already marked used",
"radar.today.reply.usedOutbox": "Sent (check the outbox queue)",
"radar.import.open": "Manual import",
"radar.import.close": "Hide manual import",
"radar.import.title": "Manually import demand",
"radar.import.hint": "Paste a Threads/Facebook post URL and its text; it runs the same five-question judge. There's no crawler for arbitrary URLs, so paste the text yourself. Use this to top up today's list when volume is low.",
"radar.import.url": "Post URL",
"radar.import.text": "Post text",
"radar.import.author": "Author (optional)",
"radar.import.addRow": "+ Add row",
"radar.import.removeRow": "Remove",
"radar.import.submit": "Import",
"radar.import.submitting": "Importing…",
"radar.import.needRow": "Fill in at least one row with a URL and text",
"radar.import.csvOpen": "Paste CSV instead",
"radar.import.csvClose": "Hide CSV",
"radar.import.csvLabel": "Paste CSV",
"radar.import.csvHint": "Format: url,text,author (author optional). A header row with url/text is auto-detected; otherwise columns are read as url,text,author.",
"radar.import.csvApply": "Apply to rows below",
"radar.import.csvEmpty": "Couldn't parse any rows — check the format",
"radar.import.status.qualified": "Added to today's demand",
"radar.import.status.rejected": "Judged as not a fit (kept for reference)",
"radar.import.status.skipped": "Already imported — skipped",
"radar.import.status.failed": "Import failed",
"radar.explore.open": "Explore now",
"radar.explore.close": "Hide explore",
"radar.explore.title": "Explore now",
"radar.explore.hint": "Search Threads right away for people looking for your service. Results go through the same five-question judge into todays list. Max 2 words per query, 24 CJK chars each.",
"radar.explore.loadingSuggest": "Loading suggested terms…",
"radar.explore.suggestions": "Suggested short terms (click to add)",
"radar.explore.selected": "Selected terms",
"radar.explore.emptyTerms": "No terms yet — pick a suggestion or add your own.",
"radar.explore.removeChip": "Click to remove",
"radar.explore.addLabel": "Add a short term",
"radar.explore.addPh": "e.g. nanny recommend",
"radar.explore.add": "Add",
"radar.explore.run": "Start explore",
"radar.explore.running": "Exploring…",
"radar.explore.needTerms": "Pick at least one term",
"radar.explore.result": "Found {hits}, judged {judged}, added {created}",
"radar.explore.resultZeroHint": "Nothing new this run. Try more conversational short terms, or adjust daily watches.",
"radar.explore.resultTruncated": "{n} skipped by daily cap — try again tomorrow or upgrade.",
"radar.explore.termError.empty": "Enter a term",
"radar.explore.termError.tooLong": "At most 12 characters without spaces (long Threads queries often return nothing)",
"radar.explore.termError.tooManyTokens": "At most 2 words (space-separated)",
"radar.explore.termError.invalidToken": "No punctuation, emoji, AND/OR, or tokens that are too short/long",
"radar.explore.termError.duplicate": "Already added",
"radar.explore.termError.max": "At most 6 terms per run",
"scout.promote": "Save to Demand", "scout.promote": "Save to Demand",
"scout.promoted": "Copied into Todays demand ({band} · {score}) — follow up under Demand", "scout.promoted": "Copied into Todays demand ({band} · {score}) — follow up under Demand",

View File

@ -28,8 +28,9 @@ export const primaryNav: NavItem[] = [
{ key: "today", path: "/app/today", labelKey: "nav.today", label: "今日", en: "Today" }, { key: "today", path: "/app/today", labelKey: "nav.today", label: "今日", en: "Today" },
{ key: "crew", path: "/app/crew", labelKey: "nav.crew", label: "帳號", en: "Crew" }, { key: "crew", path: "/app/crew", labelKey: "nav.crew", label: "帳號", en: "Crew" },
{ key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" }, { key: "studio", path: "/app/studio", labelKey: "nav.studio", label: "創作", en: "Studio" },
{ key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "海巡", en: "Patrol" }, /** 話題靈感(原海巡活躍話題);找需求請用商機頁「立即探索」 */
/** 側欄用「商機」:與海巡(手動掃場)對照,進的是每日自動名單 */ { key: "scout", path: "/app/scout", labelKey: "nav.scout", label: "話題", en: "Topics" },
/** 側欄用「商機」:每日自動名單+立即探索+手動匯入 */
{ key: "radar", path: "/app/radar/today", labelKey: "nav.radar", label: "商機", en: "Demand" }, { key: "radar", path: "/app/radar/today", labelKey: "nav.radar", label: "商機", en: "Demand" },
{ key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" }, { key: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
{ key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" }, { key: "outbox", path: "/app/outbox", labelKey: "nav.outbox", label: "發送", en: "Outbox" },
@ -59,7 +60,7 @@ export const primaryNav: NavItem[] = [
{ key: "utm", path: "/app/utm", labelKey: "nav.utm", label: "追蹤", en: "UTM" }, { key: "utm", path: "/app/utm", labelKey: "nav.utm", label: "追蹤", en: "UTM" },
]; ];
/** 手機底欄固定 4 格(主流程;T553radar 進主四格scout 移入更多) */ /** 手機底欄固定 4 格(主流程;radar 進主四格,話題移入更多) */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"]; export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"];
/** 手機底欄「更多」內項目 */ /** 手機底欄「更多」內項目 */
@ -83,7 +84,7 @@ export type NavGroup = {
keys: NavKey[]; keys: NavKey[];
}; };
/** 側欄二級分類:主流程 → 帳號品牌 → 成長工具,避免 11 項全部散在同一層 */ /** 側欄二級分類:主流程(商機→名單)→ 帳號品牌 → 成長工具話題放創作側workflow 內 studio 旁) */
export const navGroups: NavGroup[] = [ export const navGroups: NavGroup[] = [
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] }, { key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] },
{ key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands"] }, { key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands"] },

View File

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "./threadsTerm";
describe("threadsTerm", () => {
it("normalizes full-width spaces", () => {
expect(normalizeSearchTerm(" 保母 求推薦 ")).toBe("保母 求推薦");
});
it("accepts short Threads terms", () => {
expect(isThreadsSearchable("保母 求推薦")).toBe(true);
expect(isThreadsSearchable("到府保母")).toBe(true);
expect(isThreadsSearchable("wedding photo")).toBe(true);
});
it("rejects overlong or multi-token or punctuated terms", () => {
expect(isThreadsSearchable("台北 婚攝 推薦")).toBe(false);
expect(isThreadsSearchable("這是一個超長關鍵字超過十二字")).toBe(false);
expect(isThreadsSearchable("保母 AND 求推薦")).toBe(false);
expect(isThreadsSearchable("保母#推薦")).toBe(false);
expect(isThreadsSearchable("a")).toBe(false);
expect(checkThreadsTerm("")).toEqual({ ok: false, reason: "empty" });
});
});

View File

@ -0,0 +1,75 @@
/**
* Threads domain.IsThreadsSearchable
* 2 token 24 12 booleanemoji#
*/
const MAX_TOKENS = 2;
const MIN_CJK = 2;
const MAX_CJK = 4;
const MAX_TOTAL = 12;
export function normalizeSearchTerm(raw: string): string {
return raw
.replace(/\u3000/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
.join(" ");
}
function isCJK(ch: string): boolean {
const code = ch.codePointAt(0) ?? 0;
return (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0x3040 && code <= 0x30ff)
);
}
function isAllowedChar(ch: string): boolean {
if (ch === "#" || ch === '"' || ch === "'" || ch === "「" || ch === "」") return false;
if (/[-+*()|&!]/.test(ch)) return false;
// letters / digits / CJK
if (/[a-zA-Z0-9]/.test(ch)) return true;
if (isCJK(ch)) return true;
// reject punctuation / symbols / emoji
if (/\p{P}|\p{S}/u.test(ch)) return false;
return false;
}
function isAllowedToken(tok: string): boolean {
if (!tok) return false;
const upper = tok.toUpperCase();
if (upper === "AND" || upper === "OR" || upper === "NOT") return false;
let hasCJK = false;
let hasLetter = false;
for (const ch of [...tok]) {
if (!isAllowedChar(ch)) return false;
if (isCJK(ch)) hasCJK = true;
else if (/[a-zA-Z0-9]/.test(ch)) hasLetter = true;
}
const n = [...tok].length;
if (hasCJK) return n >= MIN_CJK && n <= MAX_CJK;
if (hasLetter) return n >= 2 && n <= MAX_TOTAL;
return false;
}
export type ThreadsTermCheck = { ok: true } | { ok: false; reason: string };
/** 回傳是否合規與可給 UI 的原因 key 後綴(由 i18n 翻譯)。 */
export function checkThreadsTerm(raw: string): ThreadsTermCheck {
const term = normalizeSearchTerm(raw);
if (!term) return { ok: false, reason: "empty" };
const compact = term.replace(/\s+/g, "");
if ([...compact].length > MAX_TOTAL) return { ok: false, reason: "tooLong" };
const tokens = term.split(/\s+/);
if (tokens.length > MAX_TOKENS) return { ok: false, reason: "tooManyTokens" };
for (const tok of tokens) {
if (!isAllowedToken(tok)) return { ok: false, reason: "invalidToken" };
}
return { ok: true };
}
export function isThreadsSearchable(term: string): boolean {
return checkThreadsTerm(term).ok;
}

View File

@ -41,6 +41,11 @@ function sampleOpp(id: string, band: "high" | "mid" | "low", score: number): Opp
vi.mock("../data/DataContext", () => ({ vi.mock("../data/DataContext", () => ({
useRepos: () => ({ useRepos: () => ({
accounts: {
async list() {
return [];
},
},
radar: { radar: {
async getToday() { async getToday() {
if (!backend.today) throw new Error("no today"); if (!backend.today) throw new Error("no today");

View File

@ -5,7 +5,9 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, EmptyState } from "../components/ui"; import { ExplorePanel } from "../components/radar/ExplorePanel";
import { ManualImportPanel } from "../components/radar/ManualImportPanel";
import { Badge, Button, EmptyState, Select } from "../components/ui";
import type { BadgeTone } from "../components/ui"; import type { BadgeTone } from "../components/ui";
import { useRepos } from "../data/DataContext"; import { useRepos } from "../data/DataContext";
import type { import type {
@ -15,6 +17,7 @@ import type {
RadarToday, RadarToday,
ReplyVariant, ReplyVariant,
ReplyVariantKind, ReplyVariantKind,
ThreadsAccount,
} from "../domain/types"; } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
@ -60,6 +63,7 @@ function OppCard({
o, o,
reply, reply,
busy, busy,
canSendOutbox,
onAccept, onAccept,
onDismiss, onDismiss,
onGenerateReply, onGenerateReply,
@ -70,6 +74,8 @@ function OppCard({
o: Opportunity; o: Opportunity;
reply?: ReplyVariant; reply?: ReplyVariant;
busy: string; busy: string;
/** false沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */
canSendOutbox: boolean;
onAccept: () => void; onAccept: () => void;
onDismiss: () => void; onDismiss: () => void;
onGenerateReply: (variant: ReplyVariantKind) => void; onGenerateReply: (variant: ReplyVariantKind) => void;
@ -173,7 +179,8 @@ function OppCard({
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
disabled={busy === `mark-${o.id}`} disabled={busy === `mark-${o.id}` || !canSendOutbox}
title={canSendOutbox ? undefined : t("radar.today.reply.needAccount")}
onClick={() => onMarkUsed("outbox")} onClick={() => onMarkUsed("outbox")}
> >
{t("radar.today.reply.markOutbox")} {t("radar.today.reply.markOutbox")}
@ -181,8 +188,18 @@ function OppCard({
) : null} ) : null}
</> </>
) : null} ) : null}
{!isDm && !canSendOutbox && activeReply && !activeReply.used_at ? (
<span className="hb-radar-section__hint">
{t("radar.today.reply.needAccount")}{" "}
<Link to="/app/crew">{t("nav.crew")}</Link>
</span>
) : null}
{activeReply?.used_at ? ( {activeReply?.used_at ? (
<span className="hb-radar-section__hint">{t("radar.today.reply.used")}</span> <span className="hb-radar-section__hint">
{activeReply.sent_channel === "outbox" && activeReply.outbox_id
? t("radar.today.reply.usedOutbox")
: t("radar.today.reply.used")}
</span>
) : null} ) : null}
</div> </div>
</div> </div>
@ -252,6 +269,10 @@ export function RadarTodayPage() {
const [lowOpen, setLowOpen] = useState(false); const [lowOpen, setLowOpen] = useState(false);
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [replies, setReplies] = useState<Record<string, ReplyVariant>>({}); const [replies, setReplies] = useState<Record<string, ReplyVariant>>({});
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [sendAccountId, setSendAccountId] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [exploreOpen, setExploreOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const next = await repos.radar.getToday(); const next = await repos.radar.getToday();
@ -276,6 +297,18 @@ export function RadarTodayPage() {
}; };
}, [load]); // eslint-disable-line react-hooks/exhaustive-deps }, [load]); // eslint-disable-line react-hooks/exhaustive-deps
// 一鍵送出要知道用哪個帳號;多數人只有一個已連帳號,預設選第一個可用的即可。
useEffect(() => {
void repos.accounts
.list()
.then((list) => {
const usable = list.filter((a) => a.is_usable);
setAccounts(usable);
setSendAccountId((prev) => (prev && usable.some((a) => a.id === prev) ? prev : usable[0]?.id || ""));
})
.catch(() => undefined);
}, [repos.accounts]);
async function run(key: string, action: () => Promise<void>, okMessage?: string) { async function run(key: string, action: () => Promise<void>, okMessage?: string) {
setBusy(key); setBusy(key);
setErr(null); setErr(null);
@ -318,10 +351,15 @@ export function RadarTodayPage() {
return; return;
} }
await run(`mark-${id}`, async () => { await run(`mark-${id}`, async () => {
const res = await repos.radar.markReplyUsed(id, r.id, channel); const res = await repos.radar.markReplyUsed(
id,
r.id,
channel,
channel === "outbox" ? sendAccountId : undefined,
);
setReplies((m) => ({ ...m, [id]: res.reply })); setReplies((m) => ({ ...m, [id]: res.reply }));
if (res.health_advice) setMsg(res.health_advice); if (res.health_advice) setMsg(res.health_advice);
}, t("radar.today.msg.marked")); }, channel === "outbox" ? t("radar.today.msg.sent") : t("radar.today.msg.marked"));
} }
async function overrideBand(id: string, band: IntentBand) { async function overrideBand(id: string, band: IntentBand) {
@ -350,11 +388,48 @@ export function RadarTodayPage() {
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"> <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">
{t("radar.today.link.watches")} {t("radar.today.link.watches")}
</Link> </Link>
<Button
type="button"
variant="secondary"
onClick={() => {
setExploreOpen((v) => !v);
if (!exploreOpen) setImportOpen(false);
}}
>
{exploreOpen ? t("radar.explore.close") : t("radar.explore.open")}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setImportOpen((v) => !v);
if (!importOpen) setExploreOpen(false);
}}
>
{importOpen ? t("radar.import.close") : t("radar.import.open")}
</Button>
<Link className="hb-btn hb-btn--ghost" to="/app/crm"> <Link className="hb-btn hb-btn--ghost" to="/app/crm">
{t("radar.today.link.crm")} {t("radar.today.link.crm")}
</Link> </Link>
{accounts.length > 1 ? (
<Select
name="radar-send-account"
label={t("radar.today.sendAccount")}
value={sendAccountId}
onChange={(e) => setSendAccountId(e.target.value)}
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
@{a.username}
</option>
))}
</Select>
) : null}
</div> </div>
{exploreOpen ? <ExplorePanel onExplored={() => void load()} /> : null}
{importOpen ? <ManualImportPanel onImported={() => void load()} /> : null}
{err ? ( {err ? (
<p className="hb-banner-error" role="alert"> <p className="hb-banner-error" role="alert">
{err} {err}
@ -432,6 +507,7 @@ export function RadarTodayPage() {
o={o} o={o}
reply={replies[o.id]} reply={replies[o.id]}
busy={busy} busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)} onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)} onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)} onGenerateReply={(v) => void generateReply(o.id, v)}
@ -458,6 +534,7 @@ export function RadarTodayPage() {
o={o} o={o}
reply={replies[o.id]} reply={replies[o.id]}
busy={busy} busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)} onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)} onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)} onGenerateReply={(v) => void generateReply(o.id, v)}
@ -488,6 +565,7 @@ export function RadarTodayPage() {
o={o} o={o}
reply={replies[o.id]} reply={replies[o.id]}
busy={busy} busy={busy}
canSendOutbox={Boolean(sendAccountId)}
onAccept={() => void accept(o.id)} onAccept={() => void accept(o.id)}
onDismiss={() => void dismiss(o.id)} onDismiss={() => void dismiss(o.id)}
onGenerateReply={(v) => void generateReply(o.id, v)} onGenerateReply={(v) => void generateReply(o.id, v)}

View File

@ -8,6 +8,7 @@ import { useRepos } from "../data/DataContext";
import type { RadarWatch, RadarWatchStatus, WatchTermSuggestion } from "../domain/types"; import type { RadarWatch, RadarWatchStatus, WatchTermSuggestion } from "../domain/types";
import { useI18n } from "../i18n/I18nContext"; import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors"; import { useFormatApiError } from "../lib/apiErrors";
import { isThreadsSearchable } from "../lib/threadsTerm";
import { formatLocalDateTime } from "../lib/time"; import { formatLocalDateTime } from "../lib/time";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -256,6 +257,11 @@ export function RadarWatchesPage() {
onChange={(e) => setDraft((d) => ({ ...d, terms: e.target.value }))} onChange={(e) => setDraft((d) => ({ ...d, terms: e.target.value }))}
placeholder={t("radar.watches.termsPh")} placeholder={t("radar.watches.termsPh")}
/> />
{splitTerms(draft.terms).some((term) => !isThreadsSearchable(term)) ? (
<p className="hb-banner-ok" role="status">
{t("radar.watches.threadsWarn")}
</p>
) : null}
<Textarea <Textarea
name="radar-watch-exclude" name="radar-watch-exclude"
label={t("radar.watches.excludeTerms")} label={t("radar.watches.excludeTerms")}

View File

@ -1,15 +1,12 @@
import { useEffect, useEffectEvent, useMemo, useState } from "react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader"; import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Input, Pager, Select, Textarea } from "../components/ui"; import { Badge, Button, Card, EmptyState, Input, Pager, Textarea } from "../components/ui";
import { pageSlice } from "../lib/pagination"; import { pageSlice } from "../lib/pagination";
import { useData, useRepos } from "../data/DataContext"; import { useData, useRepos } from "../data/DataContext";
import type { import type {
Brand,
BrandProduct,
ScoutHomeworkRecord, ScoutHomeworkRecord,
ScoutPost, ScoutPost,
ScoutPurpose,
ScoutRunBrief, ScoutRunBrief,
} from "../domain/types"; } from "../domain/types";
import { newId } from "../lib/id"; import { newId } from "../lib/id";
@ -21,15 +18,12 @@ import { useJobLive } from "../data/JobLiveContext";
import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels"; import { jobStatusLabel, jobStatusTone } from "../lib/jobLabels";
import { formatLocalDateTime } from "../lib/time"; import { formatLocalDateTime } from "../lib/time";
import type { Job } from "../domain/types"; import type { Job } from "../domain/types";
import { checkThreadsTerm, isThreadsSearchable, normalizeSearchTerm } from "../lib/threadsTerm";
function isPending(p: ScoutPost): boolean { function isPending(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted"; return p.outreach_status === "new" || p.outreach_status === "drafted";
} }
function isProviderReady(product: BrandProduct | null): boolean {
return product !== null;
}
function outreachStatusLabel( function outreachStatusLabel(
p: ScoutPost, p: ScoutPost,
t: (k: string, p?: Record<string, string | number>) => string, t: (k: string, p?: Record<string, string | number>) => string,
@ -85,31 +79,15 @@ function MatchQueue({
); );
} }
function stanceOf(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string { /** 每次話題批次的分組 key與 theme_key 對齊) */
if (p.scout_mode === "activity") return t("scout.stanceActivity");
if (p.scout_mode === "demand") return t("scout.stanceDemand");
if (p.scout_mode === "provider") return t("scout.stanceProvider");
if (p.scout_mode === "product" || p.matched_product_label) return t("scout.stanceProduct");
return t("scout.stanceRelation");
}
/** 每次海巡批次的分組 key與 theme_key 對齊) */
function postRunKey(p: ScoutPost): string { function postRunKey(p: ScoutPost): string {
if (p.theme_key) return p.theme_key; if (p.theme_key) return p.theme_key;
if (p.matched_product_id) return `product|${p.matched_product_id}`;
if (p.intent_snippet) return `intent|${p.intent_snippet}`;
if (p.scout_mode === "activity") return `activity|${p.search_tag || "x"}`; if (p.scout_mode === "activity") return `activity|${p.search_tag || "x"}`;
return `tag|${p.search_tag || "other"}`; return `tag|${p.search_tag || "other"}`;
} }
function postRunLabel(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string { function postRunLabel(p: ScoutPost, t: (k: string, p?: Record<string, string | number>) => string): string {
return ( return p.theme_label || p.intent_snippet || p.search_tag || t("scout.unnamedRun");
p.theme_label ||
p.matched_product_label ||
p.intent_snippet ||
p.search_tag ||
t("scout.unnamedRun")
);
} }
function runTimeSuffix(): string { function runTimeSuffix(): string {
@ -134,44 +112,59 @@ function postTimeMs(p: ScoutPost): number {
/** 佇列排序:待回優先,再依發文時間新→舊 */ /** 佇列排序:待回優先,再依發文時間新→舊 */
function compareQueuePosts(a: ScoutPost, b: ScoutPost): number { function compareQueuePosts(a: ScoutPost, b: ScoutPost): number {
const scoreDiff = Number(b.score || 0) - Number(a.score || 0);
if (scoreDiff !== 0) return scoreDiff;
const pendingDiff = Number(isPending(b)) - Number(isPending(a)); const pendingDiff = Number(isPending(b)) - Number(isPending(a));
if (pendingDiff !== 0) return pendingDiff; if (pendingDiff !== 0) return pendingDiff;
return postTimeMs(b) - postTimeMs(a); return postTimeMs(b) - postTimeMs(a);
} }
/** API 異常重複或 URL 追蹤參數變體都只能在佇列出現一次。 */
function dedupeScoutPosts(list: ScoutPost[]): ScoutPost[] {
const seen = new Set<string>();
return list.filter((post) => {
const raw = post.permalink?.trim();
let key = raw ? `url:${raw}` : `id:${post.id}`;
if (raw) {
try {
const url = new URL(raw);
key = `url:${url.hostname.toLowerCase()}${url.pathname.replace(/\/$/, "")}`;
} catch {
// Invalid legacy URLs still have a stable raw-string key.
}
}
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** /**
* *
*
*/ */
export function ScoutPage() { export function ScoutPage() {
const repos = useRepos(); const repos = useRepos();
const { tick } = useData(); const { tick } = useData();
const { t } = useI18n(); const { t } = useI18n();
const { jobs: liveJobs, reload: reloadJobs } = useJobLive(); const { jobs: liveJobs, reload: reloadJobs, revision: jobsRevision } = useJobLive();
const seenJobsRevision = useRef<number | null>(null);
const [brands, setBrands] = useState<Brand[]>([]);
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [posts, setPosts] = useState<ScoutPost[]>([]); const [posts, setPosts] = useState<ScoutPost[]>([]);
const [purpose, setPurpose] = useState<ScoutPurpose>("demand");
const [intent, setIntent] = useState(""); const [intent, setIntent] = useState("");
const [productId, setProductId] = useState("");
const [goal, setGoal] = useState(8); const [goal, setGoal] = useState(8);
const [todayDone, setTodayDone] = useState(0); const [todayDone, setTodayDone] = useState(0);
const [currentId, setCurrentId] = useState<string | null>(null); const [currentId, setCurrentId] = useState<string | null>(null);
const [draftText, setDraftText] = useState(""); const [draftText, setDraftText] = useState("");
const [valueQueuePage, setValueQueuePage] = useState(1);
const [activityQueuePage, setActivityQueuePage] = useState(1); const [activityQueuePage, setActivityQueuePage] = useState(1);
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]); const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
/** 目前檢視的海巡批次(每次按開始 = 一筆) */ /** 目前檢視的話題批次(每次確認掃描 = 一筆) */
const [activeRunKey, setActiveRunKey] = useState<string | null>(null); const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [convAmount, setConvAmount] = useState("");
const [convNote, setConvNote] = useState("");
const [scanJob, setScanJob] = useState<Job | null>(null); const [scanJob, setScanJob] = useState<Job | null>(null);
const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(null); const [scanJobThemeKey, setScanJobThemeKey] = useState<string | null>(null);
const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false); const [crawlerSessionRequired, setCrawlerSessionRequired] = useState(false);
@ -180,30 +173,37 @@ export function ScoutPage() {
/** 關鍵字工坊prepareBrief 後停在這裡,確認才 scan */ /** 關鍵字工坊prepareBrief 後停在這裡,確認才 scan */
const [workshopBrief, setWorkshopBrief] = useState<ScoutRunBrief | null>(null); const [workshopBrief, setWorkshopBrief] = useState<ScoutRunBrief | null>(null);
const [workshopTerms, setWorkshopTerms] = useState<string[]>([]); const [workshopTerms, setWorkshopTerms] = useState<string[]>([]);
/** 勾選要送搜尋的詞(預設只勾主查詢,避免多詞 fan-out 稀釋精準度) */
const [selectedTermKeys, setSelectedTermKeys] = useState<Set<string>>(new Set());
const [newTerm, setNewTerm] = useState(""); const [newTerm, setNewTerm] = useState("");
const [termDraftErr, setTermDraftErr] = useState("");
const refreshPostsOnJobRevision = useEffectEvent(async () => {
try {
setPosts(dedupeScoutPosts(await repos.scout.listPosts()));
} catch {
// Keep the existing list visible when a background refresh fails.
}
});
useEffect(() => { useEffect(() => {
const t = loadScoutToday(); const today = loadScoutToday();
setTodayDone(t.done); setTodayDone(today.done);
setGoal(purpose === "activity" ? t.goalActivity : t.goalValue); setGoal(today.goalActivity || 8);
}, [purpose]); }, []);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
try { try {
const [b, hw, prods, postList] = await Promise.all([ const [hw, postList] = await Promise.all([
repos.scout.listBrands(),
repos.scout.listHomework(), repos.scout.listHomework(),
repos.scout.listAllProducts(),
repos.scout.listPosts(), repos.scout.listPosts(),
]); ]);
setBrands(b); const uniquePosts = dedupeScoutPosts(postList);
setHomeworkList(hw); setHomeworkList(hw);
setAllProducts(prods); setPosts(uniquePosts);
setProductId((current) => current || prods[0]?.id || "");
setPosts(postList);
const pending = postList.filter(isPending).sort(compareQueuePosts); const pending = uniquePosts.filter((p) => p.scout_mode === "activity" && isPending(p)).sort(compareQueuePosts);
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!)); if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
setLoadError(""); setLoadError("");
} catch (e) { } catch (e) {
@ -212,20 +212,17 @@ export function ScoutPage() {
})(); })();
}, [repos, t, tick]); }, [repos, t, tick]);
const productOptions = useMemo(() => { // Jobs may finish after navigating away from Scout. The shared revision changes
return allProducts.map((p) => { // when an active job is removed, so refresh posts without requiring a page reload.
const brand = brands.find((b) => b.id === p.brand_id); useEffect(() => {
return { if (seenJobsRevision.current === null) {
id: p.id, seenJobsRevision.current = jobsRevision;
label: `${brand?.display_name || t("scout.brandFallback")} · ${p.label}`, return;
}; }
}); if (seenJobsRevision.current === jobsRevision) return;
}, [allProducts, brands, t]); seenJobsRevision.current = jobsRevision;
void refreshPostsOnJobRevision();
const selectedProduct = useMemo( }, [jobsRevision]);
() => allProducts.find((p) => p.id === productId) || null,
[allProducts, productId],
);
/** 每次搜尋一筆:依 posts 出現順序(新掃在前) */ /** 每次搜尋一筆:依 posts 出現順序(新掃在前) */
const runGroups = useMemo(() => { const runGroups = useMemo(() => {
@ -279,9 +276,6 @@ export function ScoutPage() {
.sort(compareQueuePosts); .sort(compareQueuePosts);
}, [posts, activeRunKey]); }, [posts, activeRunKey]);
const pendingQueue = useMemo(() => runQueue.filter(isPending), [runQueue]);
const demandQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "demand"), [runQueue]);
const providerQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "provider"), [runQueue]);
const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]); const activityQueue = useMemo(() => runQueue.filter((p) => p.scout_mode === "activity"), [runQueue]);
const current = useMemo( const current = useMemo(
@ -293,6 +287,9 @@ export function ScoutPage() {
() => (scanJob ? liveJobs.find((job) => job.id === scanJob.id) || scanJob : null), () => (scanJob ? liveJobs.find((job) => job.id === scanJob.id) || scanJob : null),
[liveJobs, scanJob], [liveJobs, scanJob],
); );
const scanInProgress = Boolean(
visibleScanJob && !["succeeded", "failed", "cancelled"].includes(visibleScanJob.status),
);
const pollScanJob = useEffectEvent(async (jobId: string, cancelled: () => boolean) => { const pollScanJob = useEffectEvent(async (jobId: string, cancelled: () => boolean) => {
try { try {
@ -307,14 +304,13 @@ export function ScoutPage() {
: job, : job,
); );
if (job.status === "succeeded") { if (job.status === "succeeded") {
const list = await repos.scout.listPosts(); const list = dedupeScoutPosts(await repos.scout.listPosts());
if (cancelled()) return; if (cancelled()) return;
setPosts(list); setPosts(list);
const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey; const completedThemeKey = scanJobThemeKey || job.ref_id || activeRunKey;
// 掃完立刻切到這一批並選第一則,避免還停在舊批次像「沒結果」要手動重整 // 掃完立刻切到這一批並選第一則,避免還停在舊批次像「沒結果」要手動重整
if (completedThemeKey) { if (completedThemeKey) {
setActiveRunKey(completedThemeKey); setActiveRunKey(completedThemeKey);
setValueQueuePage(1);
setActivityQueuePage(1); setActivityQueuePage(1);
} }
const pending = list const pending = list
@ -389,7 +385,6 @@ export function ScoutPage() {
function selectRun(key: string) { function selectRun(key: string) {
setActiveRunKey(key); setActiveRunKey(key);
setValueQueuePage(1);
setActivityQueuePage(1); setActivityQueuePage(1);
const pending = posts const pending = posts
.filter((p) => postRunKey(p) === key && isPending(p)) .filter((p) => postRunKey(p) === key && isPending(p))
@ -428,51 +423,56 @@ export function ScoutPage() {
function setGoalPersist(n: number) { function setGoalPersist(n: number) {
const g = Math.max(1, Math.min(99, n)); const g = Math.max(1, Math.min(99, n));
setGoal(g); setGoal(g);
const t = loadScoutToday(); const today = loadScoutToday();
if (purpose === "activity") { saveScoutToday({ ...today, goalActivity: g });
saveScoutToday({ ...t, goalActivity: g });
} else {
saveScoutToday({ ...t, goalValue: g });
}
} }
/** ① 產出可審關鍵字(不搜尋) */ /** ① 產出可審關鍵字(不搜尋) */
async function planKeywords() { async function planKeywords() {
const text = intent.trim(); const text = intent.trim();
if (purpose === "activity" && !text) { if (!text) {
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); setMessage(t("scout.needKeyword"));
return; return;
} }
setBusy("plan"); setBusy("plan");
setMessage(""); setMessage("");
setCrawlerSessionRequired(false); setCrawlerSessionRequired(false);
try { try {
const freshProducts = await repos.scout.listAllProducts();
setAllProducts(freshProducts);
const selectedProductID = productId || freshProducts[0]?.id || "";
if (purpose !== "activity" && selectedProductID) {
setProductId(selectedProductID);
}
const selected =
purpose === "activity" ? null : freshProducts.find((p) => p.id === selectedProductID) || null;
if (purpose !== "activity" && !selected) {
throw new Error(t("scout.productMissing"));
}
if (purpose === "provider" && !isProviderReady(selected)) {
throw new Error(t("scout.providerSetupRequired"));
}
const brief = await repos.scout.prepareBrief({ const brief = await repos.scout.prepareBrief({
intent: text, intent: text,
brandId: purpose === "activity" ? null : selected?.brand_id || null, brandId: null,
productId: purpose === "activity" ? null : selected?.id || null, productId: null,
purpose, purpose: "activity",
deep: false, // 後端會產規則變體 +(有 Key 時AI 擴充短詞
deep: true,
}); });
const terms = (brief.scan_terms || []).map((s) => s.trim()).filter(Boolean); const terms = (brief.scan_terms || [])
.map((s) => normalizeSearchTerm(s))
.filter(Boolean);
const short = terms.filter((x) => isThreadsSearchable(x));
const all = short.length ? short : terms.length ? terms : [];
if (!all.length) {
setMessage(t("scout.topic.noTerms"));
setWorkshopBrief(null);
setWorkshopTerms([]);
return;
}
// 精準優先:預設只勾「最接近你原意」的 1 組主查詢,其餘變體可手動加回。
// 避免 68 組 fan-out 把 Threads 相關性稀釋成雜訊池。
const primary =
(isThreadsSearchable(normalizeSearchTerm(text))
? normalizeSearchTerm(text)
: null) ||
all[0]!;
const primaryNorm = normalizeSearchTerm(primary);
const rest = all.filter((x) => x.toLowerCase() !== primaryNorm.toLowerCase());
// 工坊列表主查詢在前變體接後預設只送主查詢confirm 時再允許使用者加)
setWorkshopBrief(brief); setWorkshopBrief(brief);
setWorkshopTerms(terms.length ? terms : [text]); setWorkshopTerms([primaryNorm, ...rest]);
setSelectedTermKeys(new Set([primaryNorm.toLowerCase()]));
setNewTerm(""); setNewTerm("");
setMessage(t("scout.termsReady", { n: terms.length || 1 })); setTermDraftErr("");
setMessage(t("scout.topic.termsReadyPrimary", { n: rest.length }));
} catch (e) { } catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.patrolFail")); setMessage(e instanceof Error ? e.message : t("scout.patrolFail"));
} finally { } finally {
@ -481,52 +481,103 @@ export function ScoutPage() {
} }
function removeWorkshopTerm(idx: number) { function removeWorkshopTerm(idx: number) {
setWorkshopTerms((prev) => prev.filter((_, i) => i !== idx)); setWorkshopTerms((prev) => {
const removed = prev[idx];
const next = prev.filter((_, i) => i !== idx);
if (removed) {
const k = removed.toLowerCase();
setSelectedTermKeys((sel) => {
const n = new Set(sel);
n.delete(k);
return n;
});
}
return next;
});
}
function toggleTermSelected(term: string) {
const k = normalizeSearchTerm(term).toLowerCase();
setSelectedTermKeys((prev) => {
const n = new Set(prev);
if (n.has(k)) n.delete(k);
else n.add(k);
return n;
});
} }
function addWorkshopTerm() { function addWorkshopTerm() {
const term = newTerm.trim(); const term = normalizeSearchTerm(newTerm);
if (!term) return; const check = checkThreadsTerm(term);
if (!check.ok) {
setTermDraftErr(t(`radar.explore.termError.${check.reason}`));
return;
}
setWorkshopTerms((prev) => { setWorkshopTerms((prev) => {
if (prev.some((p) => p.toLowerCase() === term.toLowerCase())) return prev; if (prev.some((p) => p.toLowerCase() === term.toLowerCase())) return prev;
return [...prev, term]; return [...prev, term];
}); });
setSelectedTermKeys((prev) => new Set(prev).add(term.toLowerCase()));
setNewTerm(""); setNewTerm("");
setTermDraftErr("");
} }
function updateWorkshopTerm(idx: number, value: string) { function updateWorkshopTerm(idx: number, value: string) {
setWorkshopTerms((prev) => prev.map((t, i) => (i === idx ? value : t))); setWorkshopTerms((prev) => {
const old = prev[idx];
const next = prev.map((x, i) => (i === idx ? value : x));
if (old) {
const ok = old.toLowerCase();
const nk = normalizeSearchTerm(value).toLowerCase();
setSelectedTermKeys((sel) => {
const n = new Set(sel);
if (n.has(ok)) {
n.delete(ok);
if (nk) n.add(nk);
}
return n;
});
}
return next;
});
} }
function clearWorkshop() { function clearWorkshop() {
setWorkshopBrief(null); setWorkshopBrief(null);
setWorkshopTerms([]); setWorkshopTerms([]);
setSelectedTermKeys(new Set());
setNewTerm(""); setNewTerm("");
setTermDraftErr("");
} }
/** ② 確認關鍵字後才 enqueue scan */ /** ② 確認關鍵字後才 enqueue scan(帶今日目標 target_count 補抓) */
async function confirmScan() { async function confirmScan() {
if (scanInProgress) return;
const text = intent.trim(); const text = intent.trim();
if (purpose === "activity" && !text) { if (!text) {
setMessage(purpose === "activity" ? t("scout.needKeyword") : t("scout.needIntent")); setMessage(t("scout.needKeyword"));
return; return;
} }
if (purpose !== "activity" && !productId) { // 只送勾選詞;若都沒勾,退回第一組
setMessage(t("scout.productMissing")); let terms = workshopTerms
return; .map((s) => normalizeSearchTerm(s))
.filter((s) => s && selectedTermKeys.has(s.toLowerCase()));
if (!terms.length && workshopTerms[0]) {
terms = [normalizeSearchTerm(workshopTerms[0])].filter(Boolean);
} }
if ( // 精準:一次最多 3 組 fan-out對齊「你在 Threads 打少數詞」
purpose === "provider" && if (terms.length > 3) {
!isProviderReady(allProducts.find((p) => p.id === productId) || null) terms = terms.slice(0, 3);
) {
setMessage(t("scout.providerSetupRequired"));
return;
} }
const terms = workshopTerms.map((s) => s.trim()).filter(Boolean);
if (!terms.length) { if (!terms.length) {
setMessage(t("scout.workshopEmpty")); setMessage(t("scout.workshopEmpty"));
return; return;
} }
const bad = terms.filter((x) => !isThreadsSearchable(x));
if (bad.length) {
setMessage(t("scout.topic.termsNeedShort", { n: bad.length }));
return;
}
setBusy("run"); setBusy("run");
setMessage(""); setMessage("");
setCrawlerSessionRequired(false); setCrawlerSessionRequired(false);
@ -535,38 +586,36 @@ export function ScoutPage() {
workshopBrief || workshopBrief ||
(await repos.scout.prepareBrief({ (await repos.scout.prepareBrief({
intent: text, intent: text,
brandId: brandId: null,
purpose === "activity" productId: null,
? null purpose: "activity",
: allProducts.find((p) => p.id === productId)?.brand_id || null,
productId: purpose === "activity" ? null : productId || null,
purpose,
deep: false, deep: false,
})); }));
const baseLabel = const baseLabel =
base.theme_label || base.product_label || base.intent.slice(0, 36) || t("scout.defaultLabel"); base.theme_label || base.intent.slice(0, 36) || t("scout.defaultLabel");
const theme_key = newId("run"); const theme_key = newId("run");
const theme_label = `${baseLabel} · ${runTimeSuffix()}`; const theme_label = `${baseLabel} · ${runTimeSuffix()}`;
const remaining = Math.max(1, goal - todayDone);
const briefSaved: ScoutRunBrief = { const briefSaved: ScoutRunBrief = {
...base, ...base,
intent: text, intent: text,
scan_terms: terms, scan_terms: terms,
theme_key, theme_key,
theme_label, theme_label,
target_count: remaining,
}; };
const { job } = await repos.scout.runScanFromBrief(briefSaved); const { job } = await repos.scout.runScanFromBrief(briefSaved);
await repos.scout.saveHomework({ await repos.scout.saveHomework({
theme_key, theme_key,
theme_label, theme_label,
purpose, purpose: "activity",
brief: briefSaved, brief: briefSaved,
created_at: nowUnixNano(), created_at: nowUnixNano(),
}); });
setHomeworkList(await repos.scout.listHomework()); setHomeworkList(await repos.scout.listHomework());
setActiveRunKey(theme_key); setActiveRunKey(theme_key);
setCurrentId(null); setCurrentId(null);
setValueQueuePage(1);
setActivityQueuePage(1); setActivityQueuePage(1);
setScanJob(job); setScanJob(job);
setScanJobThemeKey(theme_key); setScanJobThemeKey(theme_key);
@ -583,7 +632,7 @@ export function ScoutPage() {
} }
async function reloadPosts(): Promise<ScoutPost[]> { async function reloadPosts(): Promise<ScoutPost[]> {
const list = await repos.scout.listPosts(); const list = dedupeScoutPosts(await repos.scout.listPosts());
setPosts(list); setPosts(list);
return list; return list;
} }
@ -657,59 +706,6 @@ export function ScoutPage() {
} }
} }
async function promoteCurrent() {
if (!current) return;
setBusy("promote");
setMessage("");
try {
const res = await repos.scout.promoteToOpportunity(current.id);
setMessage(
t("scout.promoted", {
band: res.intent_band || "—",
score: res.intent_score ?? 0,
}),
);
} catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.promoteFail"));
} finally {
setBusy("");
}
}
async function reportConversionForCurrent() {
if (!current || current.outreach_status !== "published") return;
const amountRaw = window.prompt("成交金額(可留空)", convAmount);
if (amountRaw === null) return;
const note = window.prompt("備註(可留空)", convNote) ?? "";
const amount = Number(amountRaw) || 0;
setBusy("conversion");
setMessage("");
try {
// find outcome by listing recent and matching source
const outcomes = await repos.growth.listOutcomes(1, 50);
const hit =
outcomes.find(
(o) => o.source_type === "scout_outreach" && o.source_id === current.id,
) ?? null;
if (!hit) {
setMessage("尚無歸因紀錄(請稍後再試,或先完成標記)");
return;
}
await repos.growth.reportConversion(hit.id, amount, note, "TWD");
setConvAmount(String(amount || ""));
setConvNote(note);
setMessage(
amount > 0
? `已回報成交 $${Math.round(amount)}`
: "已回報成交(無金額)",
);
} catch (e) {
setMessage(e instanceof Error ? e.message : "回報成交失敗");
} finally {
setBusy("");
}
}
async function removeCurrent() { async function removeCurrent() {
if (!current) return; if (!current) return;
if (!window.confirm(t("scout.confirmDeletePost"))) return; if (!window.confirm(t("scout.confirmDeletePost"))) return;
@ -747,48 +743,12 @@ export function ScoutPage() {
</p> </p>
) : null} ) : null}
{/* ① 今日設定 */} {/* ① 今日設定 — 僅話題靈感activity */}
<Card title={t("scout.today")}> <Card title={t("scout.today")}>
<div className="hb-stack"> <div className="hb-stack">
<div className="hb-tabs hb-tabs--sm" role="tablist"> <p className="hb-radar-section__hint" style={{ margin: 0 }}>
<button {t("scout.topic.intro")}
type="button" </p>
className={`hb-tab ${purpose === "demand" ? "is-active" : ""}`}
onClick={() => {
setPurpose("demand");
setProductId((current) => current || allProducts[0]?.id || "");
clearWorkshop();
const t = loadScoutToday();
setGoal(t.goalValue);
}}
>
{t("scout.purposeDemand")}
</button>
<button
type="button"
className={`hb-tab ${purpose === "provider" ? "is-active" : ""}`}
onClick={() => {
setPurpose("provider");
setProductId((current) => current || allProducts[0]?.id || "");
clearWorkshop();
}}
>
{t("scout.purposeProvider")}
</button>
<button
type="button"
className={`hb-tab ${purpose === "activity" ? "is-active" : ""}`}
onClick={() => {
setPurpose("activity");
setProductId("");
clearWorkshop();
const t = loadScoutToday();
setGoal(t.goalActivity);
}}
>
{t("scout.purposeActivity")}
</button>
</div>
<div className="hb-scout-today-row"> <div className="hb-scout-today-row">
<Input <Input
@ -809,7 +769,6 @@ export function ScoutPage() {
</div> </div>
</div> </div>
{purpose === "activity" ? (
<Textarea <Textarea
label={t("scout.keyword")} label={t("scout.keyword")}
value={intent} value={intent}
@ -817,59 +776,17 @@ export function ScoutPage() {
rows={2} rows={2}
placeholder={t("scout.keywordPh")} placeholder={t("scout.keywordPh")}
/> />
) : null}
{purpose !== "activity" ? (
<>
<Select
label={t("scout.productRequired")}
value={
productId && productOptions.some((o) => o.id === productId) ? productId : ""
}
onChange={(e) => setProductId(e.target.value)}
>
<option value="">{t("scout.selectProduct")}</option>
{productOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</Select>
{selectedProduct ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("scout.providerProduct", { label: selectedProduct.label })}
{selectedProduct.pain_points?.[0]
? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
: ""}
</p>
) : null}
{purpose === "provider" && selectedProduct && !isProviderReady(selectedProduct) ? (
<p className="hb-banner-ok" role="alert">
{t("scout.providerSetupRequired")} <Link to="/app/brands">{t("nav.brands")}</Link>
</p>
) : null}
{allProducts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{t("scout.noProductsBefore")}{" "}
<Link to="/app/brands">{t("nav.brands")}</Link>{" "}
{t("scout.noProductsAfter")}
</p>
) : null}
</>
) : null}
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
<Button <Button
type="button" type="button"
onClick={() => void planKeywords()} onClick={() => void planKeywords()}
disabled={busy === "plan" || busy === "run" || (purpose === "activity" ? !intent.trim() : !productId)} disabled={busy === "plan" || busy === "run" || !intent.trim()}
> >
{busy === "plan" {busy === "plan"
? t("scout.planning") ? t("scout.planning")
: workshopBrief : workshopBrief
? t("scout.replan") ? t("scout.replan")
: pendingQueue.length
? t("scout.planKeywords")
: t("scout.planKeywords")} : t("scout.planKeywords")}
</Button> </Button>
</div> </div>
@ -880,17 +797,42 @@ export function ScoutPage() {
<Card title={t("scout.workshop")}> <Card title={t("scout.workshop")}>
<div className="hb-stack"> <div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}> <p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("scout.workshopHint")} {t("scout.topic.workshopHintSelect")}
</p> </p>
<div className="hb-stack" style={{ gap: "0.5rem" }}> <div className="hb-stack" style={{ gap: "0.5rem" }}>
{workshopTerms.map((term, idx) => ( {workshopTerms.map((term, idx) => {
const key = normalizeSearchTerm(term).toLowerCase();
const checked = selectedTermKeys.has(key);
const isPrimary = idx === 0;
return (
<div <div
key={`term-${idx}`} key={`term-${idx}`}
style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }} style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }}
> >
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.35rem",
paddingBottom: "0.45rem",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleTermSelected(term)}
disabled={busy === "run"}
aria-label={t("scout.topic.useTerm")}
/>
</label>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Input <Input
label={`${idx + 1}`} label={
isPrimary
? t("scout.topic.primaryTerm")
: t("scout.topic.variantTerm", { n: idx })
}
value={term} value={term}
onChange={(e) => updateWorkshopTerm(idx, e.target.value)} onChange={(e) => updateWorkshopTerm(idx, e.target.value)}
aria-label={`${t("scout.workshop")} ${idx + 1}`} aria-label={`${t("scout.workshop")} ${idx + 1}`}
@ -905,14 +847,18 @@ export function ScoutPage() {
{t("scout.removeTerm")} {t("scout.removeTerm")}
</Button> </Button>
</div> </div>
))} );
})}
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }}> <div style={{ display: "flex", gap: "0.5rem", alignItems: "flex-end" }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Input <Input
label={t("scout.addTermPh")} label={t("scout.addTermPh")}
value={newTerm} value={newTerm}
onChange={(e) => setNewTerm(e.target.value)} onChange={(e) => {
setNewTerm(e.target.value);
setTermDraftErr("");
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@ -925,6 +871,15 @@ export function ScoutPage() {
{t("scout.addTerm")} {t("scout.addTerm")}
</Button> </Button>
</div> </div>
{termDraftErr ? (
<p className="hb-banner-ok" role="status">
{termDraftErr}
</p>
) : (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("scout.topic.termHint")}
</p>
)}
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
<Button <Button
type="button" type="button"
@ -932,6 +887,7 @@ export function ScoutPage() {
disabled={ disabled={
busy === "run" || busy === "run" ||
busy === "plan" || busy === "plan" ||
scanInProgress ||
workshopTerms.map((s) => s.trim()).filter(Boolean).length === 0 workshopTerms.map((s) => s.trim()).filter(Boolean).length === 0
} }
> >
@ -1017,8 +973,7 @@ export function ScoutPage() {
) : null} ) : null}
</div> </div>
<p className="hb-scout-now__stance text-muted"> <p className="hb-scout-now__stance text-muted">
{stanceOf(current, t)} {t("scout.stanceActivity")}
{current.matched_product_label ? ` · ${current.matched_product_label}` : ""}
{activeRunMeta ? ` · ${shortRunLabel(activeRunMeta.label, 18)}` : ""} {activeRunMeta ? ` · ${shortRunLabel(activeRunMeta.label, 18)}` : ""}
</p> </p>
</div> </div>
@ -1039,33 +994,20 @@ export function ScoutPage() {
{current.match_reason} {current.match_reason}
</p> </p>
) : null} ) : null}
{current.opportunity ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)", margin: 0 }}>
{current.opportunity}
</p>
) : null}
{current.scout_mode !== "provider" ? <Textarea <Textarea
label={t("scout.draft")} label={t("scout.draft")}
value={draftText} value={draftText}
onChange={(e) => setDraftText(e.target.value)} onChange={(e) => setDraftText(e.target.value)}
rows={current.scout_mode === "activity" ? 3 : 5} rows={3}
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")} placeholder={t("scout.draftPhActivity")}
/> : null} />
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}> <p className="text-muted" style={{ fontSize: "var(--hb-text-sm)", margin: 0 }}>
{current.scout_mode === "provider" ? t("scout.providerHint") : current.scout_mode === "demand" ? t("scout.demandHint") : t("scout.manualReplyHint")} {t("scout.manualReplyHint")}
</p> </p>
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
{current.scout_mode !== "activity" && current.scout_mode !== "provider" ? <Button
type="button"
variant="secondary"
disabled={Boolean(busy)}
onClick={() => void promoteCurrent()}
>
{busy === "promote" ? "…" : t("scout.promote")}
</Button> : null}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -1074,39 +1016,29 @@ export function ScoutPage() {
> >
{busy === "skip" ? "…" : t("scout.skip")} {busy === "skip" ? "…" : t("scout.skip")}
</Button> </Button>
{current.scout_mode !== "provider" ? <Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
disabled={Boolean(busy) || !isPending(current)} disabled={Boolean(busy) || !isPending(current)}
onClick={() => void regenDraft()} onClick={() => void regenDraft()}
> >
{busy === "draft" ? "…" : t("scout.regen")} {busy === "draft" ? "…" : t("scout.regen")}
</Button> : null} </Button>
<Button <Button
type="button" type="button"
disabled={Boolean(busy) || !allowHttpUrl(current.permalink)} disabled={Boolean(busy) || !allowHttpUrl(current.permalink)}
onClick={openThreadsReply} onClick={openThreadsReply}
> >
{current.scout_mode === "provider" ? t("scout.openPermalink") : t("scout.openThreadsReply")} {t("scout.openThreadsReply")}
</Button> </Button>
{current.scout_mode !== "provider" ? <Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
disabled={Boolean(busy) || !isPending(current)} disabled={Boolean(busy) || !isPending(current)}
onClick={() => void markManualDone()} onClick={() => void markManualDone()}
> >
{busy === "manual-done" ? "…" : t("scout.markManualDone")} {busy === "manual-done" ? "…" : t("scout.markManualDone")}
</Button> : null}
{current.scout_mode !== "provider" && current.outreach_status === "published" ? (
<Button
type="button"
variant="secondary"
disabled={Boolean(busy)}
onClick={() => void reportConversionForCurrent()}
>
{busy === "conversion" ? "…" : "回報成交"}
</Button> </Button>
) : null}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -1121,25 +1053,7 @@ export function ScoutPage() {
</Card> </Card>
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */} {/* ⑤ 話題佇列(需求/解法媒合佇列已併入商機) */}
<MatchQueue
title={t("scout.demandQueue", { n: demandQueue.length })}
posts={demandQueue}
page={valueQueuePage}
onPageChange={setValueQueuePage}
currentId={currentId}
onSelect={setCurrentId}
t={t}
/>
<MatchQueue
title={t("scout.providerQueue", { n: providerQueue.length })}
posts={providerQueue}
page={valueQueuePage}
onPageChange={setValueQueuePage}
currentId={currentId}
onSelect={setCurrentId}
t={t}
/>
<MatchQueue <MatchQueue
title={t("scout.activityQueue", { n: activityQueue.length })} title={t("scout.activityQueue", { n: activityQueue.length })}
posts={activityQueue} posts={activityQueue}

View File

@ -574,3 +574,117 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* ---------- 手動匯入P1spec §4.11 ---------- */
.hb-radar-import {
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface);
}
.hb-radar-import__csv {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
}
.hb-radar-import__row {
display: grid;
grid-template-columns: minmax(11rem, 1.4fr) minmax(14rem, 2fr) minmax(8rem, 1fr) auto;
align-items: end;
gap: var(--hb-space-3);
padding: var(--hb-space-3) 0;
border-top: 1px solid var(--hb-line);
}
.hb-radar-import__row:first-of-type {
border-top: none;
}
.hb-radar-import__results {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
list-style: none;
margin: 0;
padding: 0;
}
.hb-radar-import__result {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--hb-space-2);
padding: var(--hb-space-2) var(--hb-space-3);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius);
background: var(--hb-surface-muted);
}
.hb-radar-import__resultUrl {
font-size: var(--hb-text-sm);
color: var(--hb-ink-secondary);
word-break: break-all;
}
/* ---------- 立即探索面板 ---------- */
.hb-radar-explore {
padding: var(--hb-space-4);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius);
background: var(--hb-surface);
}
.hb-radar-explore__suggest {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
}
.hb-radar-explore__chips {
display: flex;
flex-wrap: wrap;
gap: var(--hb-space-2);
min-height: var(--hb-space-8);
}
.hb-radar-explore__chip {
display: inline-flex;
align-items: center;
gap: var(--hb-space-1);
border: none;
background: transparent;
cursor: pointer;
padding: 0;
color: inherit;
font: inherit;
}
.hb-radar-explore__chip:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.hb-radar-explore__chip-x {
font-size: var(--hb-text-sm);
color: var(--hb-muted);
}
.hb-radar-explore__add {
display: grid;
grid-template-columns: minmax(12rem, 1fr) auto;
gap: var(--hb-space-3);
align-items: end;
}
.hb-radar-explore__result {
padding: var(--hb-space-3);
border-radius: var(--hb-radius);
background: var(--hb-surface-muted);
border: 1px solid var(--hb-line);
font-size: var(--hb-text-sm);
color: var(--hb-ink);
}

View File

@ -3,7 +3,7 @@
> Status: `approved` > Status: `approved`
> Status note: 使用者 2026-07-31「好」spec 通過) > Status note: 使用者 2026-07-31「好」spec 通過)
> Source: `docs/product/demand-radar/requirements.md`**approved** 2026-07-31 > Source: `docs/product/demand-radar/requirements.md`**approved** 2026-07-31
> Last updated: `2026-07-31` > Last updated: `2026-08-07`
> 底座:`docs/product/haixun-backend/spec.md`海巡雙路徑、Outbox、用量 BYOK、`docs/product/growth-loop/spec.md`OutcomeEvent 歸因、AccountHealth 送出閘);本 run 為**加值層** > 底座:`docs/product/haixun-backend/spec.md`海巡雙路徑、Outbox、用量 BYOK、`docs/product/growth-loop/spec.md`OutcomeEvent 歸因、AccountHealth 送出閘);本 run 為**加值層**
> 決策對齊requirements §6 命名決策、§7 決策 #1#12 > 決策對齊requirements §6 命名決策、§7 決策 #1#12
@ -187,10 +187,18 @@ notified --> escalated : 已通知達上限次數 → 建議轉 lost僅建
### 4.10 既有海巡命中升級為商機P0 ### 4.10 既有海巡命中升級為商機P0
1. 既有海巡命中列表新增動作「升級為商機」。 1. 既有海巡命中列表新增動作「升級為商機」(後端 API 保留;前端需求/解法媒合 UI 併入商機後,話題頁可不顯示此鈕)
2. **決策:複製,不共用**requirements 開放問題 #5)。建立新的 Opportunity 並記 `source_scout_post_id` 反向連結,兩邊狀態機互不影響,避免 `outreach_status` 與 Opportunity 狀態互相污染。 2. **決策:複製,不共用**requirements 開放問題 #5)。建立新的 Opportunity 並記 `source_scout_post_id` 反向連結,兩邊狀態機互不影響,避免 `outreach_status` 與 Opportunity 狀態互相污染。
3. 升級後跑同一套五問判定,進同一條 CRM。 3. 升級後跑同一套五問判定,進同一條 CRM。
4. 既有海巡三模式(`product``theme``activity`)與品牌/產品模型**原樣保留**,不修改其行為。 4. 前端 **activity話題** 模式保留為 `/app/scout` 話題靈感;需求/解法媒合找客戶改走商機頁「立即探索」或每日訂閱巡。後端 scout 三模式與 BrandProduct 基礎設施仍保留。
### 4.12 立即探索與 Threads 短詞2026-08-07
1. **單一找需求管線:** 每日 watch 巡、立即探索、手動匯入皆經同一套五問判定進今日商機。
2. **`POST /api/v1/radar/explore`** req `{ terms: string[] }`16 組);每組必須通過 Threads 短詞規則,不合規整組擋下不默默修正。同步執行:逐 term fan-out 搜尋(去重)→ 分類 → `ProcessCandidates`(每日配額、去重)→ 寫 `RadarSweep``watch_id` 可空)→ 回 `hit_count``judged_count``created_count``truncated_count``credits_used`。
3. **Threads 短詞規則:** 一組 ≤2 token半形空格、中文每詞 24 字、整組去掉空格 ≤12 字禁標點booleanemoji#。建議關鍵字、explore、話題工坊共用此規則訂閱表單對不合規詞顯示警示不硬擋既有資料
4. **每日巡 fan-out** `SearchHitsOnly` 逐 term 搜尋再去重,禁止把整組 terms join 成一條 query稀釋召回
5. **話題今日目標補抓:** 話題頁(`/app/scout`)設定今日目標後,掃描 brief 帶 `target_count`(剩餘則數,上限 40。`RunScanFromBrief`:主路徑 fan-out 後若 hit < target先同路徑加碼再搜若主路徑為 crawler 仍不足再以 search providerExa domain-restricted只補缺口並 dedupe不湊數無限重試仍不足則以實際命中數入庫並讓 UI 顯示
### 4.11 P1P2 邊界 ### 4.11 P1P2 邊界
@ -233,6 +241,7 @@ notified --> escalated : 已通知達上限次數 → 建議轉 lost僅建
| `radar` | POST | `/api/v1/radar/opportunities/:id/replies` | 生成指定 variant 回覆 | | `radar` | POST | `/api/v1/radar/opportunities/:id/replies` | 生成指定 variant 回覆 |
| `radar` | GET | `/api/v1/radar/sweeps` | 巡的執行紀錄(可觀測性) | | `radar` | GET | `/api/v1/radar/sweeps` | 巡的執行紀錄(可觀測性) |
| `radar` | POST | `/api/v1/radar/import` | **P1** 手動匯入網址CSV | | `radar` | POST | `/api/v1/radar/import` | **P1** 手動匯入網址CSV |
| `radar` | POST | `/api/v1/radar/explore` | **立即探索**:短詞 fan-out 搜尋 → 同一套五問判定 → 寫入今日商機(可選無 watch |
| `crm` | GET | `/api/v1/crm/contacts` | 名單列表filterstagefollow_upband排序 | | `crm` | GET | `/api/v1/crm/contacts` | 名單列表filterstagefollow_upband排序 |
| `crm` | GET | `/api/v1/crm/contacts/:id` | 詳情+時間軸 | | `crm` | GET | `/api/v1/crm/contacts/:id` | 詳情+時間軸 |
| `crm` | POST | `/api/v1/crm/contacts/:id/stage` | 推進/回退階段 | | `crm` | POST | `/api/v1/crm/contacts/:id/stage` | 推進/回退階段 |
@ -251,7 +260,9 @@ notified --> escalated : 已通知達上限次數 → 建議轉 lost僅建
| 路由 | 變更 | | 路由 | 變更 |
|------|------| |------|------|
| `/app/radar` | **新增** 今日商機:統計列+卡片+分級分組+空狀態原因 | | `/app/radar` | **新增** 今日商機:統計列+卡片+分級分組+空狀態原因 |
| `/app/radar/today` | 今日商機:統計+卡片;工具列含**立即探索**、手動匯入、訂閱管理、名單 |
| `/app/radar/watches` | **新增** 雷達訂閱管理+關鍵字建議 | | `/app/radar/watches` | **新增** 雷達訂閱管理+關鍵字建議 |
| `/app/scout` | 話題靈感activity only找需求請用商機頁 |
| `/app/crm` | **新增** 名單:八格視圖(七階段+待追蹤)、篩選排序 | | `/app/crm` | **新增** 名單:八格視圖(七階段+待追蹤)、篩選排序 |
| `/app/crm/:contactId` | **新增** 聯絡人詳情+時間軸+成交回報 | | `/app/crm/:contactId` | **新增** 聯絡人詳情+時間軸+成交回報 |
| `/app/crm/stats` | **新增** 轉換統計(或內嵌 `/app/crm` 分頁) | | `/app/crm/stats` | **新增** 轉換統計(或內嵌 `/app/crm` 分頁) |