semantic bufo search find-bufo.com
bufo
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(bot): add nsfw filtering and polish stats dashboard

add keyword and self-label filtering to skip nsfw posts from the
firehose. update stats dashboard with source links, show all matched
bufos instead of capping at 20, and note the filtering policy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 04661efc f09dc083

+90 -7
+65
bot/src/jetstream.zig
··· 3 3 const json = std.json; 4 4 const posix = std.posix; 5 5 const Allocator = mem.Allocator; 6 + 7 + const nsfw_labels: []const []const u8 = &.{ 8 + "porn", 9 + "sexual", 10 + "nudity", 11 + "nsfl", 12 + "gore", 13 + }; 14 + 15 + // hashtags/keywords to filter in post text (lowercase) 16 + const nsfw_keywords: []const []const u8 = &.{ 17 + "#nsfw", 18 + "#porn", 19 + "#xxx", 20 + "#18+", 21 + "#adult", 22 + "#onlyfans", 23 + "#sex", 24 + "#nude", 25 + "#nudes", 26 + "#naked", 27 + "#fetish", 28 + "#kink", 29 + }; 6 30 const websocket = @import("websocket"); 7 31 8 32 pub const Post = struct { ··· 125 149 const record = commit.object.get("record") orelse return error.NotAPost; 126 150 if (record != .object) return error.NotAPost; 127 151 152 + // check for nsfw labels 153 + if (hasNsfwLabels(record.object)) return error.NotAPost; 154 + 128 155 // get text 129 156 const text_val = record.object.get("text") orelse return error.NotAPost; 130 157 if (text_val != .string) return error.NotAPost; 158 + 159 + // check for nsfw keywords in text 160 + if (hasNsfwKeywords(text_val.string)) return error.NotAPost; 131 161 132 162 // construct uri 133 163 var uri_buf: [256]u8 = undefined; ··· 141 171 }); 142 172 } 143 173 }; 174 + 175 + fn hasNsfwLabels(record: json.ObjectMap) bool { 176 + // labels structure: { "values": [{ "val": "porn" }, ...] } 177 + const labels = record.get("labels") orelse return false; 178 + if (labels != .object) return false; 179 + 180 + const values = labels.object.get("values") orelse return false; 181 + if (values != .array) return false; 182 + 183 + for (values.array.items) |item| { 184 + if (item != .object) continue; 185 + const val = item.object.get("val") orelse continue; 186 + if (val != .string) continue; 187 + 188 + for (nsfw_labels) |label| { 189 + if (mem.eql(u8, val.string, label)) return true; 190 + } 191 + } 192 + return false; 193 + } 194 + 195 + fn hasNsfwKeywords(text: []const u8) bool { 196 + // convert to lowercase for case-insensitive matching 197 + var lower_buf: [4096]u8 = undefined; 198 + const len = @min(text.len, lower_buf.len); 199 + for (text[0..len], 0..) |c, i| { 200 + lower_buf[i] = std.ascii.toLower(c); 201 + } 202 + const lower = lower_buf[0..len]; 203 + 204 + for (nsfw_keywords) |keyword| { 205 + if (mem.indexOf(u8, lower, keyword) != null) return true; 206 + } 207 + return false; 208 + }
+2 -4
bot/src/stats.zig
··· 274 274 var top_html: std.ArrayList(u8) = .{}; 275 275 defer top_html.deinit(allocator); 276 276 277 - const limit = @min(top_bufos.items.len, 20); 278 - 279 277 // find max count for scaling 280 278 var max_count: u64 = 1; 281 - for (top_bufos.items[0..limit]) |entry| { 279 + for (top_bufos.items) |entry| { 282 280 if (entry.count > max_count) max_count = entry.count; 283 281 } 284 282 285 - for (top_bufos.items[0..limit]) |entry| { 283 + for (top_bufos.items) |entry| { 286 284 // scale size: min 60px, max 160px based on count ratio 287 285 const ratio = @as(f64, @floatFromInt(entry.count)) / @as(f64, @floatFromInt(max_count)); 288 286 const size: u32 = @intFromFloat(60.0 + ratio * 100.0);
+23 -3
bot/src/stats_template.zig
··· 28 28 \\ }} 29 29 \\ .stat-label {{ color: #aaa; }} 30 30 \\ .stat-value {{ font-weight: bold; }} 31 + \\ .excluded {{ 32 + \\ margin-top: 20px; 33 + \\ padding: 12px 0; 34 + \\ color: #666; 35 + \\ font-size: 0.9em; 36 + \\ }} 37 + \\ .excluded-label {{ color: #666; }} 38 + \\ .excluded-value {{ color: #888; }} 31 39 \\ h2 {{ color: #7bed9f; margin-top: 40px; font-size: 1.2em; }} 32 40 \\ .bufo-grid {{ 33 41 \\ display: flex; ··· 73 81 \\ font-size: 0.9em; 74 82 \\ }} 75 83 \\ a {{ color: #7bed9f; }} 84 + \\ .links {{ color: #666; margin-bottom: 30px; font-size: 0.9em; }} 76 85 \\ .modal {{ 77 86 \\ display: none; 78 87 \\ position: fixed; ··· 106 115 \\</head> 107 116 \\<body> 108 117 \\<h1>bufo-bot stats</h1> 118 + \\<div class="links"> 119 + \\ <a href="https://find-bufo.com">find-bufo.com</a> · 120 + \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> · 121 + \\ <a href="https://tangled.org/zzstoatzz.io/find-bufo">source</a> 122 + \\</div> 109 123 \\ 110 124 \\<div class="stat"> 111 125 \\ <span class="stat-label">uptime</span> ··· 136 150 \\ <span class="stat-value" data-num="{}">{}</span> 137 151 \\</div> 138 152 \\ 139 - \\<h2>top bufos</h2> 153 + \\<div class="excluded"> 154 + \\ <span class="excluded-label">excluded</span> 155 + \\ <span class="excluded-value">posts with nsfw <a href="https://docs.bsky.app/docs/advanced-guides/moderation#labels">labels</a> or keywords</span> 156 + \\</div> 157 + \\ 158 + \\<h2>matched bufos</h2> 140 159 \\<div class="bufo-grid"> 141 160 \\{s} 142 161 \\</div> 143 162 \\ 144 163 \\<div class="footer"> 145 - \\ <a href="https://find-bufo.com">find-bufo.com</a> | 146 - \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> 164 + \\ <a href="https://find-bufo.com">find-bufo.com</a> · 165 + \\ <a href="https://bsky.app/profile/find-bufo.com">@find-bufo.com</a> · 166 + \\ <a href="https://tangled.org/zzstoatzz.io/find-bufo">source</a> 147 167 \\</div> 148 168 \\<div id="modal" class="modal" onclick="if(event.target===this)closeModal()"> 149 169 \\ <div class="modal-content">