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 ? ( +
+