JS 重写的新贴贴圈
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>