イラストなどを投稿した自分のポストを異なる時間帯(昼や深夜など)にセルフRPして宣伝できるツールです
script.google.com/macros/s/AKfycbwnbvG1hSPgYJOWz4yyH76WxJU3TEOmZIBk_3CeaImYNVn4uRpz21VlEjNPs06FojkJLQ/exec
bluesky
gas
1/**
2 * @OnlyCurrentDoc
3 */
4
5const HOUR_LIST = [[6, 8], //Morning
6[11, 13],//Lunch
7[17, 19],//Evening
8[23, 25]]//MidNight
9const HOURS_NAME_JA = ["朝", "昼", "夜", "深夜"];
10const HOURS_NAME_EN = ["Morning", "daytime", "evening", "midnight"];
11const HOURS_NUM = 4;//HOUR_LIST.length()
12
13function doGet(e) {
14 const template = HtmlService.createTemplateFromFile('index');
15
16 if (Session.getActiveUserLocale().startsWith("ja")) {
17 template.title = "自動化セルフRPで宣伝!";
18 template.description = "自分のポストを異なる時間帯(昼や深夜など)にセルフRPして宣伝できるツールです。";
19 template.appPassword = "アプリパスワード";
20 template.appPasswordDescription = `※Blueskyのパスワードでは<a href="https://scrapbox.io/Bluesky/Bluesky%E3%82%92%E5%A7%8B%E3%82%81%E3%82%8B%E4%B8%8A%E3%81%A7%E3%81%93%E3%82%8C%E3%81%A0%E3%81%91%E3%81%AF%E7%9F%A5%E3%81%A3%E3%81%A6%E3%81%8A%E3%81%84%E3%81%9F%E6%96%B9%E3%81%8C%E3%81%84%E3%81%84%E3%81%93%E3%81%A8">ありません</a>`
21 template.postUrlDescription = "宣伝したいポストのURL"
22 template.setSelfRP = "セルフRPを設定"
23 template.support = `質問・要望などあれば<a href="https://bsky.app/profile/did:plc:wwqlk2n45es2ywkwrf4dwsr2">開発者(@lamrongol)</a>にお気軽に問い合わせください。`
24 } else {
25 template.title = "Spread your posts by automated self repost!";
26 template.description = "This tool reposts your post(by your own account) at different times of day(such as daytime or midnight)";
27 template.appPassword = "App Password";
28 template.appPasswordDescription = `※This is NOT Bluesky password.`
29 template.postUrlDescription = "Post url you want to spread"
30 template.setSelfRP = "Set self repost"
31 template.support = `If you have question or request, please tell <a href="https://bsky.app/profile/did:plc:wwqlk2n45es2ywkwrf4dwsr2">developer(@lamrongol)</a>`
32 }
33
34 const userProperties = PropertiesService.getUserProperties();
35 const username = userProperties.getProperty("username");
36 const appPassword = userProperties.getProperty("appPassword");
37 const timerError = userProperties.getProperty("timerError");
38 template.deployURL = ScriptApp.getService().getUrl();
39 template.username = username;
40 template.appPassword = appPassword;
41 template.timerError = timerError;
42 const htmlOutput = template.evaluate()
43 .setTitle("SkySpread - " + template.title)
44 // .addMetaTag("og:type", "website")
45 // .addMetaTag("og:title", template.title)
46 // .addMetaTag("og:description", template.description);
47
48 return htmlOutput;
49}
50
51function doPost(e) {
52 var template;
53 if (e.parameter.confirm) {
54 template = confirmTemplate(e)
55 }
56 // else if (e.parameter.submit) {
57 // template = completeTemplate(e)
58 // }
59
60 template.deployURL = ScriptApp.getService().getUrl();
61 const htmlOutput = template.evaluate();
62 return htmlOutput;
63}
64
65function resetData() {
66 const userProperties = PropertiesService.getUserProperties();
67 userProperties.deleteAllProperties()
68}
69
70function confirmTemplate(e) {
71 const isJa = Session.getActiveUserLocale().startsWith("ja");
72
73 const template = HtmlService.createTemplateFromFile('confirm');
74 const postUrl = e.parameter.postUrl
75
76 template.username = e.parameter.username;
77 template.deployURL = ScriptApp.getService().getUrl();
78 template.postUrl = postUrl;
79 if (isJa) {
80 template.message = "以下の時刻でセルフRPします"
81 } else {
82 template.message = "Repost by your own account at following time"
83 }
84 template.errMessage = "";
85
86 const sessionData = createSession(e.parameter.username, e.parameter.appPassword);
87 gasLog(sessionData);
88 if (!sessionData.did) {
89 if (isJa) {
90 template.errMessage = "ログインに失敗しました: " + sessionData.message;
91 } else {
92 template.errMessage = "login failed: " + sessionData.message;
93 }
94 const htmlOutput = template.evaluate();
95 return htmlOutput;
96 }
97
98 const userProperties = PropertiesService.getUserProperties();
99 var username = userProperties.getProperty("username");
100 var appPassword = userProperties.getProperty("appPassword");
101 if (e.parameter.username != username) {
102 username = e.parameter.username;
103 userProperties.setProperty("username", username);
104 template.username = username;
105 }
106 if (e.parameter.appPassword != appPassword) {
107 appPassword = e.parameter.appPassword;
108 userProperties.setProperty("appPassword", appPassword);
109 }
110
111 const post = getPost(sessionData.accessJwt, sessionData.did, postUrl)
112 if (post == null) {// (post.author.did != did) {
113 if (isJa) {
114 template.errMessage = "ポストが存在しない、または違う人のポストです";
115 } else {
116 template.errMessage = "Post doesn't exist, or it is not yours";
117 }
118 const htmlOutput = template.evaluate();
119 return htmlOutput;
120 }
121
122 userProperties.setProperty("sessionData", JSON.stringify(sessionData));
123
124 const repo = { uri: post.uri, cid: post.cid }
125 // gasLog(post)
126 const unixTimeZero = new Date(post.record.createdAt);
127
128 const hour = getLocalHour(unixTimeZero);
129 var minHourDiff = 24;
130 var nearestIdx = -1;
131 var idx = 0;
132 for (const hours of HOUR_LIST) {
133 const start = hours[0];
134 const end = hours[1];
135 const tmpHour = (end > 24) ? hour + 24 : hour;
136 if (start < tmpHour && tmpHour < end) {
137 nearestIdx = idx;
138 break;
139 }
140
141 const diff = (tmpHour < start) ? start - tmpHour : tmpHour - end;
142 if (diff < minHourDiff) {
143 minHourDiff = diff;
144 nearestIdx = idx;
145 }
146
147 idx++;
148 }
149
150 idx = nearestIdx;
151 // var hourNames = [];
152 var rpList = JSON.parse(userProperties.getProperty("rpList"));
153 gasLog(rpList)
154 if (rpList == null) rpList = new Array();
155 // gasLog(typeof rpList);
156 // gasLog(rpList.length);
157
158 var tableHtml = `
159 <table class="table">
160 <tbody>
161`;
162
163 for (var i = 0; i < HOURS_NUM - 1; i++) {
164 idx = (idx + 1) % HOURS_NUM
165 const startHour = (HOUR_LIST[idx][0] + getRandomInt(2)) % 24;
166 // hourNames.push(HOURS_NAME[idx]);
167 if (isJa) {
168 tableHtml += `<tr><td>${HOURS_NAME_JA[idx]}</td><td>${HOUR_LIST[idx][0]}時から${HOUR_LIST[idx][1]}時まで</td>` + "\n";
169 } else {
170 tableHtml += `<tr><td>${HOURS_NAME_EN[idx]}</td><td>${HOUR_LIST[idx][0]}:00~${HOUR_LIST[idx][1]}:00</td>` + "\n";
171 }
172 rpList.push({ repo: repo, hour: startHour })
173 }
174 userProperties.setProperty("rpList", JSON.stringify(rpList));
175 tableHtml += `</tbody></table>`;
176 template.listHtml = tableHtml;
177
178 userProperties.setProperty("timerError", "");
179
180 return template;
181}
182
183// function completeTemplate(e) {
184// gasLog(e);
185// const template = HtmlService.createTemplateFromFile('complete');
186// return template;
187// }
188
189//@see https://note.com/keiga/n/n527865bcf0d5
190const createSession = (username, appPassword) => {
191 const url = 'https://bsky.social/xrpc/com.atproto.server.createSession';
192
193 const data = {
194 'identifier': username,
195 'password': appPassword
196 };
197
198 const params = {
199 'method': 'post',
200 'headers': {
201 'Content-Type': 'application/json; charset=UTF-8',
202 },
203 'payload': JSON.stringify(data),
204 'muteHttpExceptions': true,
205 };
206 const response = UrlFetchApp.fetch(url, params);
207 const result = JSON.parse(response.getContentText());
208
209 if (response.getResponseCode() != 200) {
210 gasLog(response.getContentText())
211 return result
212 }
213 const sessionData = {
214 accessJwt: result.accessJwt,
215 refreshJwt: result.refreshJwt,
216 handle: result.handle,
217 did: result.did
218 }
219 return sessionData;
220}
221
222const getPost = (accessJwt, did, postUrl) => {
223 const url = 'https://bsky.social/xrpc/app.bsky.feed.getPosts?uris=' + convertToAtUri(did, postUrl);
224
225 const params = {
226 'method': 'get',
227 'headers': {
228 'Authorization': `Bearer ${accessJwt}`,
229 "Accept": "application/json"
230 },
231 'muteHttpExceptions': true,
232 };
233 const response = JSON.parse(UrlFetchApp.fetch(url, params));
234 posts = response.posts;
235 if (posts.length == 0) {
236 return null;
237 } else {
238 return posts[0];
239 }
240}
241
242const refreshSession = (refreshJwt) => {
243 const url = 'https://bsky.social/xrpc/com.atproto.server.refreshSession';
244
245 const params = {
246 'method': 'post',
247 'headers': {
248 'Authorization': `Bearer ${refreshJwt}`,
249 },
250 'muteHttpExceptions': true,
251 };
252 const response = UrlFetchApp.fetch(url, params);
253 const result = JSON.parse(response.getContentText());
254
255 if (response.getResponseCode() != 200) {
256 gasLog(response.getContentText())
257 return result
258 }
259 return result;
260}
261
262const convertToAtUri = (did, postUrl) => {
263 const idx = postUrl.lastIndexOf("/")
264 if (idx == -1) return ""
265
266 return "at://" + did + "/app.bsky.feed.post/" + postUrl.substring(idx + 1)
267}
268
269const repost = (sessionData, repo) => {
270 const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord';
271
272 const record = {
273 'createdAt': (new Date()).toISOString(),
274 'subject': repo
275 }
276
277 const data = {
278 'repo': sessionData.did,
279 'collection': 'app.bsky.feed.repost',
280 'record': record
281 };
282
283 const options = {
284 'method': 'post',
285 'headers': {
286 'Authorization': `Bearer ${sessionData.accessJwt}`,
287 'Content-Type': 'application/json; charset=UTF-8'
288 },
289 'payload': JSON.stringify(data),
290 };
291
292 const response = UrlFetchApp.fetch(url, options);
293 const result = JSON.parse(response.getContentText())
294 result.responseCode = response.getResponseCode();
295
296 if (response.getResponseCode() != 200) {
297 gasLog(response.getContentText())
298 return result
299 }
300
301 return result;
302}
303
304const timerRepost = () => {
305 //always refresh session
306 const userProperties = PropertiesService.getUserProperties();
307 var sessionData = JSON.parse(userProperties.getProperty("sessionData"));
308 gasLog(sessionData)
309
310 result = refreshSession(sessionData.refreshJwt);
311 sessionData.accessJwt = result.accessJwt;
312 sessionData.refreshJwt = result.refreshJwt;
313
314 if (!sessionData.did) {
315 userProperties.deleteProperty("sessionData");
316 userProperties.deleteProperty("rpList");
317
318 userProperties.setProperty("timerError", "前回設定した自動セルフRPは失敗しました。");
319 return
320 }
321 userProperties.setProperty("sessionData", JSON.stringify(sessionData));
322
323 const nowHour = getLocalHour(new Date());
324 var rpList = JSON.parse(userProperties.getProperty("rpList"));
325 var idx = 0
326 for (const rpInfo of rpList) {
327 if (rpInfo.hour == nowHour) {
328 break
329 }
330 idx++;
331 }
332 if (idx == rpList.length) {
333 return
334 }
335
336 const repo = rpList[idx].repo;
337 const res = repost(sessionData, repo);
338 gasLog(res);
339 if (res.responseCode != 200) {
340 userProperties.setProperty("timerError", "前回設定した自動セルフRPは失敗しました:" + res);
341 }
342 rpList.splice(idx, 1);
343 gasLog(rpList.length)
344 userProperties.setProperty("rpList", JSON.stringify(rpList));
345}
346
347const getLocalHour = (date) => {
348 //https://stackoverflow.com/a/74683660/3809427
349 const offset = Number(Intl.DateTimeFormat("ia", {
350 timeZoneName: "shortOffset",
351 //https://stackoverflow.com/a/24764307/3809427
352 timeZone: CalendarApp.getDefaultCalendar().getTimeZone()
353 })
354 .formatToParts()
355 .find((i) => i.type === "timeZoneName").value // => "GMT+/-hh:mm"
356 .slice(3));
357
358 return (date.getUTCHours() + offset + 24) % 24;
359}
360
361const gasLog = (obj) => {
362 Logger.log(JSON.stringify(obj));
363}
364
365//@see https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Math/random
366function getRandomInt(max) {
367 return Math.floor(Math.random() * max);
368}