/// @ 1.2.1 /// シビュラシステム — フォローリクエスト監視・自動執行ウィジェット /// 保留中のフォローリクエストを定期スキャンし、リクエスト元の犯罪係数 /// (スパム傾向・信頼度スコア) を算出して一覧表示する。 /// スコアリングは X (Twitter) 公開アルゴリズムの tweepcred /// (the-algorithm: UserMass.scala / Reputation.scala) と SpamVector の写像。 /// dominator プラグイン (ユーザーメニュー単発スキャン) と同一の算出式。 /// /// - 承認 / 拒否ボタンと自動執行 (係数300以上を自動拒否・デフォルト OFF) には /// account.write が必要 → permissions.json5 の plugin プロファイルを full にする /// - 一覧取得と採点は account.read / allow のみで動く /// - スコアはこのウィジェット内に閉じる (公開・共有機能は無い) /// /// maxStep (生涯累積 100000) 対策: 採点結果はユーザー単位でキャッシュし、 /// リクエスト一覧が変わらない間のポーリングは署名比較 + 時刻表示更新のみ。 /// TICK_LIMIT 到達で interval を自前停止して再読み込みを案内する (wellbeing 方式)。 let POLL_MS = 180000 let TICK_LIMIT = 220 let MUTED = "8a96a3" let LINE = "5a6573" let CYAN = "7ac7ff" let YELLOW = "ffb300" let RED = "ff6961" let AUTO_KEY = "sibyl:auto" // ── 信用度 (0..1) — tweepcred の写像 ────────────────── // 詳細な出典と定数の根拠は dominator プラグインのコメント参照 (同一実装) @computeTrust(user, ageDays) { if (Obj:has(user, "isSuspended") && user.isSuspended) return 0 var trust = 0.05 if (user.avatarUrl != null && user.avatarUrl.index_of("identicon") == -1) { trust += 0.25 } if (user.description != null && user.description != "") { trust += 0.15 } if (user.notesCount >= 10) { trust += 0.15 } if (ageDays >= 0) { let ageFactor = Math:min(1, Math:log(1 + ageDays / 15)) trust = trust * ageFactor } let followers = user.followersCount let following = user.followingCount if (following > 250) { let ratio = (following + 1) / (followers + 1) if (ratio > 2) { var div = Math:exp(1.5 * (ratio - 2) * Math:log(Math:log(following))) if (div > 50) { div = 50 } if (div < 1) { div = 1 } trust = trust / div } } if (Obj:has(user, "isFollowed") && user.isFollowed) { trust += 0.35 } if (followers >= 1000) { trust += 0.3 } elif (followers >= 100) { trust += 0.15 } if (trust > 1) { trust = 1 } if (Obj:has(user, "isSilenced") && user.isSilenced) { trust = trust * 0.1 } if (following <= 100 && trust < 0.17) { trust = 0.17 } trust } @analyzeNotes(notes, trust) { var penalty = 0 var reasons = [] if (notes.len == 0) return { penalty: 0, reasons: [] } var linkNotes = 0 var linkNoEngage = 0 var mentionTotal = 0 var texts = {} var dup = false each (let n, notes) { var t = "" if (Obj:has(n, "text") && n.text != null) { t = n.text } var eng = 0 if (Obj:has(n, "renoteCount") && n.renoteCount != null) { eng += n.renoteCount } if (Obj:has(n, "repliesCount") && n.repliesCount != null) { eng += n.repliesCount } if (Obj:has(n, "reactions") && n.reactions != null) { eng += Obj:keys(n.reactions).len } if (t.index_of("http") != -1) { linkNotes += 1 if (eng == 0) { linkNoEngage += 1 } } mentionTotal += (t.split("@").len - 1) if (t != "") { let k = t.slice(0, 40) if (Obj:has(texts, k)) { dup = true } else { Obj:set(texts, k, true) } } } if (trust < 0.25 && linkNoEngage * 2 > notes.len) { penalty += 80 reasons.push("低信用×リンク×無反応") } elif (linkNotes * 2 > notes.len) { penalty += 30 reasons.push("リンク投稿多め") } let mentionAvg10 = Math:floor(mentionTotal * 10 / notes.len) if (mentionAvg10 >= 30) { penalty += 50 reasons.push("メンション乱発") } elif (mentionAvg10 >= 15) { penalty += 25 reasons.push("メンション多め") } if (dup) { penalty += 40 reasons.push("同一文面の反復") } { penalty: penalty, reasons: reasons } } @scoreUser(user, notes) { var ageDays = -1 let created = Date:parse(user.createdAt) if (Core:type(created) == "num") { ageDays = Math:floor((Date:now() - created) / 86400000) } let trust = computeTrust(user, ageDays) let behavior = analyzeNotes(notes, trust) var reasons = [] reasons.push(`信用度{Math:round(trust * 100)}`) if (ageDays >= 0 && ageDays < 30) { reasons.push(`作成{ageDays}日`) } each (let r, behavior.reasons) { reasons.push(r) } var coeff = Math:round((1 - trust) * 160) + behavior.penalty if (Obj:has(user, "isBot") && user.isBot) { coeff += 15 reasons.push("bot") } if (coeff < 0) { coeff = 0 } if (coeff > 400) { coeff = 400 } { value: coeff, reasons: reasons } } // ── 状態 ───────────────────────────────── var pending = [] var scoreCache = {} var lastSig = "(init)" var autoMode = false var tickCount = 0 var stopTick = null var statusC = null let savedAuto = Mk:load(AUTO_KEY) if (savedAuto != null && Core:type(savedAuto) == "bool") { autoMode = savedAuto } @pad(n) { if (n < 10) `0{n}` else `{n}` } @coeffColor(coeff) { if (coeff >= 300) RED elif (coeff >= 100) YELLOW else CYAN } @acctLabel(user) { if (Obj:has(user, "host") && user.host != null) `@{user.username}@{user.host}` else `@{user.username}` } // ── UI ─────────────────────────────────── @statusText() { let now = Date:now() let time = `{pad(Date:hour(now))}:{pad(Date:minute(now))}` if (pending.len == 0) `$[fg.color={MUTED} 保留中のフォローリクエストなし · 最終スキャン {time}]` else `$[fg.color={MUTED} 保留中 {pending.len} 件 · 最終スキャン {time}]` } @rebuild() { var body = [] body.push(Ui:C:mfm({ text: `