Vstats Kit

Show median peak, true average, add change date arrows on month stats page, change hololive channel.

  1. // ==UserScript==
  2. // @name Vstats Kit
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.51
  5. // @description Show median peak, true average, add change date arrows on month stats page, change hololive channel.
  6. // @author Irushia
  7. // @license MIT
  8. // @match https://www.vstats.jp/channels/1:*/*
  9. // @exclude https://www.vstats.jp/channels/1:*/overall
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=vstats.jp
  11. // @run-at document-end
  12. // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
  13. // @grant GM.getValue
  14. // @grant GM.setValue
  15. // @grant GM_registerMenuCommand
  16. // ==/UserScript==
  17.  
  18. (() => {
  19. const gmc = new GM_config({
  20. id: "MyConfig",
  21. title: `${GM_info.script.name} Settings`,
  22. fields: {
  23. COPY_TO_CLIPBOARD: {
  24. label: "Copy stats to clipboard",
  25. type: "select",
  26. options: [
  27. "None",
  28. "HW",
  29. "HS",
  30. "MEDIAN",
  31. "AVERAGE",
  32. "VIDNUM",
  33. "LIVENUM",
  34. "PRENUM",
  35. ],
  36. default: "None",
  37. },
  38. },
  39. events: {
  40. init: () => {
  41. const cpy = gmc.get("COPY_TO_CLIPBOARD");
  42. copyToClipboard(cpy);
  43. },
  44. },
  45. });
  46.  
  47. const HololiveChs = [
  48. { id: "UCp6993wxpyDPHUpavwDFqgg", name: "Sora" },
  49. { id: "UCDqI2jOz0weumE8s7paEk6g", name: "Roboco" },
  50. { id: "UCFTLzh12_nrtzqBPsTCqenA", name: "Aki" },
  51. { id: "UC1CfXB_kRs3C-zaeTG3oGyg", name: "Haato" },
  52. { id: "UCdn5BQ06XqgXoAxIhbqw5Rg", name: "Fubuki" },
  53. { id: "UCQ0UDLQCjY0rmuxCDE38FGg", name: "Matsuri" },
  54. { id: "UCXTpFs_3PqI41qX2d9tL2Rw", name: "Shion" },
  55. { id: "UC7fk0CB07ly8oSl0aqKkqFg", name: "Ayame" },
  56. { id: "UC1suqwovbL1kzsoaZgFZLKg", name: "Choco" },
  57. { id: "UCvzGlP9oQwU--Y0r9id_jnA", name: "Subaru" },
  58. { id: "UC0TXe_LYZ4scaW2XMyi5_kw", name: "AZKi" },
  59. { id: "UCp-5t9SrOQwXMU7iIjQfARg", name: "Mio" },
  60. { id: "UC-hM6YJuNYVAmUWxeIr9FeA", name: "Miko" },
  61. { id: "UCvaTdHTWBGv3MKj3KVqJVCw", name: "Okayu" },
  62. { id: "UChAnqc_AY5_I3Px5dig3X1Q", name: "Korone" },
  63. { id: "UC5CwaMl1eIgY8h02uZw7u8A", name: "Suisei" },
  64. { id: "UC1DCedRgGHBdm81E1llLhOQ", name: "Pekora" },
  65. { id: "UCvInZx9h3jC2JzsIzoOebWg", name: "Flare" },
  66. { id: "UCdyqAaZDKHXg4Ahi7VENThQ", name: "Noel" },
  67. { id: "UCCzUftO8KOVkV4wQG1vkUvg", name: "Marine" },
  68. { id: "UCZlDXzGoo7d44bwdNObFacg", name: "Kanata" },
  69. { id: "UCqm3BQLlJfvkTsX_hvm0UmA", name: "Watame" },
  70. { id: "UC1uv2Oq6kNxgATlCiez59hw", name: "Towa" },
  71. { id: "UCa9Y57gfeY0Zro_noHRVrnw", name: "Luna" },
  72. { id: "UCOyYb1c43VlX9rc_lT6NKQw", name: "Risu" },
  73. { id: "UCP0BspO_AMEe3aQqqpo89Dg", name: "Moona" },
  74. { id: "UCAoy6rzhSf4ydcYjJw3WoVg", name: "Iofiteen" },
  75. { id: "UCFKOVgVbGmX65RxO3EtH3iw", name: "Lamy" },
  76. { id: "UCAWSyEs_Io8MtpY3m-zqILA", name: "Nene" },
  77. { id: "UCUKD-uaobj9jiqB-VXt71mA", name: "Botan" },
  78. { id: "UCK9V2B22uJYu3N7eR_BT9QA", name: "Polka" },
  79. { id: "UCL_qhgtOy0dy1Agp8vkySQg", name: "Calliope" },
  80. { id: "UCHsx4Hqa-1ORjQTh9TYDhww", name: "Kiara" },
  81. { id: "UCMwGHR0BTZuLsmjY_NT5Pwg", name: "Ina'nis" },
  82. { id: "UCoSrY_IQQVpmIRZ9Xf-y93g", name: "Gura" },
  83. // { id: "UCyl1z3jo3XHR1riLFKG5UAg", name: "Amelia" },
  84. { id: "UCYz_5n-uDuChHtLo7My1HnQ", name: "Ollie" },
  85. { id: "UC727SQYUvx5pDDGQpTICNWg", name: "Anya" },
  86. { id: "UChgTyjG-pdNvxxhdsXfHQ5Q", name: "Reine" },
  87. { id: "UC8rcEBzJSleTkf_-agPM20g", name: "IRyS" },
  88. { id: "UCO_aKKYxn4tvrqPjcTzZ6EQ", name: "Fauna" },
  89. { id: "UCmbs8T6MWqUHP1tIQvSgKrg", name: "Kronii" },
  90. { id: "UC3n5uGu18FoCy23ggWWp8tA", name: "Mumei" },
  91. { id: "UCgmPnx-EEeOrZSg5Tiw7ZRQ", name: "Baelz" },
  92. { id: "UCENwRMx5Yh42zWpzURebzTw", name: "Laplus" },
  93. { id: "UCs9_O1tRPMQTHQ-N_L6FU2g", name: "Lui" },
  94. { id: "UC6eWCld0KwmyHFbAqK3V-Rw", name: "Koyori" },
  95. { id: "UCIBY1ollUsauvVi4hW4cumw", name: "Chloe" },
  96. { id: "UC_vMYWcDjmfdpH6r4TTn1MQ", name: "Iroha" },
  97. { id: "UCTvHWSfBZgtxE4sILOaurIQ", name: "Zeta" },
  98. { id: "UCZLZ8Jjx_RN2CXloOmgTHVg", name: "Kaela" },
  99. { id: "UCjLEmnpCNeisMxy134KPwWw", name: "Kobo" },
  100. { id: "UCgnfPPb9JI3e9A4cXHnWbyg", name: "Shiori" },
  101. { id: "UC9p_lqQ0FEDz327Vgf5JwqA", name: "Bijou" },
  102. { id: "UC_sFNM0z0MWm9A6WlKPuMMg", name: "Nerissa" },
  103. { id: "UCt9H_RpQzhxzlyBxFqrdHqA", name: "Fuwamoco" },
  104. { id: "UCMGfV7TVTmHhEErVJg1oHBQ", name: "Ao" },
  105. { id: "UCWQtYtq9EOB4-I5P-3fh8lA", name: "Kanade" },
  106. { id: "UCtyWhCj3AqKh2dXctLkDtng", name: "Ririka" },
  107. { id: "UCdXAk5MpyLD8594lm_OvtGQ", name: "Raden" },
  108. { id: "UC1iA6_NT4mtAcIII6ygrvCw", name: "Hajime" },
  109. { id: "UCW5uhrG1eCBYditmhL0Ykjw", name: "Elizabeth" },
  110. { id: "UCDHABijvPBnJm7F-KlNME3w", name: "Gigi" },
  111. { id: "UCvN5h1ShZtc7nly3pezRayg", name: "Cecillia" },
  112. { id: "UCl69AEx4MdqMZH7Jtsm7Tig", name: "Raora" },
  113. { id: "UC9LSiN9hXI55svYEBrrK-tw", name: "Riona" },
  114. { id: "UCuI_opAVX6qbxZY-a-AxFuQ", name: "Niko" },
  115. { id: "UCjk2nKmHzgH5Xy-C5qYRd5A", name: "Su" },
  116. { id: "UCKMWFR6lAstLa7Vbf5dH7ig", name: "Chihaya" },
  117. { id: "UCGzTVXqMQHa4AgJVJIVvtDQ", name: "Vivi" },
  118. ];
  119.  
  120. const findEle = (ele, title) => {
  121. const value = ele.querySelector(`[title="${title}"]`);
  122. return value && value.textContent !== "---"
  123. ? Number.parseInt(value.textContent.replace(/,/g, ""), 10)
  124. : 0;
  125. };
  126.  
  127. const sortList = () => {
  128. const divClass =
  129. "row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-2 g-lg-3";
  130. const divEle = document.getElementsByClassName(divClass)[0];
  131.  
  132. const sortedChildren = Array.from(divEle.children).sort(
  133. (a, b) => findEle(b, "最大視聴者数") - findEle(a, "最大視聴者数"),
  134. );
  135. for (const col of sortedChildren) {
  136. divEle.appendChild(col);
  137. }
  138. };
  139.  
  140. const getMedian = (arr) => {
  141. const sortedArr = arr.sort((a, b) => a - b);
  142. const mid = Math.floor(sortedArr.length / 2);
  143. return sortedArr.length % 2 === 0
  144. ? (sortedArr[mid - 1] + sortedArr[mid]) / 2
  145. : sortedArr[mid];
  146. };
  147.  
  148. const formatNumberWithCommas = (num) => {
  149. return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  150. };
  151.  
  152. const arrIndex = (arr, index) => {
  153. if (!arr) return null;
  154. return arr[index];
  155. };
  156.  
  157. const toInt = (str) => {
  158. if (!str || str === "---") return 0;
  159. return Number.parseInt(str.replace(/,/g, ""), 10);
  160. };
  161.  
  162. const toFloat = (str) => {
  163. if (!str || str === "0:00") return 0.0;
  164. return (
  165. Number.parseInt(str.split(":")[0]) +
  166. Number.parseInt(str.split(":")[1]) / 60
  167. ).toFixed(2);
  168. };
  169.  
  170. const addTime = (time1, time2) => {
  171. const [h1, m1] = time1.split(":").map(Number);
  172. const [h2, m2] = time2.split(":").map(Number);
  173. const totalMinutes = h1 * 60 + m1 + (h2 * 60 + m2);
  174. const hours = Math.floor(totalMinutes / 60);
  175. const minutes = totalMinutes % 60;
  176. return `${hours}:${minutes.toString().padStart(2, "0")}`;
  177. };
  178.  
  179. const Stats = {
  180. hourswatched: "",
  181. hourstream: "",
  182. median: 0,
  183. average: 0,
  184. vidNum: 0,
  185. liveNum: 0,
  186. preNum: 0,
  187. init(hourswatched, hourstream, median, vidNum, liveNum, preNum) {
  188. this.hourswatched = hourswatched; // string
  189. this.hourstream = hourstream; // string
  190. this.median = median; //
  191. const hw = Number.parseInt(hourswatched.replace(/,/g, ""));
  192. const hs = toFloat(hourstream);
  193. this.average = Math.round(hw / hs); // int
  194. this.vidNum = vidNum; // int
  195. this.liveNum = liveNum; // int
  196. this.preNum = preNum; // int
  197. },
  198. toString() {
  199. return `動画:${this.vidNum}本\nライブ配信:${this.liveNum}本\n
  200. 同接中央値:${formatNumberWithCommas(this.median)}\n
  201. 同接平均値:${formatNumberWithCommas(this.average)}\n
  202. 総視聴時間:${this.hourswatched}\n配信時間:${this.hourstream}\n
  203. プレミア公開:${this.preNum}本`;
  204. },
  205. };
  206.  
  207. const editStats = () => {
  208. const divClass =
  209. "row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-2 g-lg-3";
  210. const divEle = document.getElementsByClassName(divClass)[0];
  211. const eleList = divEle.children;
  212.  
  213. const peakList = [];
  214. let hourstream = "0:00";
  215.  
  216. for (let i = 0; i < eleList.length; i++) {
  217. const peak = findEle(eleList[i], "最大視聴者数");
  218. if (peak > 0) {
  219. peakList.push(peak);
  220. const hs = eleList[i]
  221. .querySelector(`[title="放送時間"]`)
  222. .textContent.trim();
  223. hourstream = addTime(hourstream, hs);
  224. }
  225. }
  226.  
  227. if (peakList.length === 0) return;
  228.  
  229. const statsEle = document.querySelector("h5");
  230.  
  231. Stats.init(
  232. statsEle.innerHTML.match(/総視聴時間:\s*([\d,]+)/)[1],
  233. hourstream,
  234. getMedian(peakList).toFixed(0),
  235. toInt(arrIndex(statsEle.innerHTML.match(/動画:\s*(\d+)/), 1)),
  236. peakList.length,
  237. toInt(arrIndex(statsEle.innerHTML.match(/プレミア公開:\s*(\d+)/), 1)),
  238. );
  239.  
  240. statsEle.innerHTML = Stats.toString();
  241. };
  242.  
  243. const addDateChangeArrow = () => {
  244. const url = new URL(window.location.href);
  245. const [channel, date] = url.pathname.split("/").slice(-2);
  246. const [year, month] = date.split("-").map(Number);
  247.  
  248. const prevDate = new Date(year, month - 2, 1);
  249. const nextDate = new Date(year, month, 1);
  250.  
  251. const prevMonthUrl = `/channels/${channel}/${prevDate.getFullYear()}-${
  252. prevDate.getMonth() + 1
  253. }`;
  254. const nextMonthUrl = `/channels/${channel}/${nextDate.getFullYear()}-${
  255. nextDate.getMonth() + 1
  256. }`;
  257.  
  258. const dateNavElement = document.querySelector(
  259. "body > main > div.content.mt-3 > div > div:nth-child(1) > div:nth-child(2) > h4",
  260. );
  261. dateNavElement.insertAdjacentHTML(
  262. "afterbegin",
  263. `<a href="${prevMonthUrl}" class="link-dark"><i class="fas fa-angle-left" aria-hidden="true"></i></a>`,
  264. );
  265. dateNavElement.insertAdjacentHTML(
  266. "beforeend",
  267. `<a href="${nextMonthUrl}" class="link-dark"><i class="fas fa-angle-right" aria-hidden="true"></i></a>`,
  268. );
  269. };
  270.  
  271. const addChannelChangeArrow = () => {
  272. const url = new URL(window.location.href);
  273. const [channel, date] = url.pathname.split("/").slice(-2);
  274. const channelId = channel.split(":")[1];
  275.  
  276. const index = HololiveChs.findIndex((ch) => ch.id === channelId);
  277. if (index === -1) return;
  278.  
  279. const prevIndex = index === 0 ? HololiveChs.length - 1 : index - 1;
  280. const nextIndex = index === HololiveChs.length - 1 ? 0 : index + 1;
  281.  
  282. const prevHtml = `<a href="/channels/1:${HololiveChs[prevIndex].id}/${date}" class="link-dark"><i class="fas fa-angle-left" aria-hidden="true"></i></a>`;
  283. const nextHtml = `<a href="/channels/1:${HololiveChs[nextIndex].id}/${date}" class="link-dark"><i class="fas fa-angle-right" aria-hidden="true"></i></a>`;
  284.  
  285. const channelNavElement = document.querySelector(
  286. "body > main > div.content.mt-3 > div > div:nth-child(1) > div.col-12.d-flex.justify-content-start.align-items-center.py-2 > img",
  287. );
  288. channelNavElement.insertAdjacentHTML("beforebegin", prevHtml);
  289. channelNavElement.insertAdjacentHTML("afterend", nextHtml);
  290. };
  291.  
  292. const copyToClipboard = (settings) => {
  293. if (!settings) return;
  294.  
  295. const map = {
  296. HW: toInt(Stats.hourswatched),
  297. HS: toFloat(Stats.hourstream),
  298. MEDIAN: Stats.median,
  299. AVERAGE: Stats.average,
  300. VIDNUM: Stats.vidNum,
  301. LIVENUM: Stats.liveNum,
  302. PRENUM: Stats.preNum,
  303. None: 0,
  304. };
  305. const tmp = map[settings];
  306. console.debug(`Copying: ${tmp}`);
  307. navigator.clipboard.writeText(tmp).then(
  308. () => {
  309. // alert(`Async: Copying ${settings} to clipboard was successful!`);
  310. },
  311. (err) => {
  312. console.error("Async: Could not copy text: ", err);
  313. },
  314. );
  315. };
  316.  
  317. GM_registerMenuCommand("Settings", () => {
  318. gmc.open();
  319. });
  320. GM_registerMenuCommand("Sort", sortList);
  321.  
  322. addDateChangeArrow();
  323. addChannelChangeArrow();
  324. editStats();
  325. // copyToClipboard("MEDIAN");
  326. })();