diff --git a/apps/backend/crawler/README.md b/apps/backend/crawler/README.md
index d2790db..35cf6f0 100644
--- a/apps/backend/crawler/README.md
+++ b/apps/backend/crawler/README.md
@@ -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=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 `.
+- **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 > `SCOUT_MAX_AGE_DAYS` (default **180**). Response: `track`, `serp_rank`, `published_at`.
## Operations
diff --git a/apps/backend/crawler/src/server.ts b/apps/backend/crawler/src/server.ts
index 30de428..c34f906 100644
--- a/apps/backend/crawler/src/server.ts
+++ b/apps/backend/crawler/src/server.ts
@@ -1,13 +1,29 @@
-import { chromium, type Page } from "playwright";
+import { chromium, type BrowserContext, type Page } from "playwright";
import { createServer } from "node:http";
type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
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 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 {
try {
const host = new URL(value).hostname.toLowerCase();
@@ -17,42 +33,261 @@ function isThreadsURL(value: string): boolean {
}
}
-async function readPosts(page: Page, limit: number): Promise {
- const posts = new Map();
- const links = page.locator('a[href*="/post/"]');
- const count = Math.min(await links.count(), 50);
- for (let i = 0; i < count && posts.size < limit; i++) {
- const link = links.nth(i);
- const href = await link.getAttribute("href").catch(() => null);
- if (!href) continue;
- const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`;
- if (!isThreadsURL(permalink)) continue;
- 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) });
+function normalizePermalink(href: string): string {
+ const absolute = href.startsWith("http") ? href : `https://www.threads.com${href}`;
+ try {
+ const u = new URL(absolute);
+ u.hash = "";
+ u.search = "";
+ u.pathname = u.pathname.replace(/\/+$/, "");
+ return u.toString();
+ } catch {
+ return absolute.split("?")[0]!.replace(/\/+$/, "");
}
- 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 {
+ type Raw = { href: string; author: string; text: string };
+ const raws = await page.evaluate((maxScan: number) => {
+ const out: Raw[] = [];
+ const seen = new Set();
+ 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 層,取文字長度 20–800 的最近祖先
+ 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 = default;Recent = 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 {
+ 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();
+ for (const p of top) topMap.set(normalizePermalink(p.permalink), p);
+
+ const out: Post[] = [];
+ const seen = new Set();
+
+ 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 {
const state = JSON.parse(storageState) as { cookies?: unknown[] };
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 });
try {
- const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
- const page = await context.newPage();
- const query = terms.filter(Boolean).join(" ").slice(0, 180);
- await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 });
- const body = await page.locator("body").innerText().catch(() => "");
- if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
- await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
- await page.mouse.wheel(0, 900);
- await page.waitForTimeout(1000);
- const posts = await readPosts(page, limit);
+ const context: BrowserContext = await browser.newContext({
+ storageState: state,
+ locale: "zh-TW",
+ timezoneId: "Asia/Taipei",
+ userAgent:
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
+ });
+ const perTrack = Math.min(Math.max(limit, 10), 24);
+ const pageTop = await context.newPage();
+ 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();
- return posts;
+ return merged;
} finally {
await browser.close();
}
@@ -64,12 +299,21 @@ function shortcodeFromPermalink(permalink: string): string {
function findMediaID(value: unknown, shortcode: string): string {
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;
const code = String(record.code || record.shortcode || "");
const id = String(record.id || record.pk || record.media_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 "";
}
@@ -100,28 +344,36 @@ async function resolve(storageState: string, permalink: string): Promise
const responseReads: Promise[] = [];
page.on("response", async (response) => {
if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
- responseReads.push((async () => {
- try {
- const raw = await response.text();
- if (!mediaID) {
- try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ }
+ responseReads.push(
+ (async () => {
+ try {
+ const raw = await response.text();
+ if (!mediaID) {
+ try {
+ mediaID = findMediaID(JSON.parse(raw), shortcode);
+ } catch {
+ /* non-JSON */
+ }
+ }
+ if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
+ } catch {
+ /* ignored */
}
- if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
- } catch { /* ignored */ }
- })());
+ })(),
+ );
});
await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForTimeout(2500);
await Promise.allSettled(responseReads);
- if (!mediaID) {
- mediaID = findMediaIDInText(await page.content(), shortcode);
- }
+ if (!mediaID) mediaID = findMediaIDInText(await page.content(), shortcode);
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;
- } finally { await browser.close(); }
+ } finally {
+ await browser.close();
+ }
}
if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");
@@ -139,7 +391,10 @@ createServer(async (req, res) => {
const body = await new Promise((resolve, reject) => {
let raw = "";
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("error", reject);
});
@@ -150,10 +405,16 @@ createServer(async (req, res) => {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
} else {
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 }));
}
} 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");
diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api
index 711cc0e..06a15e2 100644
--- a/apps/backend/generate/api/m5.api
+++ b/apps/backend/generate/api/m5.api
@@ -247,6 +247,8 @@ type (
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"`
+ // TargetCount:話題今日目標(則)。主路徑不足時會加碼再搜/次路徑補抓,上限 40。
+ TargetCount int `json:"target_count,optional"`
}
ScoutScanReq {
Brief ScoutBriefPublic `json:"brief"`
diff --git a/apps/backend/generate/api/radar.api b/apps/backend/generate/api/radar.api
index e35d454..f251511 100644
--- a/apps/backend/generate/api/radar.api
+++ b/apps/backend/generate/api/radar.api
@@ -223,7 +223,9 @@ type (
Text string `json:"text"`
UsedAt int64 `json:"used_at,optional"`
SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy
- CreatedAt int64 `json:"created_at"`
+ // 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值。
+ OutboxId string `json:"outbox_id,optional"`
+ CreatedAt int64 `json:"created_at"`
}
ListRepliesReq {
@@ -244,6 +246,8 @@ type (
Id string `path:"id"`
ReplyId string `path:"replyId"`
Channel string `json:"channel"` // outbox | manual_copy
+ // channel=outbox 時必填:要用哪個 Threads 帳號送出。
+ AccountId string `json:"account_id,optional"`
}
MarkReplyUsedData {
@@ -279,6 +283,50 @@ type (
List []RadarSweepPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
+
+ // ---------- Manual Import(P1:不承諾全平台自動抓取的合規補位) ----------
+ 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"`
+ // skipped/failed 時的人話原因;qualified/rejected 時通常留空。
+ Error string `json:"error,optional"`
+ }
+
+ ImportOpportunitiesData {
+ Results []ImportedOpportunityResult `json:"results"`
+ }
+
+ // ---------- Explore(商機頁立即探索:短詞 fan-out → 五問判定) ----------
+ ExploreOpportunitiesReq {
+ // 1–6 組 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 (
@@ -349,4 +397,10 @@ service gateway {
@handler ListSweeps
get /sweeps (ListSweepsReq) returns (SweepListData)
+
+ @handler ImportOpportunities
+ post /import (ImportOpportunitiesReq) returns (ImportOpportunitiesData)
+
+ @handler ExploreOpportunities
+ post /explore (ExploreOpportunitiesReq) returns (ExploreOpportunitiesData)
}
diff --git a/apps/backend/internal/handler/radar/explore_opportunities_handler.go b/apps/backend/internal/handler/radar/explore_opportunities_handler.go
new file mode 100644
index 0000000..07ae69b
--- /dev/null
+++ b/apps/backend/internal/handler/radar/explore_opportunities_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+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)
+ }
+}
diff --git a/apps/backend/internal/handler/radar/import_opportunities_handler.go b/apps/backend/internal/handler/radar/import_opportunities_handler.go
new file mode 100644
index 0000000..35b243b
--- /dev/null
+++ b/apps/backend/internal/handler/radar/import_opportunities_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+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)
+ }
+}
diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go
index db6543a..3528dc3 100644
--- a/apps/backend/internal/handler/routes.go
+++ b/apps/backend/internal/handler/routes.go
@@ -1031,6 +1031,16 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/explore",
+ Handler: radar.ExploreOpportunitiesHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/import",
+ Handler: radar.ImportOpportunitiesHandler(serverCtx),
+ },
{
Method: http.MethodGet,
Path: "/opportunities",
diff --git a/apps/backend/internal/logic/radar/explore_opportunities_logic.go b/apps/backend/internal/logic/radar/explore_opportunities_logic.go
new file mode 100644
index 0000000..0c84f82
--- /dev/null
+++ b/apps/backend/internal/logic/radar/explore_opportunities_logic.go
@@ -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
+}
diff --git a/apps/backend/internal/logic/radar/import_opportunities_logic.go b/apps/backend/internal/logic/radar/import_opportunities_logic.go
new file mode 100644
index 0000000..1ed465e
--- /dev/null
+++ b/apps/backend/internal/logic/radar/import_opportunities_logic.go
@@ -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
+}
diff --git a/apps/backend/internal/logic/radar/m1_integration_test.go b/apps/backend/internal/logic/radar/m1_integration_test.go
index f868232..9ca48c9 100644
--- a/apps/backend/internal/logic/radar/m1_integration_test.go
+++ b/apps/backend/internal/logic/radar/m1_integration_test.go
@@ -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 }
+// 假 AI 回傳必須通過 Threads 短詞規則(IsThreadsSearchable):
+// include ≤2 token、中文每詞 2–4 字;exclude 仍走較寬長度界線。
const fakeSuggestReply = `[
- {"term":"台北 婚攝 推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"},
- {"term":"婚禮 攝影 價格","reason":"問價格的人通常已經在比較廠商","usage":"include"},
- {"term":"徵 婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"}
+ {"term":"婚攝 求推薦","reason":"正在找婚禮攝影的人最常這樣問","usage":"include"},
+ {"term":"台北 婚攝","reason":"問價格的人通常已經在比較廠商","usage":"include"},
+ {"term":"徵婚攝","reason":"這是同業徵才,不是客戶需求","usage":"exclude"}
]`
type m1Env struct {
diff --git a/apps/backend/internal/logic/radar/mark_opportunity_reply_used_logic.go b/apps/backend/internal/logic/radar/mark_opportunity_reply_used_logic.go
index 04d3643..5c93870 100644
--- a/apps/backend/internal/logic/radar/mark_opportunity_reply_used_logic.go
+++ b/apps/backend/internal/logic/radar/mark_opportunity_reply_used_logic.go
@@ -25,7 +25,7 @@ func (l *MarkOpportunityReplyUsedLogic) MarkOpportunityReplyUsed(req *types.Mark
if err != nil {
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 {
return nil, err
}
diff --git a/apps/backend/internal/logic/radarmap/map.go b/apps/backend/internal/logic/radarmap/map.go
index ca43d38..8f5baab 100644
--- a/apps/backend/internal/logic/radarmap/map.go
+++ b/apps/backend/internal/logic/radarmap/map.go
@@ -214,7 +214,7 @@ func Reply(r *domain.ReplyVariant) *types.ReplyVariantPublic {
}
return &types.ReplyVariantPublic{
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,
}
}
diff --git a/apps/backend/internal/module/radar/domain/opportunity.go b/apps/backend/internal/module/radar/domain/opportunity.go
index a72d48d..b5c5cc3 100644
--- a/apps/backend/internal/module/radar/domain/opportunity.go
+++ b/apps/backend/internal/module/radar/domain/opportunity.go
@@ -34,8 +34,9 @@ const (
// Opportunity sources.
const (
- OppSourceThreads = "threads"
- OppSourceManual = "manual"
+ OppSourceThreads = "threads"
+ // OppSourceManualImport:使用者貼 Threads/Facebook 貼文網址或 CSV 批次匯入(spec §4.11 P1)。
+ OppSourceManualImport = "manual_import"
OppSourceScoutPromote = "scout_promote"
)
@@ -157,7 +158,7 @@ func IsRegionMatch(s string) bool {
func IsOppSource(s string) bool {
switch s {
- case OppSourceThreads, OppSourceManual, OppSourceScoutPromote:
+ case OppSourceThreads, OppSourceManualImport, OppSourceScoutPromote:
return true
}
return false
diff --git a/apps/backend/internal/module/radar/domain/reply.go b/apps/backend/internal/module/radar/domain/reply.go
index 1a96244..eaa5f9c 100644
--- a/apps/backend/internal/module/radar/domain/reply.go
+++ b/apps/backend/internal/module/radar/domain/reply.go
@@ -22,7 +22,9 @@ type ReplyVariant struct {
Text string `bson:"text" json:"text"`
UsedAt int64 `bson:"used_at,omitempty" json:"used_at,omitempty"`
SentChannel string `bson:"sent_channel,omitempty" json:"sent_channel,omitempty"`
- CreatedAt int64 `bson:"created_at" json:"created_at"`
+ // OutboxID 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值(T550 真送出)。
+ OutboxID string `bson:"outbox_id,omitempty" json:"outbox_id,omitempty"`
+ CreatedAt int64 `bson:"created_at" json:"created_at"`
}
func IsReplyVariant(s string) bool {
diff --git a/apps/backend/internal/module/radar/domain/suggest.go b/apps/backend/internal/module/radar/domain/suggest.go
index e0b4dd3..5871d91 100644
--- a/apps/backend/internal/module/radar/domain/suggest.go
+++ b/apps/backend/internal/module/radar/domain/suggest.go
@@ -34,6 +34,8 @@ func NormalizeSuggestUsage(s string) string {
CleanSuggestions 收掉空白與重複,丟掉沒有理由的項目,並套用數量上限。
沒有理由的項目直接丟:補一句「AI 建議」等於假裝有理由,比少一則更糟。
+include 關鍵字必須通過 Threads 短詞規則(IsThreadsSearchable);不合規整條丟掉、不截短,
+避免產出半截怪詞。exclude 仍用較寬的長度界線(訂閱排除詞可能較長)。
*/
func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion {
if limit <= 0 || limit > MaxSuggestions {
@@ -42,19 +44,29 @@ func CleanSuggestions(in []WatchTermSuggestion, limit int) []WatchTermSuggestion
out := make([]WatchTermSuggestion, 0, len(in))
seen := map[string]bool{}
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)
if term == "" || reason == "" {
continue
}
- if len([]rune(term)) < MinTermLen || len([]rune(term)) > MaxTermLen {
+ usage := NormalizeSuggestUsage(s.Usage)
+ if usage == SuggestUsageInclude {
+ if !IsThreadsSearchable(term) {
+ continue
+ }
+ } else {
+ // exclude: keep broader length; still reject empty after normalize
+ n := len([]rune(term))
+ if n < MinTermLen || n > MaxTermLen {
+ continue
+ }
+ }
+ key := strings.ToLower(term)
+ if seen[key] {
continue
}
- if seen[term] {
- continue
- }
- seen[term] = true
- out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: NormalizeSuggestUsage(s.Usage)})
+ seen[key] = true
+ out = append(out, WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
if len(out) >= limit {
break
}
diff --git a/apps/backend/internal/module/radar/domain/sweep.go b/apps/backend/internal/module/radar/domain/sweep.go
index 54fdd9e..aa5e633 100644
--- a/apps/backend/internal/module/radar/domain/sweep.go
+++ b/apps/backend/internal/module/radar/domain/sweep.go
@@ -65,9 +65,8 @@ func (s *RadarSweep) Normalize() error {
if s.OwnerUID <= 0 {
return fmt.Errorf("%w: owner_uid required", ErrValidation)
}
- if strings.TrimSpace(s.WatchID) == "" {
- return fmt.Errorf("%w: watch_id required", ErrValidation)
- }
+ // WatchID may be empty for on-demand explore (no subscription); required for scheduled sweeps.
+ s.WatchID = strings.TrimSpace(s.WatchID)
if s.Path == "" {
s.Path = SweepPathAPI
}
diff --git a/apps/backend/internal/module/radar/domain/term.go b/apps/backend/internal/module/radar/domain/term.go
new file mode 100644
index 0000000..10112bc
--- /dev/null
+++ b/apps/backend/internal/module/radar/domain/term.go
@@ -0,0 +1,111 @@
+package domain
+
+import (
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// Threads 搜尋短詞硬約束(中文斷詞差、長字串常查無結果)。
+// 一組查詢 = 最多 2 個 token(半形空格分隔);中文 token 2–4 字;整組去掉空格後 ≤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: 2–4 runes; Latin tokens: 2–12 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
+}
diff --git a/apps/backend/internal/module/radar/domain/term_test.go b/apps/backend/internal/module/radar/domain/term_test.go
new file mode 100644
index 0000000..48ee55e
--- /dev/null
+++ b/apps/backend/internal/module/radar/domain/term_test.go
@@ -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)
+ }
+ }
+}
diff --git a/apps/backend/internal/module/radar/usecase/explore.go b/apps/backend/internal/module/radar/usecase/explore.go
new file mode 100644
index 0000000..15ff283
--- /dev/null
+++ b/apps/backend/internal/module/radar/usecase/explore.go
@@ -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 2–4 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
+}
diff --git a/apps/backend/internal/module/radar/usecase/explore_test.go b/apps/backend/internal/module/radar/usecase/explore_test.go
new file mode 100644
index 0000000..eff45c0
--- /dev/null
+++ b/apps/backend/internal/module/radar/usecase/explore_test.go
@@ -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"},
+ }
+}
diff --git a/apps/backend/internal/module/radar/usecase/manual_import.go b/apps/backend/internal/module/radar/usecase/manual_import.go
new file mode 100644
index 0000000..7c3f28b
--- /dev/null
+++ b/apps/backend/internal/module/radar/usecase/manual_import.go
@@ -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 讓使用者貼 Threads/Facebook 貼文網址(或 CSV 批次貼上多筆)
+建立商機,跑同一套五問判定,來源標 manual_import(spec §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 != ""
+}
diff --git a/apps/backend/internal/module/radar/usecase/manual_import_test.go b/apps/backend/internal/module/radar/usecase/manual_import_test.go
new file mode 100644
index 0000000..20bdd45
--- /dev/null
+++ b/apps/backend/internal/module/radar/usecase/manual_import_test.go
@@ -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")
+ }
+}
diff --git a/apps/backend/internal/module/radar/usecase/reply_send.go b/apps/backend/internal/module/radar/usecase/reply_send.go
index 7b53428..90088af 100644
--- a/apps/backend/internal/module/radar/usecase/reply_send.go
+++ b/apps/backend/internal/module/radar/usecase/reply_send.go
@@ -15,11 +15,30 @@ type HealthGate interface {
WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err 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)
-func (s *Service) MarkReplyUsed(ctx context.Context, ownerUID int64, opportunityID, replyID, channel string) (*domain.ReplyVariant, string, error) {
+// ReplyQueue puts a reply into the shared Outbox pipeline (same path as scout's
+// outreach send). Implemented by an adapter over studio.Service.QueueExternalReply.
+type ReplyQueue interface {
+ QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err 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)
if channel != domain.SentOutbox && channel != domain.SentManualCopy {
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 {
return nil, "", err
}
- _ = o
r, err := s.Repo.GetReply(ctx, replyID)
if err != nil {
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()
diff --git a/apps/backend/internal/module/radar/usecase/reply_send_test.go b/apps/backend/internal/module/radar/usecase/reply_send_test.go
index f3f2dfb..df11459 100644
--- a/apps/backend/internal/module/radar/usecase/reply_send_test.go
+++ b/apps/backend/internal/module/radar/usecase/reply_send_test.go
@@ -2,6 +2,7 @@ package usecase
import (
"context"
+ "fmt"
"strings"
"testing"
@@ -53,7 +54,7 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
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 {
t.Fatal(err)
}
@@ -61,19 +62,19 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
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")
}
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
pub2 := &domain.ReplyVariant{
ID: "r3", OwnerUID: uid, OpportunityID: "o1", Variant: domain.ReplyPublicComment,
Text: "再一則", CreatedAt: now,
}
_ = 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 {
t.Fatal("expected throttle block")
}
@@ -84,3 +85,116 @@ func TestMarkReplyUsed_ManualCopyAndThrottle(t *testing.T) {
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)
+ }
+}
diff --git a/apps/backend/internal/module/radar/usecase/service_profile.go b/apps/backend/internal/module/radar/usecase/service_profile.go
index 858a4e1..bc3cdb1 100644
--- a/apps/backend/internal/module/radar/usecase/service_profile.go
+++ b/apps/backend/internal/module/radar/usecase/service_profile.go
@@ -37,6 +37,12 @@ type Service struct {
CRM ContactBinder
// Health gates auto-send of public replies (AccountHealth throttle).
Health HealthGate
+ // ReplyQueue 是既有 Outbox 佇列(studio.QueueExternalReply);nil 時 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.
diff --git a/apps/backend/internal/module/radar/usecase/suggest.go b/apps/backend/internal/module/radar/usecase/suggest.go
index 96a0beb..a189b22 100644
--- a/apps/backend/internal/module/radar/usecase/suggest.go
+++ b/apps/backend/internal/module/radar/usecase/suggest.go
@@ -78,9 +78,12 @@ func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
b.WriteString(`[{"term":"關鍵字","reason":"為什麼這個詞能找到有需求的人(一句話)","usage":"include 或 exclude"}]`)
b.WriteString("\n規則:\n")
b.WriteString("1. include 是要搜尋的詞;exclude 是要排除的詞(例如同業叫賣、徵才、二手轉讓)。\n")
- b.WriteString("2. 用台灣的實際說法,包含口語問法(例如「有人推薦嗎」)。\n")
+ b.WriteString("2. 用台灣的實際說法,包含口語求助句式:求推薦、有人知道、請問、怎麼辦、哪裡買。\n")
b.WriteString("3. 每則都要有理由,理由講人話,不要覆述關鍵字本身。\n")
b.WriteString("4. 不要輸出價格數字或聯絡方式。\n")
+ b.WriteString("5. 【Threads 短詞硬約束|include 必守】每則 term 最多 2 個詞(半形空格分隔);")
+ b.WriteString("中文每詞 2–4 字;整組去掉空格後 ≤12 字;禁止標點、引號、AND/OR、-、emoji、#。\n")
+ b.WriteString("6. 每個服務意圖給 3–5 組短變體(例:「保母 求推薦」「到府保母」「台北 保母」),不要長句。\n")
return b.String()
}
diff --git a/apps/backend/internal/module/radar/usecase/suggest_test.go b/apps/backend/internal/module/radar/usecase/suggest_test.go
index e6db9d8..3ad8e7b 100644
--- a/apps/backend/internal/module/radar/usecase/suggest_test.go
+++ b/apps/backend/internal/module/radar/usecase/suggest_test.go
@@ -64,9 +64,9 @@ func suggestService(t *testing.T, reply string) (*Service, *stubAI, context.Cont
}
const suggestReply = `[
- {"term":"台北 婚攝 推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
- {"term":"婚禮 攝影 價格","reason":"問價格通常已經在比較廠商","usage":"include"},
- {"term":"徵 婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
+ {"term":"婚攝 求推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
+ {"term":"台北 婚攝","reason":"帶地區的人通常已在比較廠商","usage":"include"},
+ {"term":"徵婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
]`
// RW-03:有服務檔案就回得出建議,每則都要有理由。
@@ -160,19 +160,20 @@ func TestSuggestRespectsLimit(t *testing.T) {
}
func TestSuggestDropsUnusableItems(t *testing.T) {
- // 沒理由、太短、重複的項目都要丟掉,而不是補一句假理由湊數。
+ // 沒理由、太短、重複、超過 Threads 短詞規則的 include 都要丟掉。
svc, _, ctx := suggestService(t, `[
- {"term":"婚攝 推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
+ {"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
{"term":"沒有理由的詞","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)
if err != nil {
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)
}
}
diff --git a/apps/backend/internal/module/radar/usecase/sweep_schedule.go b/apps/backend/internal/module/radar/usecase/sweep_schedule.go
index e11775d..2349617 100644
--- a/apps/backend/internal/module/radar/usecase/sweep_schedule.go
+++ b/apps/backend/internal/module/radar/usecase/sweep_schedule.go
@@ -3,6 +3,7 @@ package usecase
import (
"context"
"fmt"
+ "strings"
"time"
"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).
// Fetch / judge (T528–T530) attach progress onto the same record via UpdateSweep.
func (s *Service) BeginSweepRecord(ctx context.Context, ownerUID int64, watchID, jobID, path string) (*domain.RadarSweep, error) {
- if ownerUID <= 0 || watchID == "" {
- return nil, fmt.Errorf("%w: owner_uid and watch_id required", domain.ErrValidation)
+ if ownerUID <= 0 {
+ return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
}
+ // watchID may be empty for on-demand explore (no subscription).
if path == "" {
path = domain.SweepPathAPI
}
sw := &domain.RadarSweep{
ID: domain.NewID(),
OwnerUID: ownerUID,
- WatchID: watchID,
+ WatchID: strings.TrimSpace(watchID),
JobID: jobID,
Path: path,
StartedAt: domain.NowNano(),
diff --git a/apps/backend/internal/module/scout/domain/domain.go b/apps/backend/internal/module/scout/domain/domain.go
index 2f56b5f..892764e 100644
--- a/apps/backend/internal/module/scout/domain/domain.go
+++ b/apps/backend/internal/module/scout/domain/domain.go
@@ -95,6 +95,9 @@ type RunBrief struct {
ThemeKey string `json:"theme_key,omitempty"`
ThemeLabel string `json:"theme_label,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 {
diff --git a/apps/backend/internal/module/scout/usecase/activity_terms.go b/apps/backend/internal/module/scout/usecase/activity_terms.go
new file mode 100644
index 0000000..6061f2e
--- /dev/null
+++ b/apps/backend/internal/module/scout/usecase/activity_terms.go
@@ -0,0 +1,290 @@
+package usecase
+
+import (
+ "encoding/json"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ radarDomain "apps/backend/internal/module/radar/domain"
+)
+
+// 話題(activity)口語錨點:與 Threads 上常見「可跟/可討論」貼文語感對齊。
+// 每詞 2–4 字,方便與主題核組 2-token 短查詢。
+var activityAnchors = []string{
+ "求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
+}
+
+// 台灣常見地名(作前綴 token,2–3 字)
+var activityRegions = []string{
+ "台北", "新北", "桃園", "台中", "台南", "高雄", "新竹", "基隆",
+ "嘉義", "宜蘭", "花蓮", "台東", "屏東", "彰化", "雲林", "南投",
+ "苗栗", "金門", "澎湖", "板橋", "中和", "三重", "淡水", "竹北",
+}
+
+/*
+planActivityTerms 依使用者意圖**產生**可搜的短詞變體,不是只把原文拆開。
+
+策略:
+ 1. 抽出主題核(2–4 字 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 從意圖抽出可當搜尋主詞的 2–4 字核。
+func extractTopicCores(intent string) []string {
+ intent = radarDomain.NormalizeSearchTerm(intent)
+ if intent == "" {
+ return nil
+ }
+
+ var cores []string
+ // 已有空白:每段當候選(再壓成 2–4 字)
+ if strings.Contains(intent, " ") {
+ for _, tok := range strings.Fields(intent) {
+ if c := compactCore(tok); c != "" {
+ cores = append(cores, c)
+ }
+ }
+ return dedupeTerms(cores)
+ }
+
+ // 連續中文:用標點/空白切完後,對每段取 2–4 字滑窗(優先較長有意義核)
+ 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 2–4 CJK 或 2–12 英數才當核
+ 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 個詞(半形空格分隔);中文每詞 2–4 字;整組去掉空格後 ≤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:])
+}
diff --git a/apps/backend/internal/module/scout/usecase/activity_terms_test.go b/apps/backend/internal/module/scout/usecase/activity_terms_test.go
new file mode 100644
index 0000000..ccfea7d
--- /dev/null
+++ b/apps/backend/internal/module/scout/usecase/activity_terms_test.go
@@ -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, " ", "")))
+}
diff --git a/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go b/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go
index cf9f309..ddc0f64 100644
--- a/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go
+++ b/apps/backend/internal/module/scout/usecase/chrome_crawler_provider.go
@@ -84,7 +84,8 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
if err != nil {
return nil, err
}
- runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
+ // 雙軌(熱門+最新)約兩倍時間
+ runCtx, cancel := context.WithTimeout(ctx, 150*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(runCtx, http.MethodPost, p.Endpoint+"/v1/threads/search", bytes.NewReader(body))
if err != nil {
@@ -94,7 +95,7 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
req.Header.Set("Authorization", "Bearer "+p.Token)
client := p.HTTP
if client == nil {
- client = &http.Client{Timeout: 95 * time.Second}
+ client = &http.Client{Timeout: 160 * time.Second}
}
res, err := client.Do(req)
if err != nil {
@@ -107,9 +108,13 @@ func (p *HTTPCrawlerProvider) SearchChrome(ctx context.Context, storageState str
}
var out struct {
Posts []struct {
- Permalink string `json:"permalink"`
- Author string `json:"author"`
- Text string `json:"text"`
+ Permalink string `json:"permalink"`
+ Author string `json:"author"`
+ 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"`
}
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) == "" {
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
}
+
+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
+}
diff --git a/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go b/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go
new file mode 100644
index 0000000..0bbee56
--- /dev/null
+++ b/apps/backend/internal/module/scout/usecase/chrome_crawler_stale_test.go
@@ -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)
+ }
+}
diff --git a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go
index 0873879..f6a4173 100644
--- a/apps/backend/internal/module/scout/usecase/exa_threads_provider.go
+++ b/apps/backend/internal/module/scout/usecase/exa_threads_provider.go
@@ -21,13 +21,17 @@ type ThreadSearchProvider interface {
}
type ThreadSearchResult struct {
- URL string
- Title string
- Snippet string
- // PublishedAt unix nanoseconds when known (Exa publishedDate).
- PublishedAt int64
+ URL string
+ Title string
+ Snippet string
+ // PublishedAt unix nanoseconds when known (Exa publishedDate / crawler parse).
+ PublishedAt int64
// MatchedQuery is set by fan-out search to the query that found this hit.
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.
diff --git a/apps/backend/internal/module/scout/usecase/planner.go b/apps/backend/internal/module/scout/usecase/planner.go
index e97413a..f5b74db 100644
--- a/apps/backend/internal/module/scout/usecase/planner.go
+++ b/apps/backend/internal/module/scout/usecase/planner.go
@@ -1,4 +1,5 @@
-package usecase
+
+ㄇㄠpackage usecase
import (
"crypto/sha256"
@@ -25,7 +26,8 @@ func planScanTerms(brief *domain.RunBrief) []string {
return nil
}
if brief.Mode == domain.ModeActivity {
- return capTerms(dedupeTerms([]string{brief.Intent}, tokenizeIntent(brief.Intent)), 6)
+ // 規則式變體(不依賴 AI);AI 擴充在 PrepareBrief 另外合併。
+ return planActivityTerms(brief.Intent)
}
if brief.Mode == domain.ModeProvider {
return planProviderTerms(brief.Pains, brief.Tags)
diff --git a/apps/backend/internal/module/scout/usecase/relevance_test.go b/apps/backend/internal/module/scout/usecase/relevance_test.go
new file mode 100644
index 0000000..723f296
--- /dev/null
+++ b/apps/backend/internal/module/scout/usecase/relevance_test.go
@@ -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)
+ }
+}
diff --git a/apps/backend/internal/module/scout/usecase/search_hits_only_test.go b/apps/backend/internal/module/scout/usecase/search_hits_only_test.go
new file mode 100644
index 0000000..2cc304f
--- /dev/null
+++ b/apps/backend/internal/module/scout/usecase/search_hits_only_test.go
@@ -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))
+ }
+}
diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go
index f0c30a9..819a5b7 100644
--- a/apps/backend/internal/module/scout/usecase/service.go
+++ b/apps/backend/internal/module/scout/usecase/service.go
@@ -7,13 +7,17 @@ import (
"net/url"
"strings"
"time"
+ "unicode/utf8"
"apps/backend/internal/module/ai"
"apps/backend/internal/module/scout/domain"
studioPublish "apps/backend/internal/module/studio/publish"
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/zeromicro/go-zero/core/logx"
)
// SettingsReader for dev_mode
@@ -33,7 +37,10 @@ type Service struct {
Settings SettingsReader
// Transport is retained only for test construction compatibility. Scout never publishes directly.
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
Provider ThreadSearchProvider
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) {
- _ = deep
intent = strings.TrimSpace(intent)
if intent == "" && purpose != "provider" && purpose != "demand" {
return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
}
- mode := domain.ModeTheme
+
+ // 話題靈感:真正「產」關鍵字(規則變體 + 可選 AI),不再只拆使用者原句。
if purpose == "activity" {
- mode = domain.ModeActivity
+ return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
}
+
+ mode := domain.ModeTheme
brief := &domain.RunBrief{
Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID,
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 {
mode = domain.ModeProduct
- if purpose == "activity" {
- mode = domain.ModeActivity
- }
brief.Mode = mode
brief.ProductLabel = p.Label
brief.ProductContext = p.ProductContext
@@ -314,6 +320,108 @@ func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, bran
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_copy/source=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
// tags become matching terms until the more specific provider terms are added.
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.
// 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) {
terms = nonEmptyTerms(terms)
if len(terms) == 0 {
@@ -374,6 +485,16 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
if 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
devMode := false
if s.Settings != nil {
@@ -390,14 +511,31 @@ func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []st
if s.Crawler == nil {
return nil, path, fmt.Errorf("Chrome crawler is not configured")
}
- hits, err = s.Crawler.SearchChrome(ctx, storageState, terms, limit)
- return hits, path, err
+ hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
+ 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 {
return nil, path, fmt.Errorf("scout search provider is not configured")
}
- hits, err = s.Provider.SearchThreads(ctx, terms, limit)
- return hits, path, err
+ hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
+ 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) {
@@ -447,11 +585,30 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
} else if len(terms) >= 6 {
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 err error
+ var storageState string
if devMode {
- storageState, serr := s.GetCrawlerSessionToken(ctx, ownerUID)
- if serr != nil {
+ storageState, err = s.GetCrawlerSessionToken(ctx, ownerUID)
+ if err != nil {
return nil, domain.ErrNoCrawlerSession
}
path = domain.PathCrawler
@@ -472,9 +629,101 @@ func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *d
if err != nil {
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)
}
+// 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 {
var out []string
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) {
now := domain.NowNano()
- // 先依原文發文時間新→舊;無時間的排後面,再以陣列序
- sortHitsByPostedAt(hits)
+ // 保真:crawler 已依 Recent 主序回傳,不再 both-first 重排蓋掉平台相關性。
+ // 無 track 的 Exa 結果仍依發文時間新→舊。
+ if !hitsHaveTrack(hits) {
+ sortHitsByPostedAt(hits)
+ }
out := make([]*domain.Post, 0, len(hits))
for i, hit := range hits {
text := strings.TrimSpace(hit.Snippet)
@@ -540,9 +792,22 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
if permalink == "" {
continue
}
+ postedAt := hit.PublishedAt
+ // 硬擋:僅極舊(180 天);測試 stub 時間戳不套用
+ if postedAt > 0 && isStalePublished(postedAt, defaultScoutHardMaxAgeDays) {
+ continue
+ }
term := strings.TrimSpace(hit.MatchedQuery)
- if term == "" {
- term = matchingTerm(text+" "+hit.Title, brief.ScanTerms)
+ // 相關性硬閘:所有來源的正文都必須含查詢主題核,避免 provider
+ // 回傳的語意擴展/空 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)
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) {
continue
}
- postedAt := hit.PublishedAt
- // created_at:有發文時間則對齊發文序;否則用掃入時間並微調保序
+ score := classified.score
+ 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
- if postedAt > 0 {
+ if postedAt > 0 && !hitsHaveTrack(hits) {
+ // 非 crawler 路徑仍用發文時間當 created 序
createdAt = postedAt
}
p := &domain.Post{
ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew,
- Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
- MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
+ Score: score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
+ MatchReason: reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
PostedAt: postedAt, CreatedAt: createdAt,
}
@@ -582,11 +868,134 @@ func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *
}
out = append(out, p)
}
- // 回傳列表:待處理優先不在這裡做,純按發文時間新→舊
- sortPostsByPostedAt(out)
+ // 話題優先顯示近期討論動能(track/問答訊號已計入分數),再以發文時間決勝。
+ // 其他模式維持既有的時間排序,避免改變商機/解法媒合的行為。
+ if brief.Mode == domain.ModeActivity {
+ sortActivityPostsByMomentum(out)
+ } else if !hitsHaveTrack(hits) {
+ sortPostsByPostedAt(out)
+ }
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) {
// newest first; unknown published time last
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 {
for _, term := range terms {
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
}
+/*
+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 {
if value == "" {
return false
diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go
index 5f1fccd..277190e 100644
--- a/apps/backend/internal/svc/service_context.go
+++ b/apps/backend/internal/svc/service_context.go
@@ -193,6 +193,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
scoutSvc.Settings = &devModeFromMembers{Members: repo}
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.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
scoutSvc.SessionSecret = c.Scout.SessionSecret
@@ -237,6 +242,9 @@ func NewServiceContext(c config.Config) *ServiceContext {
radarSvc.HitFetch = &scoutHitAdapter{Scout: scoutSvc}
radarSvc.Notifier = radarUC.NotifierFromAppNotif(&radarSystemNotif{App: appN})
radarSvc.Health = &radarHealthBridge{Growth: growthSvc}
+ // 商機回覆一鍵送出:同一條 Outbox 佇列+同一套 crawler media id 解析,不重造第二套送出路徑。
+ radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
+ radarSvc.MediaResolver = scoutSvc
// 每日巡與手動觸發共用 job.ScheduleRadarSweep(同 template、同日去重)。
radarSvc.SweepJobs = radarUC.SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
diff --git a/apps/backend/internal/types/m5_convert.go b/apps/backend/internal/types/m5_convert.go
index d663555..92870f9 100644
--- a/apps/backend/internal/types/m5_convert.go
+++ b/apps/backend/internal/types/m5_convert.go
@@ -94,6 +94,7 @@ func BriefFromDomain(b *scoutDomain.RunBrief) ScoutBriefPublic {
ProductLabel: b.ProductLabel, Pains: b.Pains, Tags: b.Tags, Periphery: b.Periphery,
ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance,
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,
ScanTerms: b.ScanTerms, PlacementNote: b.PlacementNote, ResponseStance: b.ResponseStance,
ThemeKey: b.ThemeKey, ThemeLabel: b.ThemeLabel, ProductContext: b.ProductContext,
+ TargetCount: b.TargetCount,
}
}
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index 01dd828..90067a7 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -541,6 +541,19 @@ type DraftReviewPublic 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 {
Url string `json:"url"`
ExpiresAt int64 `json:"expires_at,optional"`
@@ -641,6 +654,21 @@ type IdentityPublic struct {
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 {
Id string `json:"id"`
Copied bool `json:"copied"`
@@ -675,6 +703,15 @@ type ImportThreadsAccountSessionReq struct {
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 {
MonthKey string `json:"month_key"`
Posts int `json:"posts"`
@@ -1015,9 +1052,10 @@ type MarkReplyUsedData struct {
}
type MarkReplyUsedReq struct {
- Id string `path:"id"`
- ReplyId string `path:"replyId"`
- Channel string `json:"channel"` // outbox | manual_copy
+ Id string `path:"id"`
+ ReplyId string `path:"replyId"`
+ Channel string `json:"channel"` // outbox | manual_copy
+ AccountId string `json:"account_id,optional"`
}
type MediaUploadData struct {
@@ -1693,6 +1731,7 @@ type ReplyVariantPublic struct {
Text string `json:"text"`
UsedAt int64 `json:"used_at,optional"`
SentChannel string `json:"sent_channel,optional"` // outbox | manual_copy
+ OutboxId string `json:"outbox_id,optional"`
CreatedAt int64 `json:"created_at"`
}
@@ -1760,6 +1799,7 @@ type ScoutBriefPublic struct {
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"`
+ TargetCount int `json:"target_count,optional"`
}
type ScoutBriefReq struct {
diff --git a/apps/web/src/components/radar/ExplorePanel.test.tsx b/apps/web/src/components/radar/ExplorePanel.test.tsx
new file mode 100644
index 0000000..52ce36b
--- /dev/null
+++ b/apps/web/src/components/radar/ExplorePanel.test.tsx
@@ -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>(),
+ exploreOpportunities: vi.fn<(terms: string[]) => Promise>(),
+}));
+
+vi.mock("../../data/DataContext", () => ({
+ useRepos: () => ({
+ radar: {
+ suggestWatchTerms: backend.suggestWatchTerms,
+ exploreOpportunities: backend.exploreOpportunities,
+ },
+ }),
+}));
+
+function renderPanel(onExplored = vi.fn()) {
+ return render(
+
+
+ ,
+ );
+}
+
+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);
+ });
+ });
+});
diff --git a/apps/web/src/components/radar/ExplorePanel.tsx b/apps/web/src/components/radar/ExplorePanel.tsx
new file mode 100644
index 0000000..98d715d
--- /dev/null
+++ b/apps/web/src/components/radar/ExplorePanel.tsx
@@ -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([]);
+ const [draft, setDraft] = useState("");
+ const [draftErr, setDraftErr] = useState("");
+ const [suggestions, setSuggestions] = useState([]);
+ const [loadingSuggest, setLoadingSuggest] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+ const [result, setResult] = useState(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 (
+
+
{t("radar.explore.title")}
+
{t("radar.explore.hint")}
+
+ {loadingSuggest ? (
+
{t("radar.explore.loadingSuggest")}
+ ) : null}
+
+ {suggestions.length > 0 ? (
+
+
{t("radar.explore.suggestions")}
+
+ {suggestions.map((s) => {
+ const already = terms.some((x) => x.toLowerCase() === s.term.toLowerCase());
+ return (
+
+ );
+ })}
+
+
+ ) : null}
+
+
+ {terms.length === 0 ? (
+
{t("radar.explore.emptyTerms")}
+ ) : (
+ terms.map((term) => (
+
+ ))
+ )}
+
+
+
+ {
+ 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}
+ />
+
+
+ {draftErr ? (
+
+ {draftErr}
+
+ ) : null}
+
+
+
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+
+ {result ? (
+
+
+ {t("radar.explore.result", {
+ hits: result.hit_count,
+ created: result.created_count,
+ judged: result.judged_count,
+ })}
+
+ {result.created_count === 0 ? (
+
{t("radar.explore.resultZeroHint")}
+ ) : null}
+ {result.truncated_count > 0 ? (
+
+ {t("radar.explore.resultTruncated", { n: result.truncated_count })}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/radar/ManualImportPanel.test.tsx b/apps/web/src/components/radar/ManualImportPanel.test.tsx
new file mode 100644
index 0000000..13e0e68
--- /dev/null
+++ b/apps/web/src/components/radar/ManualImportPanel.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/apps/web/src/components/radar/ManualImportPanel.tsx b/apps/web/src/components/radar/ManualImportPanel.tsx
new file mode 100644
index 0000000..93aca41
--- /dev/null
+++ b/apps/web/src/components/radar/ManualImportPanel.tsx
@@ -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):貼 Threads/Facebook 貼文網址+內文,
+ * 或貼 CSV 批次,走跟自動巡一樣的五問判定。沒有爬蟲能讀任意網址的內文,判定一律吃
+ * 使用者貼上的文字,這是「不承諾全平台自動抓取」前提下的合規補位(不是偷懶)。
+ */
+export function ManualImportPanel({ onImported }: { onImported: () => void }) {
+ const { t } = useI18n();
+ const repos = useRepos();
+ const formatError = useFormatApiError();
+ const [rows, setRows] = useState([emptyRow()]);
+ const [csvOpen, setCsvOpen] = useState(false);
+ const [csvText, setCsvText] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+ const [results, setResults] = useState(null);
+
+ function updateRow(i: number, patch: Partial) {
+ 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 (
+
+
{t("radar.import.title")}
+
{t("radar.import.hint")}
+
+
+
+
+
+ {csvOpen ? (
+
+ ) : null}
+
+ {rows.map((row, i) => (
+
+ updateRow(i, { url: e.target.value })}
+ />
+
+ ))}
+
+
+
+
+
+
+ {err ? (
+
+ {err}
+
+ ) : null}
+
+ {results ? (
+
+ {results.map((r, i) => (
+ -
+ {t(`radar.import.status.${r.status}`)}
+ {r.url}
+ {r.intent_band ? (
+
+ {t(`radar.today.band.${r.intent_band}`)} · {r.intent_score}
+
+ ) : null}
+ {r.error ? {r.error} : null}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts
index b5e40ae..713c7c9 100644
--- a/apps/web/src/data/live/m5Repos.ts
+++ b/apps/web/src/data/live/m5Repos.ts
@@ -148,6 +148,10 @@ function mapBrief(raw: Record): ScoutRunBrief {
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
theme_label: raw.theme_label != null ? String(raw.theme_label) : 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 {
theme_key: b.theme_key,
theme_label: b.theme_label,
product_context: b.product_context,
+ target_count: b.target_count && b.target_count > 0 ? b.target_count : undefined,
};
}
diff --git a/apps/web/src/data/live/radarRepos.test.ts b/apps/web/src/data/live/radarRepos.test.ts
index 721859b..106b267 100644
--- a/apps/web/src/data/live/radarRepos.test.ts
+++ b/apps/web/src/data/live/radarRepos.test.ts
@@ -109,4 +109,34 @@ describe("demand-radar live repositories", () => {
await crm.listContacts({ 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: "網址格式不正確" });
+ });
});
diff --git a/apps/web/src/data/live/radarRepos.ts b/apps/web/src/data/live/radarRepos.ts
index f494ed2..3457995 100644
--- a/apps/web/src/data/live/radarRepos.ts
+++ b/apps/web/src/data/live/radarRepos.ts
@@ -11,6 +11,7 @@ import type {
ContactTouch,
CrmStats,
FollowUp,
+ ManualImportResult,
Opportunity,
RadarSweep,
RadarToday,
@@ -111,6 +112,7 @@ function mapReply(raw: Raw): ReplyVariant {
text: str(raw.text),
used_at: optNum(raw.used_at),
sent_channel: optStr(raw.sent_channel) as ReplyVariant["sent_channel"],
+ outbox_id: optStr(raw.outbox_id),
created_at: num(raw.created_at),
};
}
@@ -378,10 +380,10 @@ export function createLiveRadarRepo(): RadarRepo {
);
return mapReply(raw);
},
- async markReplyUsed(opportunityId, replyId, channel) {
+ async markReplyUsed(opportunityId, replyId, channel, accountId) {
const raw = await apiRequest(
`${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;
return {
@@ -395,6 +397,44 @@ export function createLiveRadarRepo(): RadarRepo {
);
return { list: rawList(raw.list).map(mapSweep), total: total(raw) };
},
+ async importOpportunities(items) {
+ const raw = await apiRequest(`${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(`${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),
+ };
+ },
};
}
diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts
index 6108542..ae24a51 100644
--- a/apps/web/src/data/repos.ts
+++ b/apps/web/src/data/repos.ts
@@ -693,11 +693,15 @@ export type RadarRepo = {
opportunityId: string,
variant: import("../domain/types").ReplyVariantKind,
): Promise;
- /** channel: outbox | manual_copy;dm 僅 manual_copy */
+ /**
+ * channel: outbox | manual_copy;dm 僅 manual_copy。
+ * accountId 在 channel=outbox 時必填:要用哪個已連的 Threads 帳號真的送出。
+ */
markReplyUsed(
opportunityId: string,
replyId: string,
channel: "outbox" | "manual_copy",
+ accountId?: string,
): Promise<{
reply: import("../domain/types").ReplyVariant;
health_advice?: string;
@@ -707,6 +711,14 @@ export type RadarRepo = {
pageSize?: number,
watchId?: string,
): Promise<{ list: import("../domain/types").RadarSweep[]; total: number }>;
+ /** 手動匯入商機(P1):貼 Threads/Facebook 貼文網址+內文或 CSV 批次,走同一套五問判定 */
+ importOpportunities(
+ items: import("../domain/types").ManualImportItem[],
+ ): Promise<{ results: import("../domain/types").ManualImportResult[] }>;
+ /** 立即探索:短詞 fan-out 搜尋 → 五問判定,寫入今日商機 */
+ exploreOpportunities(
+ terms: string[],
+ ): Promise;
};
export type CrmRepo = {
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
index 5a3ad97..778ec81 100644
--- a/apps/web/src/domain/types.ts
+++ b/apps/web/src/domain/types.ts
@@ -765,6 +765,8 @@ export type ScoutRunBrief = {
/** 分組/持久化用(與命中 theme_key 對齊) */
theme_key?: string;
theme_label?: string;
+ /** 話題今日目標(則);後端主路徑不足時會加碼/次路徑補抓 */
+ target_count?: number;
};
/** 已完成的功課(可依主題找回,不消失) */
@@ -911,14 +913,44 @@ export type ReplyVariant = {
text: string;
used_at?: number;
sent_channel?: "outbox" | "manual_copy";
+ /** 只在 sent_channel=outbox 且真的排入既有 Outbox 佇列時才有值 */
+ outbox_id?: string;
created_at: number;
};
+/** 手動匯入一列:貼 Threads/Facebook 貼文網址+內文,走同一套五問判定 */
+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 = {
id: string;
watch_id?: string;
- source: "threads" | "manual" | "scout_promote";
+ source: "threads" | "manual_import" | "scout_promote";
source_scout_post_id?: string;
external_id: string;
permalink: string;
diff --git a/apps/web/src/lib/csv.test.ts b/apps/web/src/lib/csv.test.ts
new file mode 100644
index 0000000..d0bd95b
--- /dev/null
+++ b/apps/web/src/lib/csv.test.ts
@@ -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([]);
+ });
+});
diff --git a/apps/web/src/lib/csv.ts b/apps/web/src/lib/csv.ts
new file mode 100644
index 0000000..ec358f9
--- /dev/null
+++ b/apps/web/src/lib/csv.ts
@@ -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() !== ""));
+}
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
index 504c347..01d333f 100644
--- a/apps/web/src/lib/i18n/messages.ts
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -16,7 +16,7 @@ export const zhTW: MessageDict = {
"nav.radar": "商機",
"nav.crm": "名單",
/** 手動掃場外展(vs 商機=訂閱後每天自動) */
- "nav.scout": "探索",
+ "nav.scout": "話題",
"nav.outbox": "發送",
"nav.jobs": "任務",
"nav.brands": "品牌",
@@ -96,19 +96,19 @@ export const zhTW: MessageDict = {
"help.page.studio.step3": "送出到發送匣,到 Outbox 確認排程。",
"help.page.studio.tips": "創作頁不直接等同已發佈;真正送出在 Outbox。",
- "help.page.scout.title": "海巡(手動掃場)",
- "help.page.scout.what": "先練關鍵字、確認後再搜:找痛點/話題對話、寫草稿、回完就結。不是「每天自動來名單」——那是側欄「商機」。",
- "help.page.scout.step1": "寫意圖(可選產品),按「產出關鍵字」檢視/增刪 query。",
+ "help.page.scout.title": "話題靈感",
+ "help.page.scout.what": "用關鍵字找 Threads 上可跟的活躍話題,產出草稿、回完就結。找「正在找你的人」請用側欄「商機」的每日巡或立即探索。",
+ "help.page.scout.step1": "寫話題關鍵字,按「產出關鍵字」檢視/增刪 query。",
"help.page.scout.step2": "確認後「用這些詞開始搜」,在佇列依發文時間處理。",
- "help.page.scout.step3": "值得長期跟進的,按「收進商機」複製到每日商機/名單(不影響這則海巡狀態)。",
- "help.page.scout.tips": "海巡=出擊掃場;商機=訂閱後每天自動收「正在找你的人」。兩者可並用。關鍵字要寫「對方的困擾」,不要寫產品賣點。",
+ "help.page.scout.step3": "寫草稿、開 Threads 回覆、標記完成。",
+ "help.page.scout.tips": "話題=內容靈感;商機=找需求客戶。找客戶請用商機頁,不要在這裡掃痛點。",
"help.page.radar_today.title": "今日商機(自動名單)",
- "help.page.radar_today.what": "依你訂的關鍵字,系統每天自動整理的需求名單(高/中/低意向)。和海巡不同:不用每次手動掃。",
- "help.page.radar_today.step1": "先看統計;0 筆時依原因去「商機訂閱」或服務檔案。",
+ "help.page.radar_today.what": "依你訂的關鍵字每天自動整理的需求名單(高/中/低意向)。也可「立即探索」臨時搜,或手動匯入貼文。",
+ "help.page.radar_today.step1": "先看統計;0 筆時用立即探索、訂閱管理或服務檔案。",
"help.page.radar_today.step2": "從高意向開始:開原文 → 產生回覆 → 加入名單或略過。",
"help.page.radar_today.step3": "低意向預設收合;需要長期跟進的進「名單」看板。",
- "help.page.radar_today.tips": "想臨時掃痛點/話題用「海巡」;想每天穩收需求用這裡的訂閱。",
+ "help.page.radar_today.tips": "找客戶=這裡(訂閱/立即探索/匯入)。找內容話題用側欄「話題」。",
"help.page.radar_watches.title": "商機訂閱",
"help.page.radar_watches.what": "設定常駐關鍵字後,系統每日自動巡並寫入今日商機。這不是海巡的「按一次掃一次」。",
@@ -1392,7 +1392,16 @@ export const zhTW: MessageDict = {
"common.listSep": "、",
"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.purposeValue": "痛點回覆",
"scout.purposeDemand": "找需求痛點",
@@ -2151,6 +2160,7 @@ export const zhTW: MessageDict = {
"radar.watches.editTitle": "編輯商機訂閱",
"radar.watches.terms": "關鍵字",
"radar.watches.termsHint": "一行一個,也可用逗號分隔。客人會搜的說法,命中後進意向判定。",
+ "radar.watches.threadsWarn": "有關鍵字不合 Threads 短詞規則:每組最多 2 詞、中文每詞 2–4 字、整組 ≤12 字、勿用標點/#/emoji,否則常搜不到。",
"radar.watches.termsPh": "推薦室內設計\n找設計師",
"radar.watches.excludeTerms": "排除詞",
"radar.watches.excludeHint": "命中這些字就整筆跳過,例如同業自我推銷、抽獎文。",
@@ -2278,10 +2288,62 @@ export const zhTW: MessageDict = {
"radar.today.msg.copied": "已複製到剪貼簿",
"radar.today.msg.copyFail": "無法複製,請手動選取文字",
"radar.today.msg.marked": "已標記為已送出/已複製",
+ "radar.today.msg.sent": "已送出,稍後可在發送佇列查看進度",
"radar.today.msg.needReply": "請先產生回覆草稿",
+ "radar.today.sendAccount": "送出帳號",
"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.usedOutbox": "已送出(可在發送佇列查看)",
+
+ "radar.import.open": "手動匯入",
+ "radar.import.close": "收起手動匯入",
+ "radar.import.title": "手動匯入商機",
+ "radar.import.hint": "貼 Threads/Facebook 貼文網址與內文,跑同一套五問判定;沒有爬蟲能讀任意網址,內文請直接貼上。量體不足時可用這個補足每日名單。",
+ "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,author(author 選填)。第一列若含 url/text 表頭會自動辨識,沒有就照 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 個詞、中文每詞 2–4 字。",
+ "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.promoted": "已複製進今日商機({band} · {score}),可到側欄「商機」跟進",
@@ -2369,7 +2431,7 @@ export const en: MessageDict = {
"nav.studio": "Studio",
"nav.radar": "Demand",
"nav.crm": "CRM",
- "nav.scout": "Discover",
+ "nav.scout": "Topics",
"nav.outbox": "Outbox",
"nav.jobs": "Jobs",
"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.tips": "Studio drafts are not published until Outbox succeeds.",
- "help.page.scout.title": "Patrol (manual sweep)",
- "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.step1": "Write your intent (optional product), generate keywords, then edit the queries.",
+ "help.page.scout.title": "Topic ideas",
+ "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": "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.step3": "Worth long-term follow-up? Use “Save to Demand” to copy into today’s opportunities (scout status stays unchanged).",
- "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.step3": "Draft, open Threads to reply, mark done.",
+ "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.what": "A daily demand list from your keyword watches, scored high/mid/low. Unlike Patrol, you don’t re-scan by hand each time.",
- "help.page.radar_today.step1": "Check stats; if empty, open Demand watches or the service profile.",
+ "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, 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.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.what": "Always-on keywords swept daily into Today’s demand. This is not Patrol’s “run once” scan.",
@@ -3743,7 +3805,16 @@ export const en: MessageDict = {
"common.listSep": ", ",
"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.purposeValue": "Pain-point replies",
"scout.purposeDemand": "Find demand pains",
@@ -4506,6 +4577,7 @@ export const en: MessageDict = {
"radar.watches.editTitle": "Edit demand watch",
"radar.watches.terms": "Terms",
"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 2–4 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.excludeTerms": "Exclude terms",
"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.copyFail": "Could not copy — select the text manually",
"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.sendAccount": "Send from",
"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.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 today’s list. Max 2 words per query, 2–4 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.promoted": "Copied into Today’s demand ({band} · {score}) — follow up under Demand",
diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts
index 11af8f1..b439077 100644
--- a/apps/web/src/lib/nav.ts
+++ b/apps/web/src/lib/nav.ts
@@ -28,8 +28,9 @@ export const primaryNav: NavItem[] = [
{ key: "today", path: "/app/today", labelKey: "nav.today", label: "今日", en: "Today" },
{ key: "crew", path: "/app/crew", labelKey: "nav.crew", label: "帳號", en: "Crew" },
{ 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: "crm", path: "/app/crm", labelKey: "nav.crm", label: "名單", en: "CRM" },
{ 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" },
];
-/** 手機底欄固定 4 格(主流程;T553:radar 進主四格,scout 移入更多) */
+/** 手機底欄固定 4 格(主流程;radar 進主四格,話題移入更多) */
export const mobileDockPrimaryKeys: NavKey[] = ["today", "studio", "radar", "outbox"];
/** 手機底欄「更多」內項目 */
@@ -83,7 +84,7 @@ export type NavGroup = {
keys: NavKey[];
};
-/** 側欄二級分類:主流程 → 帳號品牌 → 成長工具,避免 11 項全部散在同一層 */
+/** 側欄二級分類:主流程(商機→名單)→ 帳號品牌 → 成長工具;話題放創作側(workflow 內 studio 旁) */
export const navGroups: NavGroup[] = [
{ key: "workflow", labelKey: "navGroup.workflow", keys: ["today", "studio", "scout", "radar", "crm", "outbox", "jobs"] },
{ key: "accounts", labelKey: "navGroup.accounts", keys: ["crew", "brands"] },
diff --git a/apps/web/src/lib/threadsTerm.test.ts b/apps/web/src/lib/threadsTerm.test.ts
new file mode 100644
index 0000000..8f54950
--- /dev/null
+++ b/apps/web/src/lib/threadsTerm.test.ts
@@ -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" });
+ });
+});
diff --git a/apps/web/src/lib/threadsTerm.ts b/apps/web/src/lib/threadsTerm.ts
new file mode 100644
index 0000000..1163ca6
--- /dev/null
+++ b/apps/web/src/lib/threadsTerm.ts
@@ -0,0 +1,75 @@
+/**
+ * Threads 短詞規則(與後端 domain.IsThreadsSearchable 對齊)。
+ * 一組查詢 ≤2 token;中文每詞 2–4 字;整組去掉空格後 ≤12 字;禁標點/boolean/emoji/#。
+ */
+
+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;
+}
diff --git a/apps/web/src/pages/RadarTodayPage.test.tsx b/apps/web/src/pages/RadarTodayPage.test.tsx
index 9fae424..575ad21 100644
--- a/apps/web/src/pages/RadarTodayPage.test.tsx
+++ b/apps/web/src/pages/RadarTodayPage.test.tsx
@@ -41,6 +41,11 @@ function sampleOpp(id: string, band: "high" | "mid" | "low", score: number): Opp
vi.mock("../data/DataContext", () => ({
useRepos: () => ({
+ accounts: {
+ async list() {
+ return [];
+ },
+ },
radar: {
async getToday() {
if (!backend.today) throw new Error("no today");
diff --git a/apps/web/src/pages/RadarTodayPage.tsx b/apps/web/src/pages/RadarTodayPage.tsx
index 6ccdc71..eab1f92 100644
--- a/apps/web/src/pages/RadarTodayPage.tsx
+++ b/apps/web/src/pages/RadarTodayPage.tsx
@@ -5,7 +5,9 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
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 { useRepos } from "../data/DataContext";
import type {
@@ -15,6 +17,7 @@ import type {
RadarToday,
ReplyVariant,
ReplyVariantKind,
+ ThreadsAccount,
} from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { useFormatApiError } from "../lib/apiErrors";
@@ -60,6 +63,7 @@ function OppCard({
o,
reply,
busy,
+ canSendOutbox,
onAccept,
onDismiss,
onGenerateReply,
@@ -70,6 +74,8 @@ function OppCard({
o: Opportunity;
reply?: ReplyVariant;
busy: string;
+ /** false=沒有可用的已連 Threads 帳號,一鍵送出要停用並指路去連帳號 */
+ canSendOutbox: boolean;
onAccept: () => void;
onDismiss: () => void;
onGenerateReply: (variant: ReplyVariantKind) => void;
@@ -173,7 +179,8 @@ function OppCard({
) : null}
- {/* ① 今日設定 */}
+ {/* ① 今日設定 — 僅話題靈感(activity) */}
-
- {
- setPurpose("demand");
- setProductId((current) => current || allProducts[0]?.id || "");
- clearWorkshop();
- const t = loadScoutToday();
- setGoal(t.goalValue);
- }}
- >
- {t("scout.purposeDemand")}
-
- {
- setPurpose("provider");
- setProductId((current) => current || allProducts[0]?.id || "");
- clearWorkshop();
- }}
- >
- {t("scout.purposeProvider")}
-
- {
- setPurpose("activity");
- setProductId("");
- clearWorkshop();
- const t = loadScoutToday();
- setGoal(t.goalActivity);
- }}
- >
- {t("scout.purposeActivity")}
-
-
+
+ {t("scout.topic.intro")}
+
- {purpose === "activity" ? (
- ) : null}
-
- {purpose !== "activity" ? (
- <>
-
- {selectedProduct ? (
-
- {t("scout.providerProduct", { label: selectedProduct.label })}
- {selectedProduct.pain_points?.[0]
- ? t("scout.painPart", { pain: selectedProduct.pain_points[0] })
- : ""}
-
- ) : null}
- {purpose === "provider" && selectedProduct && !isProviderReady(selectedProduct) ? (
-
- {t("scout.providerSetupRequired")} {t("nav.brands")}
-
- ) : null}
- {allProducts.length === 0 ? (
-
- {t("scout.noProductsBefore")}{" "}
- {t("nav.brands")}{" "}
- {t("scout.noProductsAfter")}
-
- ) : null}
- >
- ) : null}
void planKeywords()}
- disabled={busy === "plan" || busy === "run" || (purpose === "activity" ? !intent.trim() : !productId)}
+ disabled={busy === "plan" || busy === "run" || !intent.trim()}
>
{busy === "plan"
? t("scout.planning")
: workshopBrief
? t("scout.replan")
- : pendingQueue.length
- ? t("scout.planKeywords")
- : t("scout.planKeywords")}
+ : t("scout.planKeywords")}
@@ -880,39 +797,68 @@ export function ScoutPage() {
- {t("scout.workshopHint")}
+ {t("scout.topic.workshopHintSelect")}
- {workshopTerms.map((term, idx) => (
-
+ );
+ })}
setNewTerm(e.target.value)}
+ onChange={(e) => {
+ setNewTerm(e.target.value);
+ setTermDraftErr("");
+ }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -925,6 +871,15 @@ export function ScoutPage() {
{t("scout.addTerm")}
+ {termDraftErr ? (
+
+ {termDraftErr}
+
+ ) : (
+
+ {t("scout.topic.termHint")}
+
+ )}
s.trim()).filter(Boolean).length === 0
}
>
@@ -1017,8 +973,7 @@ export function ScoutPage() {
) : null}
- {stanceOf(current, t)}
- {current.matched_product_label ? ` · ${current.matched_product_label}` : ""}
+ {t("scout.stanceActivity")}
{activeRunMeta ? ` · ${shortRunLabel(activeRunMeta.label, 18)}` : ""}
@@ -1039,74 +994,51 @@ export function ScoutPage() {
{current.match_reason}
) : null}
- {current.opportunity ? (
-
- {current.opportunity}
-
- ) : null}
- {current.scout_mode !== "provider" ?