FaceBook 贴文悬浮截图按钮

在贴文右上新增一个悬浮截图按钮,按下后可以对贴文进行截图保存,方便与其他人分享

  1. // ==UserScript==
  2. // @name Floating Screenshot Button for Facebook Posts
  3. // @name:zh-TW FaceBook 貼文懸浮截圖按鈕
  4. // @name:zh-CN FaceBook 贴文悬浮截图按钮
  5. // @namespace http://tampermonkey.net/
  6. // @version 3.4
  7. // @description A floating screenshot button is added to the top-right corner of the post. When clicked, it allows users to capture and save a screenshot of the post, making it easier to share with others.
  8. // @description:zh-TW 在貼文右上新增一個懸浮截圖按鈕,按下後可以對貼文進行截圖保存,方便與其他人分享
  9. // @description:zh-CN 在贴文右上新增一个悬浮截图按钮,按下后可以对贴文进行截图保存,方便与其他人分享
  10. // @author chatgpt
  11. // @match https://www.facebook.com/*
  12. // @grant none
  13. // @require https://cdn.jsdelivr.net/npm/html-to-image@1.11.11/dist/html-to-image.min.js
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. 'use strict';
  19.  
  20. // ===== 禁用聚焦樣式,移除藍框或陰影 =====
  21. const style = document.createElement('style');
  22. style.textContent = `
  23. *:focus, *:focus-visible, *:focus-within {
  24. outline: none !important;
  25. box-shadow: none !important;
  26. }
  27. `;
  28. document.head.appendChild(style);
  29.  
  30. let lastRun = 0; // 上一次主頁按鈕建立的時間
  31. const debounceDelay = 1000; // 間隔時間(毫秒)
  32.  
  33. // ===== 從貼文中取得 fbid(供檔名使用)=====
  34. function getFbidFromPost(post) {
  35. const links = Array.from(post.querySelectorAll('a[href*="fbid="], a[href*="story_fbid="]'));
  36. for (const a of links) {
  37. try {
  38. const url = new URL(a.href);
  39. const fbid = url.searchParams.get('fbid') || url.searchParams.get('story_fbid');
  40. if (fbid) return fbid;
  41. } catch (e) { }
  42. }
  43. try {
  44. const dataFt = post.getAttribute('data-ft');
  45. if (dataFt) {
  46. const match = dataFt.match(/"top_level_post_id":"(\d+)"/);
  47. if (match) return match[1];
  48. }
  49. } catch (e) { }
  50. try {
  51. const url = new URL(window.location.href);
  52. const fbid = url.searchParams.get('fbid') || url.searchParams.get('story_fbid');
  53. if (fbid) return fbid;
  54. } catch (e) { }
  55. return 'unknownFBID';
  56. }
  57.  
  58. // ===== 主頁貼文的截圖按鈕觀察器 =====
  59. const observer = new MutationObserver(() => {
  60. const now = Date.now();
  61. if (now - lastRun < debounceDelay) return;
  62. lastRun = now;
  63.  
  64. document.querySelectorAll('div.x1lliihq').forEach(post => {
  65. if (post.dataset.sbtn === '1') return;
  66.  
  67. // 排除含社團建議文字的區塊
  68. const textContent = post.innerText || post.textContent || '';
  69. if (textContent.includes('社團建議') || textContent.includes('Suggested Groups')) return;
  70.  
  71. let btnGroup = post.querySelector('div[role="group"]')
  72. || post.querySelector('div.xqcrz7y')
  73. || post.querySelector('div.x1qx5ct2');
  74. if (!btnGroup) return;
  75.  
  76. post.dataset.sbtn = '1'; // 標記已處理
  77. btnGroup.style.position = 'relative';
  78.  
  79. // 建立截圖按鈕
  80. const btn = document.createElement('div');
  81. btn.textContent = '📸';
  82. btn.title = '截圖貼文';
  83. Object.assign(btn.style, {
  84. position: 'absolute',
  85. left: '-40px',
  86. top: '0',
  87. width: '32px',
  88. height: '32px',
  89. display: 'flex',
  90. alignItems: 'center',
  91. justifyContent: 'center',
  92. borderRadius: '50%',
  93. backgroundColor: '#3A3B3C',
  94. color: 'white',
  95. cursor: 'pointer',
  96. zIndex: '9999',
  97. transition: 'background .2s',
  98. });
  99.  
  100. btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#4E4F50');
  101. btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#3A3B3C');
  102.  
  103. // 按下後進行截圖
  104. btn.addEventListener('click', async e => {
  105. e.stopPropagation();
  106. btn.textContent = '⏳';
  107. btn.style.pointerEvents = 'none';
  108.  
  109. try {
  110. // 嘗試展開貼文內的「查看更多」
  111. const seeMoreCandidates = post.querySelectorAll('span, a, div, button');
  112. let clicked = false;
  113. for (const el of seeMoreCandidates) {
  114. const text = el.innerText?.trim() || el.textContent?.trim();
  115. if (!text) continue;
  116. if (text === '查看更多' || text === '顯示更多' || text === 'See more' || text === 'See More' || text === '…更多') {
  117. try {
  118. el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
  119. clicked = true;
  120. console.log('已點擊查看更多:', el);
  121. } catch (err) {
  122. console.warn('點擊查看更多失敗:', err);
  123. }
  124. }
  125. }
  126. if (clicked) await new Promise(r => setTimeout(r, 1000));
  127.  
  128. await new Promise(r => setTimeout(r, 500));
  129.  
  130. const fbid = getFbidFromPost(post);
  131. const nowDate = new Date();
  132. const pad = n => n.toString().padStart(2, '0');
  133. const datetimeStr =
  134. nowDate.getFullYear().toString() +
  135. pad(nowDate.getMonth() + 1) +
  136. pad(nowDate.getDate()) + '_' +
  137. pad(nowDate.getHours()) + '_' +
  138. pad(nowDate.getMinutes()) + '_' +
  139. pad(nowDate.getSeconds());
  140. const filename = `${fbid}_${datetimeStr}.png`;
  141.  
  142. // 等待字型載入
  143. await document.fonts.ready;
  144.  
  145. // 執行截圖
  146. const dataUrl = await window.htmlToImage.toPng(post, {
  147. backgroundColor: '#1c1c1d',
  148. pixelRatio: 2,
  149. cacheBust: true,
  150. });
  151.  
  152. // 儲存圖片
  153. const link = document.createElement('a');
  154. link.href = dataUrl;
  155. link.download = filename;
  156. link.click();
  157.  
  158. btn.textContent = '✅';
  159. } catch (err) {
  160. console.error('截圖錯誤:', err);
  161. alert('截圖失敗,請稍後再試');
  162. btn.textContent = '❌';
  163. }
  164.  
  165. // 一秒後還原按鈕狀態
  166. setTimeout(() => {
  167. btn.textContent = '📸';
  168. btn.style.pointerEvents = 'auto';
  169. }, 1000);
  170. });
  171.  
  172. btnGroup.appendChild(btn);
  173. });
  174. });
  175.  
  176. observer.observe(document.body, { childList: true, subtree: true });
  177.  
  178. // ====== 社團截圖按鈕功能 ======
  179. let groupObserver = null;
  180. function initGroupPostObserver() {
  181. if (groupObserver) return;
  182. groupObserver = new MutationObserver(() => {
  183. document.querySelectorAll('div.x1yztbdb.x1n2onr6.xh8yej3.x1ja2u2z').forEach(post => {
  184. if (post.dataset.sbtn === '1') return;
  185. let btnParent = post.querySelector('div.xqcrz7y.x78zum5.x1qx5ct2') ||
  186. post.closest('div.xqcrz7y.x78zum5.x1qx5ct2');
  187. if (!btnParent) return;
  188. post.dataset.sbtn = '1';
  189. btnParent.style.position = 'relative';
  190.  
  191. const btn = document.createElement('div');
  192. btn.textContent = '📸';
  193. btn.title = '截圖社團貼文';
  194. Object.assign(btn.style, {
  195. position: 'absolute', left: '-40px', top: '0',
  196. width: '32px', height: '32px', display: 'flex',
  197. alignItems: 'center', justifyContent: 'center',
  198. borderRadius: '50%', backgroundColor: '#3A3B3C',
  199. color: 'white', cursor: 'pointer', zIndex: '9999',
  200. transition: 'background .2s',
  201. });
  202.  
  203. btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#4E4F50');
  204. btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#3A3B3C');
  205. btn.addEventListener('click', async e => {
  206. e.stopPropagation();
  207. btn.textContent = '⏳'; btn.style.pointerEvents = 'none';
  208.  
  209. try {
  210. post.querySelectorAll('span,a,div,button').forEach(el => {
  211. const txt = el.innerText?.trim() || el.textContent?.trim();
  212. if (['查看更多', '顯示更多', 'See more', 'See More', '…更多'].includes(txt)) {
  213. el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
  214. }
  215. });
  216. await new Promise(r => setTimeout(r, 1000));
  217. await new Promise(r => setTimeout(r, 500));
  218.  
  219. const groupId = location.pathname.match(/^\/groups\/(\d+)/)?.[1] || 'unknownGroup';
  220. const now = new Date();
  221. const pad = n => n.toString().padStart(2, '0');
  222. const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}`;
  223. const filename = `${groupId}_${ts}.png`;
  224.  
  225. // 等待字型載入
  226. await document.fonts.ready;
  227.  
  228. // 執行截圖
  229. const dataUrl = await window.htmlToImage.toPng(post, {
  230. backgroundColor: '#1c1c1d',
  231. pixelRatio: 2,
  232. cacheBust: true,
  233. });
  234.  
  235. // 儲存圖片
  236. const link = document.createElement('a');
  237. link.href = dataUrl;
  238. link.download = filename;
  239. link.click();
  240.  
  241. btn.textContent = '✅';
  242. } catch (err) {
  243. console.error('社團截圖錯誤:', err);
  244. alert('截圖失敗,請稍後再試');
  245. btn.textContent = '❌';
  246. }
  247. setTimeout(() => { btn.textContent = '📸'; btn.style.pointerEvents = 'auto'; }, 1000);
  248. });
  249.  
  250. btnParent.appendChild(btn);
  251. });
  252. });
  253. groupObserver.observe(document.body, { childList: true, subtree: true });
  254. console.log('[腳本] 社團觀察器已啟動');
  255. }
  256.  
  257. function stopGroupPostObserver() {
  258. if (groupObserver) {
  259. groupObserver.disconnect(); groupObserver = null;
  260. console.log('[腳本] 社團觀察器已停止');
  261. }
  262. }
  263.  
  264. // ====== 粉絲專頁截圖按鈕功能 ======
  265. let pageObserver = null;
  266. function initPagePostObserver() {
  267. if (pageObserver) return;
  268. pageObserver = new MutationObserver(() => {
  269. document.querySelectorAll('div.x1yztbdb.x1n2onr6.xh8yej3.x1ja2u2z').forEach(post => {
  270. if (post.dataset.sbtn === '1') return;
  271. let btnParent = post.querySelector('div.xqcrz7y.x78zum5.x1qx5ct2.x1y1aw1k.xf159sx.xwib8y2.xmzvs34.xw4jnvo') ||
  272. post.closest('div.xqcrz7y.x78zum5.x1qx5ct2.x1y1aw1k.xf159sx.xwib8y2.xmzvs34.xw4jnvo');
  273. if (!btnParent) return;
  274. post.dataset.sbtn = '1';
  275. btnParent.style.position = 'relative';
  276.  
  277. const btn = document.createElement('div');
  278. btn.textContent = '📸';
  279. btn.title = '截圖粉專貼文';
  280. Object.assign(btn.style, {
  281. position: 'absolute', left: '-40px', top: '0',
  282. width: '32px', height: '32px', display: 'flex',
  283. alignItems: 'center', justifyContent: 'center',
  284. borderRadius: '50%', backgroundColor: '#3A3B3C',
  285. color: 'white', cursor: 'pointer', zIndex: '9999',
  286. transition: 'background .2s',
  287. });
  288.  
  289. btn.addEventListener('mouseenter', () => btn.style.backgroundColor = '#4E4F50');
  290. btn.addEventListener('mouseleave', () => btn.style.backgroundColor = '#3A3B3C');
  291. btn.addEventListener('click', async e => {
  292. e.stopPropagation();
  293. btn.textContent = '⏳'; btn.style.pointerEvents = 'none';
  294.  
  295. try {
  296. post.querySelectorAll('span,a,div,button').forEach(el => {
  297. const txt = el.innerText?.trim() || el.textContent?.trim();
  298. if (['查看更多', '顯示更多', 'See more', 'See More', '…更多'].includes(txt)) {
  299. el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
  300. }
  301. });
  302. await new Promise(r => setTimeout(r, 1000));
  303. await new Promise(r => setTimeout(r, 500));
  304.  
  305. const now = new Date();
  306. const pad = n => n.toString().padStart(2, '0');
  307. const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}`;
  308. const pageName = location.pathname.split('/').filter(Boolean)[0] || 'page';
  309. const filename = `${pageName}_${ts}.png`;
  310.  
  311. // 等待字型載入
  312. await document.fonts.ready;
  313.  
  314. // 執行截圖
  315. const dataUrl = await window.htmlToImage.toPng(post, {
  316. backgroundColor: '#1c1c1d',
  317. pixelRatio: 2,
  318. cacheBust: true,
  319. });
  320.  
  321. // 儲存圖片
  322. const link = document.createElement('a');
  323. link.href = dataUrl;
  324. link.download = filename;
  325. link.click();
  326.  
  327. btn.textContent = '✅';
  328. } catch (err) {
  329. console.error('粉專截圖錯誤:', err);
  330. alert('截圖失敗,請稍後再試');
  331. btn.textContent = '❌';
  332. }
  333. setTimeout(() => { btn.textContent = '📸'; btn.style.pointerEvents = 'auto'; }, 1000);
  334. });
  335.  
  336. btnParent.appendChild(btn);
  337. });
  338. });
  339. pageObserver.observe(document.body, { childList: true, subtree: true });
  340. console.log('[腳本] 粉專觀察器已啟動');
  341. }
  342.  
  343. function stopPagePostObserver() {
  344. if (pageObserver) {
  345. pageObserver.disconnect();
  346. pageObserver = null;
  347. console.log('[腳本] 粉專觀察器已停止');
  348. }
  349. }
  350.  
  351. let lastPathname = location.pathname;
  352.  
  353. // 判斷是否為社團頁面
  354. function isGroupPage(path) {
  355. return path.startsWith('/groups/');
  356. }
  357.  
  358. // 判斷是否為粉專頁面(排除常見非粉專頁)
  359. function isPagePage(path) {
  360. if (isGroupPage(path)) return false;
  361. const segments = path.split('/').filter(Boolean);
  362. if (segments.length === 0) return false;
  363.  
  364. // 排除非粉專常見路徑,例如 marketplace、gaming、watch 等
  365. const excluded = ['watch', 'gaming', 'marketplace', 'groups', 'friends', 'notifications', 'messages'];
  366. return !excluded.includes(segments[0]);
  367. }
  368.  
  369. // 根據路徑切換對應的觀察器
  370. function handlePathChange(newPath) {
  371. stopGroupPostObserver();
  372. stopPagePostObserver();
  373.  
  374. if (isGroupPage(newPath)) {
  375. initGroupPostObserver();
  376. } else if (isPagePage(newPath)) {
  377. initPagePostObserver();
  378. } else {
  379. console.log(`[腳本] 非支援的頁面類型:${newPath}`);
  380. }
  381. }
  382.  
  383. // 初次執行(初次載入)
  384. handlePathChange(location.pathname);
  385.  
  386. // 每秒偵測路徑變化並切換觀察器
  387. setInterval(() => {
  388. const currentPath = location.pathname;
  389. if (currentPath !== lastPathname) {
  390. lastPathname = currentPath;
  391. handlePathChange(currentPath);
  392. }
  393. }, 1000);
  394.  
  395. })();