23 KiB
相似帳號 / 對標帳號 / 找 TA — 改進計畫
⚠ 換手進度(2026-06-28)
✅ Phase 1 已完成(全部測試通過)
make fmt && make test 全 ok;make build-frontend 通過。go build ./... 通過。
已完成項目:
- 1.1 名額放寬:
MaxSimilarAccounts5 → 10(backend/internal/library/viral/discover_accounts.go:15)。 - 1.2 停止清空:
analyze_copy_mission.go不再SimilarAccounts = nil,改用similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)保留前次(backend/internal/worker/job/analyze_copy_mission.go:128)。 - 1.3 合併取代覆蓋:
scan_viral.go改libviral.MergeSimilarAccounts(prev, new)(backend/internal/worker/job/scan_viral.go:195)。 - 1.4 排序權重抽出:
reference_accounts.go新增ReferenceRankWeights+DefaultReferenceRankWeights+rankScore(log-scale follower bucket 1k/10k/100k/1M、topicHits 加權),排序用加權和;postTopicRelevant改為topicTopicHits回傳 hits 並用normalisedTopicTermslower-case 取代topicTerms。 - 1.5 web search fallback:
scan_viral.go:170BuildReferenceAccountsFromScan後若len(referenceAccounts) < MaxSimilarAccounts且placement.WebSearchAvailable(research)→ 呼supplementSimilarAccountsFromWeb(scan_viral.go:369),透過websearch.New(memberCtx.WebSearchConfig())建 client →DiscoverSimilarAccounts→MergeSimilarAccounts串接。失敗安全:web search 失敗不讓 job fail。 - 1.6 ProfileURL helper:新檔
backend/internal/library/threadsapi/url.goProfileURLFromPermalink(permalink, username),優先用 permalink 反推、fallbackhttps://www.threads.com/@<u>(.com為 canonical host)。reference_accounts.go:137、enrich_accounts.go、discover_accounts.go已改用。 - 1.7 Brave StartPublishedDate:
brave.SearchOptions加StartPublishedDate string欄位(forward-compat,目前 brave client 尚未傳給 API);websearch/client.gobraveAdapter 把opts.StartPublishedDate塞進 libbrave.SearchOptions。Exa 原本就有支援。 - 1.8 前端顯示:
frontend/src/types/copyMission.tsCopySimilarAccountData加matched_source?: string[]+similarAccountSourceTier/similarAccountSourceLabelhelper;frontend/src/lib/viralSignals.ts加similarAccountTone;CopyMissionDetailPage.tsx:837-878row 加「來源」badge(scan+web/sky/success、web/sky、scan/brand)、「置信度」小字。 - schema:
missionentity.SimilarAccount加MatchedSource []string(backend/internal/model/copy_mission/domain/entity/mission.go:27);domusecase.SimilarAccountSummary同步;usecase.toSummary帶 matched;types.CopySimilarAccountData同步;logic/copy_mission/mapper.goentity↔API 兩處對映帶 matched;generate/api/copy_mission.api的CopySimilarAccountData同步(手動補,未跑make gen-api——必要時下一手可跑 gen-api 重新產生以對齊)。 - 新檔:
backend/internal/library/viral/merge_accounts.goMergeSimilarAccounts+unionMatchedSource。backend/internal/library/threadsapi/url.goProfileURLFromPermalink。backend/internal/worker/job/similar_accounts.gosimilarAccountsFromSummary(summary→entity 轉換 helper,給 worker 跨 rerun 保留 Previous)。
未做、給下一手
- Phase 2:相似帳號變一等公民(
Status/TopicRelevance/LastSeenAtschema + PATCH API + worker 尊重 excluded/pinned + 前端釘選/排除按鈕 + Placement 維持不動)。詳見下方 Phase 2 章節。 - Phase 3:TA 樣本(留言作者採樣,
AudienceSample+audience.go+ worker 接入 + PATCH API + 前端「TA 樣本」區)。詳見下方 Phase 3 章節。 - Phase 4:跨任務記憶(後續,未規劃細節)。
- 重新 gen-api:
generate/api/copy_mission.api只有CopySimilarAccountData加matched_source一處,types.go 已手動同步。若下一手要擴 API schema(Phase 2),建議跑make gen-api重新產 handler/logic/types 確保對齊。 - 新測試:Phase 1 沒額外加新 test case。建議下一手補:
viral/merge_accounts_test.go(MergeSimilarAccountsmerges、preserves prev、truncates to limit、unionMatchedSource dedup)、viral/reference_accounts_test.go新排序 case(topicHits 加權、log-scale follower bucket)、worker/job/scan_viral_test.goweb fallback case(mock websearch client)。這些 test 是 Phase 1 的「補強」層,不阻塞 Phase 2 進行。
已動的檔案清單(Phase 1)
Go 後端
backend/internal/library/viral/discover_accounts.go(常數、ProfileURL helper、accountCandidate.permalink)backend/internal/library/viral/reference_accounts.go(ReferenceRankWeights、topicHits、MatchedSource、ProfileURL helper、刪 topicTerms)backend/internal/library/viral/enrich_accounts.go(MatchedSource、ProfileURL helper、authorScore.permalink)backend/internal/library/viral/merge_accounts.go(新檔)backend/internal/library/threadsapi/url.go(新檔)backend/internal/library/websearch/client.go(braveAdapter 傳 StartPublishedDate)backend/internal/library/brave/client.go(SearchOptions 加 StartPublishedDate)backend/internal/worker/job/scan_viral.go(web fallback + MergeSimilarAccounts + supplementSimilarAccountsFromWeb)backend/internal/worker/job/analyze_copy_mission.go(停止清空 SimilarAccounts)backend/internal/worker/job/similar_accounts.go(新檔:similarAccountsFromSummary)backend/internal/model/copy_mission/domain/entity/mission.go(SimilarAccount 加 MatchedSource)backend/internal/model/copy_mission/domain/usecase/usecase.go(SimilarAccountSummary 加 MatchedSource)backend/internal/model/copy_mission/usecase/usecase.go(toSummary 帶 matched)backend/internal/types/types.go(CopySimilarAccountData 加 matched_source)backend/generate/api/copy_mission.api(CopySimilarAccountData 加 matched_source)backend/internal/logic/copy_mission/mapper.go(兩處對映帶 matched)
前端
frontend/src/types/copyMission.ts(CopySimilarAccountData 加 matched_source + helper)frontend/src/lib/viralSignals.ts(similarAccountTone)frontend/src/pages/CopyMissionDetailPage.tsx(row 顯示 source badge、置信度小字)
設計選擇備忘
similarAccountsFromSummary只帶 summary 可見欄位(沒TopicRelevance/LastSeenAt/Status,那些是 Phase 2)。Phase 1 schema 只補MatchedSource,其他 Phase 2 欄位別在 Phase 1 先加,避免污染。- 排序權重改成 log-scale follower bucket(1k/10k/100k/1M cut),避免 mega 帳號壓過 niche 高相關作者。
ReferenceRankWeights結構在reference_accounts.go內部,未注入 input,未來要調整再加。 ProfileURL從https://www.threads.net/@改https://www.threads.com/@(threads.com 現在是 canonical host,前端frontend/src/lib/threadsLinks.ts用threadsProfileUrl(username, profileUrl)已能接受profile_urlinjected,不需改)。MergeSimilarAccounts串接 priority:新(scan+web)> 舊(保舊不被清掉),命中兩條 source 的 usernameMatchedSource會 union 含 scan 與 web,但 confidence 不會自動升 high(confidence 由BuildReferenceAccountsFromScan內 strict/relaxed gate + verified + bestEngagement 算出來)。若未來要把「跨來源」當 high confidence 的訊號,需在MergeSimilarAccounts或 mapper 後處理;目前只透過前端MatchedSourcebadge 視覺呈現。- web search fallback 只在
< MaxSimilarAccounts(=10)時觸發,避免每次 scan 都額外打 web 浪費 quota;也避免 web 來的帳號擠掉 scan 來的(因為 new 排前面)。 - Brave
StartPublishedDate欄位加了但沒 map 到 Brave 的freshness參數(Brave API freshness 接受d/w/m/y而非 ISO date,要做轉換)。Phase 1 只做資訊不丟失;實際生效需要第二階段處理 Brave adapter 的freshnessmapping。Exa 已有支援。
沒動的檔案(確認 boundary)
- Placement / Persona / Brand schema 完全沒動。
- 沒碰
internal/handler/routes.go、internal/svc/service_context.go、internal/response、internal/middleware。 - 沒動
internal/model/copy_mission/repository、internal/model/copy_mission/domain/repository。
範圍:僅 CopyMission flow。Placement(B 流海巡獲客)刻意維持「無對標帳號」設計,本計畫不動其 schema。 上限:
MaxSimilarAccounts自 5 放寬到 10。 遵循:code/message/dataenvelope、page/pageSize、SSCCCDDD錯誤碼、UTC+0 nanoseconds、RedisworkerIDlock、不裸Updatejob 狀態。 驗證:go mod tidy && make fmt && go test ./...;前端動到另執行make web-build。
背景:threads-api-skill repo 對位
參考 repo madebypan/threads-api-skill 是純發布工具包(容器→publish 30s wait、reply_to_id 串貼、catbox.moe 圖床、60 天 token refresh、Chrome 自動化 setup),沒有「找相似帳號 / 找對標 / 找 TA」功能。它整理的 Threads Graph API endpoint 與踩坑,可用來校正下列事實:
me?fields=id,name,username,...取 self 資訊 → 目前threadsapi/client.go完全沒用,無法驗 token 對應 username。keyword_search(TOP/RECENT,50/page,無 cursor)→ 已實作;目前沒做翻頁,召回量受限。media/{id}/replies→ 已實作,目前只當 outreach 標的,沒把留言作者彙整成 TA 樣本。- Threads Graph API 沒有 endpoint 查任意別人的 follower / 受眾輪廓 → 「找 TA」必須繞道:把留言作者/互動者彙整成潛在受眾樣本。
目前狀態(viral 流程摸整)
- 唯一活著的相似帳號產生路徑:
scan_viral.gojob →libviral.RunDiscover(抓 threads 貼文)→BuildReferenceAccountsFromScan(by author 聚合 + verified/follower/engagement 排序,strict gate→relaxed fallback)→ 寫入Mission.ResearchMap.SimilarAccounts(reference_accounts.go:43)。 DiscoverSimilarAccounts/EnrichSimilarAccounts/ToEntitySimilarAccounts是 dead code(只有測試呼叫)。analyze_copy_mission.go:138顯式researchMap.SimilarAccounts = nil→ 重新跑研究地圖 job 會清空相似帳號,前端沒警告。- API 對相似帳號純唯讀:
UpdateCopyMissionReq(copy_mission.api:60-72)沒similar_accounts欄位,使用者無法排除/釘選。 - 「找 TA」目前只是 LLM 產出的一段文字
AudienceSummary,沒有任何「找出受眾樣本帳號」的機制,連結構都沒有。 - 驗證缺位:
ProfileURL一律https://www.threads.net/@<username>拼字串(reference_accounts.go:137、enrich_accounts.go:77、discover_accounts.go:119),不驗真。 - 純 Threads API 模式下
AuthorVerified=false, FollowerCount=0恆成立,排序權重失衡。 - 排序權重寫死:
reference_accounts.go:108-119固定 verified→follower→engagement,沒有主題 pillar 相似度、沒有跨任務記憶、沒有使用者可調。 postTopicRelevant(reference_accounts.go:166-180)用裸strings.Contains,中文長 label 命中精度低。
Phase 1 — 資料品質低風險修正(先做)
1.1 名額放寬
backend/internal/library/viral/discover_accounts.go:14:MaxSimilarAccounts = 5→10。scan_viral.go:177:Limit: libviral.MaxSimilarAccounts維持引用常數。- 前端已用 list render,不需改。
1.2 停止 analyze_copy_mission 清空相似帳號
backend/internal/worker/job/analyze_copy_mission.go:138:刪researchMap.SimilarAccounts = nil。- 確認
model/copy_mission/usecase/usecase.godot-path 更新其他 research_map 欄位時不會覆蓋similar_accounts(worker 才會用Patch.ResearchMap整包,且 worker 現在會保留 SimilarAccounts)。
1.3 scan 結果改合併而非覆蓋
scan_viral.go:189-197:SimilarAccounts: referenceAccounts→SimilarAccounts: mergeSimilarAccounts(mission.ResearchMap.SimilarAccounts, referenceAccounts)。- 新檔
backend/internal/library/viral/merge_accounts.go:by username 合併,新來的更新統計、舊有保留;Phase 1 schema 還沒Status,先純保舊不被清掉。 mergeSimilarAccounts(prev, new []missionentity.SimilarAccount) []missionentity.SimilarAccount:- key = lowercase username;新>舊(更新統計欄位),舊沒被新覆蓋就保留。
- 排序:保留新來秩序在前 N 個,舊的接在後面直到達
MaxSimilarAccounts。
1.4 排序權重抽出 + 主題相關改用命中詞數
reference_accounts.go:108-119:抽出ReferenceRankWeightsstruct(VerifiedW/FollowerW/TotalEngagementW/BestEngagementW/TopicRelevanceW,預設4/2/1/1/2),排序 key = 加權和。reference_accounts.go:166-180postTopicRelevant:仍維持「命中詞數 >= 1 才納入」,但額外回傳topicHitCount給排序用。referenceAuthorAgg加topicHits int;BuildReferenceAccountsFromScan內部迴圈累加。 -硼切詞暫不引入 jieba:對 CJK 用strings.Fields+ 拆字元組(>=2 字才當 term)即可,避免新依賴。
1.5 web search 補洞(復活 dead code 線)
scan_viral.go:172BuildReferenceAccountsFromScan之後,若len(referenceAccounts) < 5且memberCtx.WebSearchEnabled():extra, err := libviral.DiscoverSimilarAccounts(ctx, websearchClient, libviral.DiscoverAccountsInput{ SeedQuery: mission.SeedQuery, Brief: mission.Brief, Pillars: mission.ResearchMap.Pillars, }) if err == nil && len(extra) > 0 { referenceAccounts = libviral.EnrichSimilarAccounts(referenceAccounts, nil, libviral.MaxSimilarAccounts) // 把 web 找到的也 by username 合併進去 }enrich_accounts.go:Source改成集合概念:viral.SimilarAccount與missionentity.SimilarAccount都加MatchedSource []string,命中 web 也命中 scan =confidence="high"。- web search 失敗不可讓 scan job 失敗,只跳過並 log。
1.6 threadsapi profile URL helper
- 新檔
backend/internal/library/threadsapi/url.go:ProfileURLFromPermalink(permalink, username) string:- regex
threads\.(?:com|net)/@([^/]+)/post/從 permalink 抓 author roothttps://www.threads.com/@<u>/。 - fallback
https://www.threads.com/@<username>(注意www.threads.com而非www.threads.net較新且穩)。
- regex
reference_accounts.go:137、enrich_accounts.go:77、discover_accounts.go:119改用此 helper。
1.7 Brave adapter 補 StartPublishedDate
backend/internal/library/websearch/client.go:114-127Brave adapter:把SearchOptions.StartPublishedDate(即便 Brave API 不支援)也讀進libbrave.SearchOptions,讓未來 Brave 端支援時自動生效。實作細節:libbrave.SearchOptions 若沒對應欄位就先無效果,但頂層 client 不能丟失資訊。
1.8 前端顯示新增資訊
frontend/src/pages/CopyMissionDetailPage.tsx:837-875row:加sourcebadge(已有AccountSource文字時顯示brand/neutral)、confidence用text-muted小字。frontend/src/lib/viralSignals.ts:42-48viralAccountRowClass:依 Phase 1 schema 補MatchedSource→ 範含scan與web用--verified-hot樣式。frontend/src/types/copyMission.ts:SimilarAccount加matched_source?: string[](Phase 1 只補這欄位,舊資料缺少不影響渲染)。- 執行
make web-build。
Phase 2 — 相似帳號變一等公民(動 API,CopyMission only)
2.1 schema 變更
backend/internal/model/copy_mission/domain/entity/mission.go:23-35 SimilarAccount 加:
Status string `bson:"status,omitempty" json:"status,omitempty"` // recommended|pinned|excluded|promoted
TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
internal/types/types.go 同步(重新 gen-api)。
2.2 API 新增
backend/generate/api/copy_mission.api 加:
@handler patchCopyMissionSimilarAccount
patch /personas/:persona_id/copy-missions/:copy_mission_id/similar-accounts/:username (PatchSimilarAccountReq) returns (CommonRes)
type PatchSimilarAccountReq {
Status string `json:"status"`
}
跑 make gen-api 重新產 handler/logic/types。logic 走 dot-path:
out[fmt.Sprintf("research_map.similar_accounts.%s.status", username)] = strings.TrimSpace(req.Status)
錯誤碼:CopyMission 目前沒有專屬 scope,Phase 2 用 Facade (10) 暫時頂著,待新增 CopyMission Scope=41 後再回頭改。禁止裸寫數字,一律 errs.For(code.Facade).ResInvalidState("similar account status not allowed")。
2.3 worker 尊重 status
reference_accounts.go:54開頭:if isExcluded(in.ExcludedUsernames, post.Author) { continue }。scan_viral.go:170:把mission.ResearchMap.SimilarAccounts中status=excluded的 username 傳進ReferenceAccountInput.ExcludedUsernames。merge_accounts.go:status=pinned永遠置頂,本次沒掃到也保留。
2.4 前端互動
CopyMissionDetailPage.tsxrow 加兩按鈕「釘選」「排除」,呼api.patch(.../similar-accounts/:username, {status}),本地 optimistic update。- 新檔
frontend/src/lib/copyMissionSimilarAccounts.ts包 API client。 CopyMissionResearchOverview.tsx加「相似帳號」子區(從 detail 頁整合進來)。
Phase 3 — TA 樣本(留言作者採樣,全新功能)
Threads API 拿不到任意別人的受眾 → 繞道:用 MediaReplies 的 ReplyCandidate.Author 聚合成潛在受眾樣本。
3.1 schema 變更
backend/internal/model/copy_mission/domain/entity/mission.go ResearchMap 加:
AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
新 struct:
type AudienceSample struct {
Username string `bson:"username" json:"username"`
SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
}
generate/api/copy_mission.api 在 CopyResearchMap 加 AudienceSamples []CopyAudienceSampleData;make gen-api。
3.2 新 library
backend/internal/library/viral/audience.go:
func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample
appearance = by username.Count- 過濾:自己 mission 的 author(即 SimilarAccount source)排除、純連結/數字罐頭 reply 文字過濾(
len < 5 charsor URL-only)。 - 排序
appearance desc → replyLikeCount desc → postedAt desc,前 15 筆。 - confidence(前端呈現用,不入 struct):
appearance >= 3high、= 2medium、= 1low。
3.3 worker 接入
scan_viral.go 在 AttachReplies 完成後:
var allReplies []placement.ReplyCandidate
for _, p := range postsWithReplies {
allReplies = append(allReplies, p.Replies...)
}
samples := libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{
Max: 15,
ExcludeAuthors: similarAccountUsernames,
})
researchMap.AudienceSamples = mergeAudienceSamples(prev.Samples, samples)
合併邏輯與 merge_accounts.go 同款。
3.4 API
GET /copy-missions/:id已含(在research_map.audience_samples)。PATCH /copy-missions/:id/audience-samples/:usernamebody{status},dot-path 更新research_map.audience_samples.<username>.status。make gen-api+ 邏輯同 2.2。
3.5 前端
CopyMissionResearchOverview.tsx新區「TA 樣本」(在「相似帳號」之後):- 每筆
@username(連threadsProfileUrl)、出現 N 篇、最近一次樣本文字。 - 按鈕「收藏為長期受眾」(status=pinned)、「忽略」(status=excluded)。
- 每筆
- 新檔
frontend/src/lib/copyMissionAudience.ts包 API client。 frontend/src/index.css加.hx-audience-samples*token,沿用hx-copy-similar-accounts結構。
3.6 Phase 3 驗收
make web-build過、go test ./...過。- E2E:開 Threads API 連線 → 跑 scan job → detail page 顯示「TA 樣本」最多 15 筆、@username 連結不 404、可釘選後重跑 scan 不消失。
Phase 4 — 跨任務記憶(後續,本計畫不實作)
ThreadsAccount 層加 KnownAccounts map[username]AccountProfile{ FirstSeenAt, LastSeenAt, Missions[], PersonaIds[], Tags[], AvgEngagement, Status },scan job 結束 upsert。同一 username 在多 mission 出現 → confidence 累積、auto-suggest 為 persona benchmark;已 excluded 跨任務繼承。
移交檢查表
要動的檔案(Phase 1)
Go 後端
internal/library/viral/discover_accounts.go(常數、ProfileURL helper)internal/library/viral/reference_accounts.go(排序權重、topicHits)internal/library/viral/enrich_accounts.go(MatchedSource 集合)internal/library/viral/merge_accounts.go(新檔)internal/library/threadsapi/url.go(新檔,profileURL helper)internal/library/websearch/client.go(Brave 補 StartPublishedDate)internal/worker/job/scan_viral.go(web 補洞、merge 取代覆蓋)internal/worker/job/analyze_copy_mission.go:138(停止清空)internal/model/copy_mission/domain/entity/mission.go(SimilarAccount 加 MatchedSource)internal/types/types.go同步(gen-api 或手動加)
前端(Phase 1.8)
src/pages/CopyMissionDetailPage.tsx(顯示 source/confidence)src/lib/viralSignals.ts(MatchedSource badge 對應)src/types/copyMission.ts(加 matched_source)
要動的檔案(Phase 2-3,後續 agent)
model/copy_mission/domain/entity/mission.go(Status、TopicRelevance、LastSeenAt、AudienceSample、AudienceSamples)generate/api/copy_mission.api(兩支 PATCH endpoint + types)internal/logic/copy_mission/patch_similar_account_logic.go+patch_audience_sample_logic.go(gen 產出後手寫)internal/library/viral/audience.go(新檔)internal/worker/job/scan_viral.go(excluded 傳遞、audience samples、pinned 保留)frontend/src/components/CopyMissionResearchOverview.tsx(整合區)frontend/src/lib/copyMissionSimilarAccounts.ts、copyMissionAudience.ts(新)frontend/src/index.css(.hx-audience-samples*)
錯誤碼配置
- Phase 2 patch similar 錯誤:CopyMission 無專屬 scope → 先用
errs.For(code.Facade).ResInvalidState("similar account status not allowed");待新增CopyMission=41後再回頭改。禁止裸寫數字。
失敗安全
- 任何 web search 呼叫失敗不能讓 scan job fail,只跳過。
mergeSimilarAccounts必須處理 prev=nil。AudienceSamples(Phase 3)在無 Threads API token 時直接跳過整段,不報錯。