EE Enhancer

字节圈增强脚本

  1. // ==UserScript==
  2. // @name EE Enhancer
  3. // @namespace https://greasyfork.org/zh-CN/users/467781
  4. // @version 0.6.2
  5. // @license WTFPL
  6. // @description 字节圈增强脚本
  7. // @run-at document-start
  8. // @author acdzh
  9. // @match https://ee.bytedance.net/malaita/pc/*
  10. // @icon https://ee.bytedance.net/malaita/static/img/malaita.png
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_unregisterMenuCommand
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @grant unsafeWindow
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. function debugLog(...args) {
  20. console.log('[EE Enhancer]', ...args);
  21. }
  22.  
  23. function waitForGet(selector, timeout = 500, maxRetry = 20) {
  24. return new Promise((resolve, reject) => {
  25. let retryCount = 0;
  26. const interval = setInterval(() => {
  27. if (val = selector()) {
  28. clearInterval(interval);
  29. resolve(val);
  30. } else if (retryCount++ > maxRetry) {
  31. clearInterval(interval);
  32. reject(new Error(`waitForGet: ${selector} timeout`));
  33. }
  34. }, timeout);
  35. });
  36. }
  37. (function () {
  38. const MENU_KEY_TIME_ORDER = 'menu_time_order';
  39. const MENU_KEY_AUTO_BACKUP = 'menu_auto_backup';
  40. const MENU_KEY_FILTER_NEW_BYTEDANCER = 'menu_filter_new_bytedancer';
  41. const MENU_KEY_FILTER_ANONYMOUS = 'menu_key_filter_anonymous';
  42. const MENU_KEY_POST_BLACK_WORD_LIST = 'menu_key_post_blackword_list';
  43. const MENU_KEY_DEBUG_MENU = 'menu_key_debug_menu';
  44. const MENU_ALL = [
  45. [MENU_KEY_TIME_ORDER, '帖子按时间排序', true, () => { switchMenuCommand(MENU_KEY_TIME_ORDER); alert('请手动刷新页面以生效.'); }],
  46. [MENU_KEY_AUTO_BACKUP, '备份浏览过的帖子', false, () => { switchMenuCommand(MENU_KEY_AUTO_BACKUP); }],
  47. [MENU_KEY_FILTER_NEW_BYTEDANCER, '过滤新人报道', true, () => { switchMenuCommand(MENU_KEY_FILTER_NEW_BYTEDANCER) }],
  48. [MENU_KEY_FILTER_ANONYMOUS, '过滤匿名', true, () => { switchMenuCommand(MENU_KEY_FILTER_ANONYMOUS) }],
  49. [MENU_KEY_POST_BLACK_WORD_LIST, '过滤词列表 (英文逗号分隔)', '', () => { inputMenuCommand(MENU_KEY_POST_BLACK_WORD_LIST) }],
  50. [MENU_KEY_DEBUG_MENU, 'DEBUG MENU', 0, () => { debugLog(MENU_VALUE, REGISITED_MENU_ID) }]
  51. ];
  52. const MENU_VALUE = {};
  53. const REGISITED_MENU_ID = [];
  54. const TTQ_BACKUP_DB_NAME = 'ttq_backup_db';
  55. // region MENU
  56. function registerMenuCommand() {
  57. if (REGISITED_MENU_ID.length >= MENU_ALL.length) {
  58. REGISITED_MENU_ID.forEach(id => GM_unregisterMenuCommand(id));
  59. REGISITED_MENU_ID.length = 0;
  60. }
  61. MENU_ALL.forEach(([key, name, defaultValue, handler]) => {
  62. let v = MENU_VALUE[key] ?? GM_getValue(key);
  63. if (v == null){
  64. GM_setValue(key, defaultValue);
  65. v = defaultValue;
  66. };
  67. MENU_VALUE[key] = v;
  68. const menuId = GM_registerMenuCommand(`${v === true ? '✅ ' : v === false ? '❌ ': ''}${name}`, handler);
  69. REGISITED_MENU_ID.push(menuId);
  70. });
  71. }
  72. function switchMenuCommand(key) {
  73. const currentValue = MENU_VALUE[key];
  74. GM_setValue(key, !currentValue);
  75. MENU_VALUE[key] = !currentValue;
  76. registerMenuCommand();
  77. }
  78. function inputMenuCommand(key) {
  79. const currentValue = MENU_VALUE[key];
  80. const newValue = prompt(`请输入${key}`, currentValue);
  81. GM_setValue(key, newValue);
  82. MENU_VALUE[key] = newValue;
  83. registerMenuCommand();
  84. }
  85. function getMenuValue(key) {
  86. return MENU_VALUE[key];
  87. }
  88. // endregion
  89. // region indexDB
  90. const TTQDB = (function () {
  91. function TTQDB() {
  92. this.db = null;
  93. this.isReady = false;
  94. this.dbName = TTQ_BACKUP_DB_NAME;
  95. this.dbVersion = 1;
  96. this.dbStoreName = 'ttq_backup_store';
  97. }
  98. TTQDB.prototype.init = function () {
  99. return new Promise((resolve, reject) => {
  100. const request = indexedDB.open(this.dbName, this.dbVersion);
  101. request.onerror = reject;
  102. request.onsuccess = () => {
  103. this.db = request.result;
  104. this.isReady = true;
  105. resolve(this.db);
  106. };
  107. request.onupgradeneeded = () => {
  108. const db = request.result;
  109. ['posts', 'comments', 'item_comments', 'likes', 'item_likes', 'users'].forEach(storeName => {
  110. if (!db.objectStoreNames.contains(storeName)) {
  111. const store = db.createObjectStore(storeName, { keyPath: 'id' });
  112. store.createIndex('id', 'id', { unique: true });
  113. }
  114. });
  115. };
  116. });
  117. }
  118. TTQDB.prototype.getStore = function (storeName, mode = 'readonly') {
  119. if (!this.db) { throw new Error('db not init'); }
  120. return this.db.transaction(storeName, mode).objectStore(storeName);
  121. }
  122. TTQDB.prototype.get = function (storeName, id) {
  123. return new Promise((resolve, reject) => {
  124. const store = this.getStore(storeName);
  125. const request = store.get(id);
  126. request.onerror = reject;
  127. request.onsuccess = () => resolve(request.result);
  128. });
  129. }
  130. TTQDB.prototype.getAll = function (storeName) {
  131. return new Promise((resolve, reject) => {
  132. const store = this.getStore(storeName);
  133. const request = store.getAll();
  134. request.onerror = reject;
  135. request.onsuccess = () => resolve(request.result);
  136. });
  137. }
  138. TTQDB.prototype.put = function (storeName, data) {
  139. return new Promise((resolve, reject) => {
  140. const store = this.getStore(storeName, 'readwrite');
  141. const request = store.put(data);
  142. request.onerror = reject;
  143. request.onsuccess = () => resolve(request.result);
  144. });
  145. }
  146. return TTQDB;
  147. })();
  148. const ttqDB = new TTQDB();
  149. window.unsafeWindow.ttqDB = ttqDB;
  150. window.unsafeWindow.ttqMap = {
  151. posts: {},
  152. users: {},
  153. comments: {},
  154. likes: {},
  155. };
  156. async function backupRes(res) {
  157. if (!ttqDB.isReady) await ttqDB.init();
  158. res.entities && ['posts', 'comments', 'likes', 'users'].forEach(storeName =>
  159. res.entities[storeName] && Object.values(res.entities[storeName]).forEach(data => {
  160. ttqDB.put(storeName, data);
  161. window.unsafeWindow.ttqMap[storeName][data.id] = data;
  162. storeName === 'posts' && debugLog('put: ', storeName, data.id, data);
  163. })
  164. );
  165. res.relationships && ['item_comments', 'item_likes'].forEach(storeName =>
  166. res.relationships[storeName]
  167. && Object.entries(res.relationships[storeName])
  168. .forEach(
  169. ([id, data]) => ttqDB.put(storeName, { ...data, id: parseInt(id, 10) })
  170. )
  171. );
  172. }
  173. // endregion
  174. // region main
  175. // region main - hack fetch
  176. const originFetch = fetch;
  177. window.unsafeWindow.fetch = (url, options) => {
  178. return originFetch(url, options).then(async (response) => {
  179. debugLog('hack: ', url, options)
  180. if (url.includes('/malaita/v2/user/settings/')) {
  181. // 获取用户设置
  182. if (getMenuValue(MENU_KEY_TIME_ORDER)) {
  183. debugLog('hit settings api');
  184. const responseClone = response.clone();
  185. let res = await responseClone.json();
  186. res.feed_type = 1;
  187. return new Response(JSON.stringify(res), response);
  188. }
  189. } else if (
  190. url.includes('/malaita/feeds/enter/') // 首屏
  191. || url.includes('/malaita/feeds/time_sequential/') // 下拉刷新 feed
  192. || /\/malaita\/v2\/1\/items\/\d+\/detail\//.exec(url) // 展开评论
  193. || /\/malaita\/v2\/1\/items\/\d+\/likes\//.exec(url) // 展开点赞
  194. || /\/malaita\/v2\/users\/\d+\/\?email_prefix=/.exec(url) // 他人信息
  195. || /\/malaita\/users\/\d+\/posts\//.exec(url) // 主页 posts
  196. || /\/malaita\/v2\/1\/search\/posts\//.exec(url) // 搜索 posts
  197. ) {
  198. // feed 与 展开点赞 / 评论列表
  199. const responseClone = response.clone();
  200. let res = await responseClone.json();
  201. // backup
  202. if (getMenuValue(MENU_KEY_AUTO_BACKUP)) {
  203. debugLog('hit backup posts: ', url);
  204. backupRes(res);
  205. }
  206.  
  207. // 打标
  208. if (res.entities && res.entities.users) {
  209. Object.keys(res.entities.users).forEach(userId => {
  210. const user = res.entities.users[userId];
  211. user.name = `${user.name}_${userId}`;
  212. });
  213. }
  214.  
  215. // filter
  216. if (res.entities && res.entities.posts) {
  217. const blackWordList = getMenuValue(MENU_KEY_POST_BLACK_WORD_LIST).split(',').filter(a => a !== '');
  218. const shouldFilterNewBytedancerPost = getMenuValue(MENU_KEY_FILTER_NEW_BYTEDANCER);
  219. const shouldFilterAnonymousPost = getMenuValue(MENU_KEY_FILTER_ANONYMOUS);
  220. Object.keys(res.entities.posts).forEach(postId => {
  221. const post = res.entities.posts[postId];
  222. let isHit = false;
  223. let hitBlackWord = '';
  224. if (blackWordList.some(word => post.content.includes(word) && (hitBlackWord = word))) {
  225. debugLog('hit black word: ',hitBlackWord, post);
  226. isHit = true;
  227. post.content = 'Blocked because black word: ' + hitBlackWord + '.';
  228. } else if (shouldFilterNewBytedancerPost && post.status === 128) {
  229. isHit = true;
  230. debugLog('remove new bytedancer post: ', post);
  231. post.content = 'Blocked because this post is from new bytedancer.';
  232. } else if (shouldFilterAnonymousPost && post.is_anonymous) {
  233. isHit = true;
  234. debugLog('remove anonymous post: ', post);
  235. post.content = 'Blocked because this is an anonymous post.';
  236. }
  237. if (isHit) {
  238. post.images = [];
  239. post.vid = post.vid_height = post.vid_width = post.vid_size = post.video_url = null;
  240. post.is_anonymous = true;
  241. if (relationships = res.relationships) {
  242. if (itemComment = relationships.item_comments?.[postId]) {
  243. itemComment.ids = [];
  244. itemComment.total = 0;
  245. itemComment.has_more = false;
  246. }
  247. if (itemLike = relationships.item_likes?.[postId]) {
  248. itemLike.total = 0;
  249. itemLike.has_like = false;
  250. itemLike.anonymous_count = 114514;
  251. itemLike.ids = itemLike.like_ids = [];
  252. if (extra = itemLike.extra) {
  253. extra.anonymous_count = 114514;
  254. extra.has_like = false;
  255. }
  256. }
  257. }
  258. debugLog('edited post', postId, res.relationships.item_comments[postId], res.relationships.item_likes[postId]);
  259. }
  260. });
  261. }
  262. return new Response(JSON.stringify(res), response);
  263. } else {
  264. return response;
  265. }
  266. });
  267. };
  268. // endregion
  269. // region main - hack xhr
  270. const originXhrOpen = XMLHttpRequest.prototype.open;
  271. XMLHttpRequest.prototype.open = function (method, url, ...args) {
  272. if (url.includes('snssdk.com/') || url.includes('https://ee.bytedance.net/sentry/api/')) {
  273. // debugLog('已拦截埋点上报: ', url);
  274. this.abort();
  275. return;
  276. }
  277. return originXhrOpen.call(this, method, url, ...args);
  278. };
  279. // endregion
  280. // region main - register menu
  281. registerMenuCommand();
  282. // endregion
  283. // endregion
  284. })();
  285. (async function () {
  286. const postListElement = await waitForGet(() => document.querySelector('.post-list'), 500, 20);
  287. const observer = new MutationObserver((mutations) => {
  288. mutations.forEach((mutation) => {
  289. if (mutation.type === 'childList') {
  290. mutation.addedNodes.forEach((node) => {
  291. if (node.classList && node.classList.contains('post') && node.classList.contains('card')) {
  292. const postElement = node;
  293. const mainContainerEle = postElement.querySelector('.main-container');
  294. const postHeaderEle = postElement.querySelector('.post-header');
  295. const afterPostHeaderEle = postHeaderEle.nextElementSibling;
  296. const authorEle = postElement.querySelector('.nickname');
  297. const [authorName, authorId] = authorEle.innerText.split('_');
  298.  
  299. if (authorId) {
  300. const author = window.unsafeWindow.ttqMap.users[authorId] || (await (window.unsafeWindow.ttqDB.get('users', authorId)));
  301. if (author.department) {
  302. const authorDepartmentSpanEle = document.createElement('div');
  303. if (author.office) {
  304. authorDepartmentSpanEle.innerText = author.office + ' ' + author.department;
  305. } else {
  306. authorDepartmentSpanEle.innerText = author.department;
  307. }
  308. authorDepartmentSpanEle.style = "font-size:4px;color:grey;margin-top:6px;";
  309. mainContainerEle.insertBefore(authorDepartmentSpanEle, afterPostHeaderEle);
  310. }
  311. if (author.description) {
  312. const authorDescriptionSpanEle = document.createElement('div');
  313. authorDescriptionSpanEle.innerText = author.description;
  314. authorDescriptionSpanEle.style = "color:grey;font-size:4px;margin-top:6px;";
  315. mainContainerEle.insertBefore(authorDescriptionSpanEle, afterPostHeaderEle);
  316. }
  317. }
  318. // const postId = postElement.getAttribute('data-id');
  319. // const postContentElement = postElement.querySelector('.post-content');
  320. // const postContent = postContentElement.innerText;
  321. // const postContentElementClone = postContentElement.cloneNode(true);
  322. // postContentElementClone.querySelectorAll('img').forEach(img => img.remove());
  323. // const postContentText = postContentElementClone.innerText;
  324. // const postContentTextLength = postContentText.length;
  325. // const postContentTextLengthElement = document.createElement('span');
  326. // postContentTextLengthElement.innerText = postContentTextLength;
  327. // postContentTextLengthElement.style.color = postContentTextLength > 100 ? 'red' : 'green';
  328. // postContentElement.appendChild(postContentTextLengthElement);
  329. }
  330. });
  331. }
  332. });
  333. });
  334. observer.observe(postListElement, {
  335. childList: true,
  336. subtree: false
  337. });
  338. })();
  339. })();