at main 14 kB view raw
1<!DOCTYPE html> 2<html> 3 <head> 4 <meta charset="UTF-8" /> 5 <title>半个贴贴圈</title> 6 <link 7 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" 8 rel="stylesheet" 9 /> 10 <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script> 11 <script> 12 async function changeProgress(currentProgress) { 13 document.getElementById("progress").innerHTML = currentProgress; 14 } 15 16 // 常量定义 17 const PI = Math.PI; 18 const CANVAS_SIZE = 850; // 画布大小 19 const CENTER_X = CANVAS_SIZE / 2; // 画布中心X坐标 20 const CENTER_Y = CANVAS_SIZE / 2; // 画布中心Y坐标 21 22 // 头像大小配置 23 const BASE_RADIUS = Math.floor(35 * 1.25); // 基础半径 24 const CENTER_AVATAR_SIZE = 150; // 中心头像直径 25 const SIZE_STEP = 5; // 每一层头像大小递减值 26 const MIN_AVATAR_SIZE = 15; // 最小头像直径 27 28 // 布局配置 29 const RADIUS_STEP = 85; // 每一层圆的半径增量 30 const INITIAL_RADIUS = 140; // 初始圆半径 31 const GAP_SIZE = 10; // 头像之间的间隙 32 const MAX_LAYERS = 4; // 最大层数 33 34 /** 35 * 计算中心头像的位置 36 * @returns {Object} 包含x, y坐标和size的对象 37 */ 38 function calculateCenterAvatarPosition() { 39 const radius = CENTER_AVATAR_SIZE / 2; 40 return { 41 x: Math.round(CENTER_X - radius), 42 y: Math.round(CENTER_Y - radius), 43 size: CENTER_AVATAR_SIZE, 44 }; 45 } 46 47 /** 48 * 根据朋友数量计算所有头像的位置和大小 49 * @param {number} friendCount 朋友数量 50 * @returns {Array} 包含每个头像位置和大小的对象数组,格式为{"x":xxx,"y":xxx,"size":xxx} 51 */ 52 function calculateAvatarPositionsByCount(friendCount) { 53 const result = []; 54 let friendIndex = 0; 55 let currentRadius = INITIAL_RADIUS; 56 let layer = 0; 57 58 while (friendIndex < friendCount && layer < MAX_LAYERS) { 59 // 计算当前层的头像半径和直径 60 const avatarRadius = Math.max( 61 BASE_RADIUS - layer * SIZE_STEP, 62 Math.floor(MIN_AVATAR_SIZE / 2) 63 ); 64 const avatarSize = avatarRadius * 2; 65 66 // 计算当前层可以放置的头像数量 67 const circumference = 2 * PI * currentRadius; 68 const numInCurrentCircle = Math.floor( 69 circumference / (avatarSize + GAP_SIZE) 70 ); 71 72 if (numInCurrentCircle <= 0) break; 73 74 // 计算每个头像之间的角度步长 75 const thetaStep = (2 * PI) / numInCurrentCircle; 76 // 每层的旋转偏移 77 const rotationOffset = layer * (PI / 12); 78 79 // 计算当前层每个头像的位置 80 for (let i = 0; i < numInCurrentCircle; i++) { 81 if (friendIndex >= friendCount) break; 82 83 // 计算当前头像的角度(包含旋转偏移) 84 const theta = i * thetaStep + rotationOffset; 85 86 // 计算X和Y坐标(带偏移修正) 87 const x = CENTER_X + currentRadius * Math.cos(theta) - avatarRadius; 88 const y = CENTER_Y + currentRadius * Math.sin(theta) - avatarRadius; 89 90 // 添加到结果数组,格式为{"x":xxx,"y":xxx,"size":xxx} 91 result.push({ 92 x: Math.round(x), 93 y: Math.round(y), 94 size: avatarSize, 95 }); 96 97 friendIndex++; 98 } 99 100 // 进入下一层 101 currentRadius += RADIUS_STEP; 102 layer++; 103 } 104 105 return result; 106 } 107 108 async function getReplyScore(handle) { 109 const ENDPOINT_URL = "https://public.api.bsky.app/xrpc/"; 110 111 async function handle2DID(handle) { 112 try { 113 const response = await fetch( 114 `${ENDPOINT_URL}com.atproto.identity.resolveHandle?handle=${handle}` 115 ); 116 if (!response.ok) { 117 throw new Error(`http response code: ${response.status}`); 118 } 119 const data = await response.json(); 120 console.log("data:", data); 121 return data; 122 } catch (error) { 123 console.error("request fail:", error); 124 throw error; 125 } 126 } 127 128 async function getPosts(did) { 129 try { 130 const response = await fetch( 131 `${ENDPOINT_URL}app.bsky.feed.getAuthorFeed?actor=${did}&limit=100&includePins=true` 132 ); 133 console.log( 134 `${ENDPOINT_URL}app.bsky.feed.getAuthorFeed?actor=${did}&limit=100&includePins=true` 135 ); 136 if (!response.ok) { 137 throw new Error(`http response code: ${response.status}`); 138 } 139 const data = await response.json(); 140 // console.log("data:", data); 141 return data; 142 } catch (error) { 143 console.error("request fail:", error); 144 throw error; 145 } 146 } 147 148 async function getReplies(uri) { 149 try { 150 const response = await fetch( 151 `${ENDPOINT_URL}app.bsky.feed.getPostThread?uri=${uri}&depth=1` 152 ); 153 console.log( 154 `${ENDPOINT_URL}app.bsky.feed.getPostThread?uri=${uri}&depth=1` 155 ); 156 if (!response.ok) { 157 throw new Error(`http response code: ${response.status}`); 158 } 159 const data = await response.json(); 160 // console.log("data:", data); 161 return data; 162 } catch (error) { 163 console.error("request fail:", error); 164 throw error; 165 } 166 } 167 168 async function getHandle(did) { 169 try { 170 const response = await fetch( 171 `${ENDPOINT_URL}app.bsky.actor.getProfile?actor=${did}` 172 ); 173 console.log( 174 `${ENDPOINT_URL}app.bsky.actor.getProfile?actor=${did}` 175 ); 176 if (!response.ok) { 177 throw new Error(`http response code: ${response.status}`); 178 } 179 const data = await response.json(); 180 // console.log("data:", data); 181 return data; 182 } catch (error) { 183 console.error("request fail:", error); 184 throw error; 185 } 186 } 187 188 function getDidByAtUrl(aturl) { 189 return aturl.split("/")[2]; 190 } 191 192 var result = {}; 193 194 await changeProgress(`[0/2] resolving handle ${handle}`); 195 const did_result = await handle2DID(handle); 196 var did = did_result.did; 197 198 handle_result = await getHandle(did); 199 handle = handle_result.handle; 200 avatar = handle_result.avatar; 201 202 // result_html = '<div class="result">\n'; 203 result_html = ""; 204 205 // console.log(`${handle}'s reply_count is ${reply_count}, avatar ${handle_result.avatar}`); 206 result_html += `<a href="https://bsky.app/profile/${handle}">\n`; 207 208 result_html += `<img style="border-radius:50%;left:350px;top:350px;max-height:150px;"\n`; 209 result_html += ` src="${avatar}"\n`; 210 result_html += ` data-bs-toggle="popover"\n`; 211 result_html += ` title="@${handle}"\n`; 212 result_html += ` data-bs-content="@${handle} shows the handle itself"\n`; 213 result_html += ` target="_blank" />\n`; 214 215 result_html += `</a>\n\n`; 216 // console.log(result_html); 217 218 document.getElementById("result").innerHTML += result_html; 219 220 const posts_result = await getPosts(did); 221 for (const id in posts_result.feed) { 222 item = posts_result.feed[id]; 223 await changeProgress( 224 `[1/2] fetching post ${item.post.uri}, ${id}/${posts_result.feed.length}` 225 ); 226 // will recognize posts with no reason, whose replies will + 1.5 227 // posts has reply.parent, parent + 1 228 if ("reason" in item) { 229 console.warn( 230 `${item.post.uri} has reason ${item.reason.$type}, ignored` 231 ); 232 } else { 233 if ("reply" in item) { 234 console.log(`${item.post.uri} replies ${item.reply.parent.uri}`); 235 did_of_the_guy = getDidByAtUrl(item.reply.parent.uri); 236 if (!(did_of_the_guy in result)) { 237 result[did_of_the_guy] = 0.0; 238 } 239 result[did_of_the_guy] += 1.0; 240 } else { 241 main_uri = item.post.uri; 242 reply_result = await getReplies(main_uri); 243 if ("thread" in reply_result) { 244 for (const replypost of reply_result.thread.replies) { 245 reply_post_uri = replypost.post.uri; 246 did_of_the_guy = getDidByAtUrl(reply_post_uri); 247 if (!(did_of_the_guy in result)) { 248 result[did_of_the_guy] = 0.0; 249 } 250 result[did_of_the_guy] += 1.5; 251 } 252 } else { 253 console.warn(`${item.post.uri} has no replies`); 254 } 255 } 256 } 257 } 258 259 if (did in result) { 260 delete result.did; 261 } 262 263 function format_result(input) { 264 // 反转键值对 265 const reversed = Object.entries(input).reduce((acc, [key, value]) => { 266 if (!acc[value]) { 267 acc[value] = []; 268 } 269 acc[value].push(key); 270 return acc; 271 }, {}); 272 273 // 对键进行数值降序排序 274 const sortedKeys = Object.keys(reversed) 275 .map(Number) 276 .sort((a, b) => b - a); // 仅保留降序逻辑 277 278 // 生成最终的排序对象,并对每个数组内的did排序 279 return sortedKeys.reduce((acc, key) => { 280 acc[key] = reversed[key].sort(); 281 return acc; 282 }, {}); 283 } 284 285 formatted_result = format_result(result); 286 287 avatar_id = 0; 288 289 const sorted_rc = Object.keys(formatted_result).sort((a, b) => b - a); // 降序排序 290 291 total_user = Object.keys(result).length; 292 293 proceed_user = 0; 294 295 avatars_list = calculateAvatarPositionsByCount(total_user); 296 297 console.warn(avatars_list.length); 298 299 for (const reply_count of sorted_rc) { 300 for (const final_did of formatted_result[reply_count]) { 301 proceed_user += 1; 302 await changeProgress( 303 `[2/2] fetching metadata of ${final_did}, ${proceed_user}/${total_user} or 87` 304 ); 305 if (proceed_user < 88) { 306 handle_result = await getHandle(final_did); 307 handle = handle_result.handle; 308 avatar = handle_result.avatar; 309 310 // console.log(`${handle}'s reply_count is ${reply_count}, avatar ${handle_result.avatar}`); 311 result_html += `<a href="https://bsky.app/profile/${handle}">\n`; 312 313 result_html += `<img style="border-radius:50%;left:${ 314 avatars_list[proceed_user - 1].x 315 }px;top:${avatars_list[proceed_user - 1].y}px;max-height:${ 316 avatars_list[proceed_user - 1].size 317 }px;"\n`; 318 result_html += ` src="${avatar}"\n`; 319 result_html += ` data-bs-toggle="popover"\n`; 320 result_html += ` title="@${handle}"\n`; 321 result_html += ` data-bs-content="@${handle} got ${reply_count} reply counts"\n`; 322 result_html += ` target="_blank" />\n`; 323 324 result_html += `</a>\n\n`; 325 // console.log(result_html); 326 327 document.getElementById("result").innerHTML = result_html; 328 } 329 } 330 } // result_html += "</div>"; 331 return result_html; 332 } 333 334 // 使用示例 335 async function main() { 336 try { 337 document.getElementById("result").innerHTML = ""; 338 const did = await getReplyScore( 339 document.getElementById("bruh").value 340 ); 341 console.log("result:", did); 342 } catch (error) { 343 console.error("fail:", error); 344 } 345 } 346 347 main(); 348 349 function changeColor() { 350 targetElement = document.getElementById("background"); 351 const colorPicker = document.createElement("input"); 352 colorPicker.type = "color"; 353 354 colorPicker.addEventListener("input", (e) => { 355 targetElement.style.backgroundColor = e.target.value; 356 }); 357 358 // 触发颜色选择对话框 359 colorPicker.click(); 360 } 361 </script> 362 <style> 363 #progress::before { 364 content: "当前进度:"; 365 } 366 367 #result { 368 position: absolute; 369 } 370 371 #result img { 372 position: absolute; 373 z-index: 10; 374 height: auto; 375 } 376 377 #background { 378 height: 850px; 379 width: 850px; 380 background-color: blueviolet; 381 } 382 </style> 383 </head> 384 385 <body> 386 <div class="container"> 387 <label for="bruh" class="form-label">点击输入 handle</label> 388 <input type="text" id="bruh" class="form-control" /> 389 <button class="btn btn-primary" onclick="main()">启动</button> 390 <button class="btn btn-primary" onclick="changeColor()">换背景色</button> 391 <button class="btn btn-primary" onclick="alert('自己截去😡😡😡')"> 392 导出 PNG! 393 </button> 394 <div id="progress"></div> 395 <div id="ssarea"> 396 <div id="result"></div> 397 </div> 398 <div id="background"></div> 399 </div> 400 <div id="readme"> 401 </div> 402 </body> 403</html>