GreasyFork Installs Notifier

It shows a browser notification when any of the numbers of installs reached round numbers on your own user page.

  1. // ==UserScript==
  2. // @name GreasyFork Installs Notifier
  3. // @name:ja GreasyFork Installs Notifier
  4. // @name:zh-CN GreasyFork Installs Notifier
  5. // @namespace knoa.jp
  6. // @description It shows a browser notification when any of the numbers of installs reached round numbers on your own user page.
  7. // @description:ja ご自身のユーザーページで各スクリプトのインストール数がキリのいい数字を超えたらブラウザ通知でお知らせします。
  8. // @description:zh-CN 在您自己的用户页面上,如果每个脚本的安装数量超过整数或靓号,我们将通过浏览器通知通知您。
  9. // @include https://gf.qytechs.cn/*/users/*
  10. // @version 1.0.2
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function(){
  15. const SCRIPTID = 'GreasyForkInstallsNotifier';
  16. const SCRIPTNAME = 'GreasyFork Installs Notifier';
  17. const DEBUG = false;/*
  18. [update] 1.0.2
  19. Properly added yellow background highlight and fixed the 1st install text.
  20.  
  21. [bug]
  22.  
  23. [todo]
  24.  
  25. [possible]
  26.  
  27. [research]
  28.  
  29. [memo]
  30. */
  31. if(window === top && console.time) console.time(SCRIPTID);
  32. const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  33. const THRESHOLDS = [1, 10, 100, 1000, 10000, 100000, 1000000];
  34. const FLAGNAME = SCRIPTID.toLowerCase();
  35. const site = {
  36. targets: {
  37. userScriptListItems: () => $$('#user-script-list > li'),
  38. },
  39. get: {
  40. scriptName: (li) => li.dataset.scriptName,
  41. totalInstalls: (li) => parseInt(li.dataset.scriptTotalInstalls),
  42. },
  43. is: {
  44. owner: () => ($('#control-panel') !== null),
  45. },
  46. };
  47. let elements = {}, installs;
  48. const core = {
  49. initialize: function(){
  50. elements.html = document.documentElement;
  51. elements.html.classList.add(SCRIPTID);
  52. if(site.is.owner()){
  53. core.ready();
  54. core.addStyle();
  55. }
  56. },
  57. ready: function(){
  58. core.getTargets(site.targets).then(() => {
  59. log("I'm ready.");
  60. Notification.requestPermission();
  61. core.getInstalls();
  62. }).catch(e => {
  63. console.error(`${SCRIPTID}:${e.lineNumber} ${e.name}: ${e.message}`);
  64. });
  65. },
  66. getInstalls: function(){
  67. installs = Storage.read('installs') || {};
  68. let items = elements.userScriptListItems;
  69. Array.from(items).forEach(li => {
  70. let name = site.get.scriptName(li);
  71. let totalInstalls = site.get.totalInstalls(li);
  72. if(THRESHOLDS.some(t => installs[name] < t && t <= totalInstalls)){
  73. let numberText = totalInstalls.toLocaleString();
  74. let installsText = (totalInstalls === 1) ? 'install' : 'installs';
  75. let notification = new Notification(SCRIPTNAME, {body: `${numberText} ${installsText}: ${name}`});
  76. notification.addEventListener('click', function(e){
  77. notification.close();
  78. });
  79. li.dataset[FLAGNAME] = 'true';
  80. }
  81. installs[name] = totalInstalls;
  82. });
  83. Storage.save('installs', installs);
  84. },
  85. getTarget: function(selector, retry = 10, interval = 1*SECOND){
  86. const key = selector.name;
  87. const get = function(resolve, reject){
  88. let selected = selector();
  89. if(selected && selected.length > 0) selected.forEach((s) => s.dataset.selector = key);/* elements */
  90. else if(selected instanceof HTMLElement) selected.dataset.selector = key;/* element */
  91. else if(--retry) return log(`Not found: ${key}, retrying... (${retry})`), setTimeout(get, interval, resolve, reject);
  92. else return reject(new Error(`Not found: ${selector.name}, I give up.`));
  93. elements[key] = selected;
  94. resolve(selected);
  95. };
  96. return new Promise(function(resolve, reject){
  97. get(resolve, reject);
  98. });
  99. },
  100. getTargets: function(selectors, retry = 10, interval = 1*SECOND){
  101. return Promise.all(Object.values(selectors).map(selector => core.getTarget(selector, retry, interval)));
  102. },
  103. addStyle: function(name = 'style'){
  104. if(html[name] === undefined) return;
  105. let style = createElement(html[name]());
  106. document.head.appendChild(style);
  107. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  108. elements[name] = style;
  109. },
  110. };
  111. const html = {
  112. style: () => `
  113. <style type="text/css" id="${SCRIPTID}-style">
  114. li[data-${FLAGNAME}="true"]{
  115. background: #ffc;
  116. }
  117. </style>
  118. `,
  119. };
  120. const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window);
  121. const alert = window.alert.bind(window), confirm = window.confirm.bind(window), prompt = window.prompt.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
  122. if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  123. class Storage{
  124. static key(key){
  125. return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
  126. }
  127. static save(key, value, expire = null){
  128. key = Storage.key(key);
  129. localStorage[key] = JSON.stringify({
  130. value: value,
  131. saved: Date.now(),
  132. expire: expire,
  133. });
  134. }
  135. static read(key){
  136. key = Storage.key(key);
  137. if(localStorage[key] === undefined) return undefined;
  138. let data = JSON.parse(localStorage[key]);
  139. if(data.value === undefined) return data;
  140. if(data.expire === undefined) return data;
  141. if(data.expire === null) return data.value;
  142. if(data.expire < Date.now()) return localStorage.removeItem(key);/*undefined*/
  143. return data.value;
  144. }
  145. static remove(key){
  146. key = Storage.key(key);
  147. delete localStorage.removeItem(key);
  148. }
  149. static delete(key){
  150. Storage.remove(key);
  151. }
  152. static saved(key){
  153. key = Storage.key(key);
  154. if(localStorage[key] === undefined) return undefined;
  155. let data = JSON.parse(localStorage[key]);
  156. if(data.saved) return data.saved;
  157. else return undefined;
  158. }
  159. }
  160. const $ = function(s, f){
  161. let target = document.querySelector(s);
  162. if(target === null) return null;
  163. return f ? f(target) : target;
  164. };
  165. const $$ = function(s, f){
  166. let targets = document.querySelectorAll(s);
  167. return f ? Array.from(targets).map(t => f(t)) : targets;
  168. };
  169. const createElement = function(html = '<div></div>'){
  170. let outer = document.createElement('div');
  171. outer.innerHTML = html;
  172. return outer.firstElementChild;
  173. };
  174. const log = function(){
  175. if(!DEBUG) return;
  176. let l = log.last = log.now || new Date(), n = log.now = new Date();
  177. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  178. //console.log(error.stack);
  179. console.log(
  180. SCRIPTID + ':',
  181. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  182. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  183. /* :00 */ ':' + line,
  184. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  185. /* caller */ (callers[1] || '') + '()',
  186. ...arguments
  187. );
  188. };
  189. log.formats = [{
  190. name: 'Firefox Scratchpad',
  191. detector: /MARKER@Scratchpad/,
  192. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  193. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  194. }, {
  195. name: 'Firefox Console',
  196. detector: /MARKER@debugger/,
  197. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  198. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  199. }, {
  200. name: 'Firefox Greasemonkey 3',
  201. detector: /\/gm_scripts\//,
  202. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  203. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  204. }, {
  205. name: 'Firefox Greasemonkey 4+',
  206. detector: /MARKER@user-script:/,
  207. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  208. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  209. }, {
  210. name: 'Firefox Tampermonkey',
  211. detector: /MARKER@moz-extension:/,
  212. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  213. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  214. }, {
  215. name: 'Chrome Console',
  216. detector: /at MARKER \(<anonymous>/,
  217. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  218. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  219. }, {
  220. name: 'Chrome Tampermonkey',
  221. detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?name=/,
  222. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 4,
  223. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  224. }, {
  225. name: 'Chrome Extension',
  226. detector: /at MARKER \(chrome-extension:/,
  227. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
  228. getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
  229. }, {
  230. name: 'Edge Console',
  231. detector: /at MARKER \(eval/,
  232. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  233. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  234. }, {
  235. name: 'Edge Tampermonkey',
  236. detector: /at MARKER \(Function/,
  237. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  238. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  239. }, {
  240. name: 'Safari',
  241. detector: /^MARKER$/m,
  242. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  243. getCallers: (e) => e.stack.split('\n'),
  244. }, {
  245. name: 'Default',
  246. detector: /./,
  247. getLine: (e) => 0,
  248. getCallers: (e) => [],
  249. }];
  250. log.format = log.formats.find(function MARKER(f){
  251. if(!f.detector.test(new Error().stack)) return false;
  252. //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
  253. return true;
  254. });
  255. core.initialize();
  256. if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
  257. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址