您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Send Brew.link profiles directly to your Fellow Aiden
// ==UserScript== // @name Brew.link to Aiden // @namespace NewsGuyTor // @version 1.2 // @description Send Brew.link profiles directly to your Fellow Aiden // @match https://brew.link/p/* // @license MIT // @grant GM.xmlHttpRequest // @grant GM_xmlHttpRequest // @run-at document-idle // ==/UserScript== (function() { 'use strict'; /**************************************************** * 1) Detect Which “GM” HTTP Function is Available ****************************************************/ let gmRequest = null; if (typeof GM_xmlHttpRequest !== 'undefined') { // Tampermonkey-style gmRequest = GM_xmlHttpRequest; } else if (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function') { // Violentmonkey-style gmRequest = GM.xmlHttpRequest; } // Fallback: if no GM_ method is found, we’ll use fetch() (may fail with CORS). function doHttpRequest({method, url, headers, data, onload, onerror}) { if (gmRequest) { gmRequest({ method, url, headers, data, onload, onerror }); } else { fetch(url, { method, headers, body: data }) .then(async (resp) => { const text = await resp.text(); onload({ status: resp.status, responseText: text }); }) .catch((err) => { onerror(err); }); } } /**************************************************** * 2) Config / Constants ****************************************************/ const BASE_URL = 'https://l8qtmnc692.execute-api.us-west-2.amazonaws.com/v1'; const LOGIN_ENDPOINT = '/auth/login'; const DEVICES_ENDPOINT = '/devices'; const PROFILES_ENDPOINT = (id) => `/devices/${id}/profiles`; const SHARED_PROFILE_ENDPOINT = (brewId) => `/shared/${brewId}`; // Fields we strip out before creating a new profile const SERVER_SIDE_PROFILE_FIELDS = [ 'id', 'createdAt', 'deletedAt', 'lastUsedTime', 'sharedFrom', 'isDefaultProfile', 'instantBrew', 'folder', 'duration', 'lastGBQuantity' ]; // Token validity duration in milliseconds (30 minutes) const TOKEN_VALIDITY_DURATION = 30 * 60 * 1000; /**************************************************** * 3) CSS (gradient button, bigger, animations, etc.) ****************************************************/ const STYLE = ` /* Big gradient "Send to Aiden" button, pinned top-right */ #sendToAidenBtn { position: fixed; top: 20px; right: 20px; padding: 16px 30px; font-size: 18px; font-family: sans-serif; font-weight: bold; color: #fff; background: linear-gradient(135deg, #111, #444); border: none; border-radius: 8px; cursor: pointer; z-index: 100000; transition: transform 0.3s ease, box-shadow 0.3s ease; box-shadow: 0 4px 10px rgba(0,0,0,0.4); } #sendToAidenBtn:hover { transform: scale(1.06); box-shadow: 0 6px 14px rgba(0,0,0,0.6); } /* Subtle 'pop in' animation on page load */ #sendToAidenBtn { animation: aidenBtnPop 0.7s ease 0s 1 normal forwards; } @keyframes aidenBtnPop { 0% { transform: scale(0.7); opacity: 0; } 100% { transform: scale(1); opacity: 1; } } /* Backdrop for modals */ #aidenBackdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 99998; display: none; } /******************************************** * "Billion-dollar startup" style login form ********************************************/ #aidenLoginForm { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 340px; background: linear-gradient(135deg, #fff, #f4f4f4); border-radius: 10px; box-shadow: 0 6px 14px rgba(0,0,0,0.25); z-index: 99999; display: none; padding: 24px; font-family: "Helvetica Neue", Arial, sans-serif; animation: loginFormPop 0.3s ease forwards; } @keyframes loginFormPop { 0% { transform: translate(-50%, -50%) scale(0.7); opacity: 0; } 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; } } #aidenLoginHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } #aidenLoginHeader h3 { margin: 0; font-size: 20px; } #aidenLoginClose { cursor: pointer; color: #a33; font-weight: bold; font-size: 20px; } #aidenLoginForm input { display: block; width: 100%; margin: 10px 0; padding: 10px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; } #aidenLoginButton { width: 100%; padding: 12px; border: none; border-radius: 4px; background: linear-gradient(135deg, #111, #444); color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; margin-top: 10px; transition: transform 0.2s ease; } #aidenLoginButton:hover { transform: scale(1.03); } #aidenMessage { color: #b00; margin-top: 6px; min-height: 1.2em; } /******************************************** * Success modal ********************************************/ #aidenSuccessModal { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 380px; background: #fff; border-radius: 8px; box-shadow: 0 6px 14px rgba(0,0,0,0.25); z-index: 99999; display: none; padding: 20px; font-family: "Helvetica Neue", Arial, sans-serif; } #aidenSuccessModal h2 { margin-top: 0; font-size: 20px; } #successDetailsToggle { color: #1d72b8; text-decoration: underline; cursor: pointer; margin-top: 10px; display: inline-block; } #successDetails { margin-top: 10px; padding: 10px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; display: none; /* hidden by default */ max-height: 300px; overflow-y: auto; white-space: pre-wrap; } #successCloseBtn { margin-top: 12px; padding: 8px 12px; border: none; border-radius: 4px; background: #888; color: #fff; cursor: pointer; } #successCloseBtn:hover { background: #666; } `; // Insert <style> into <head> const styleEl = document.createElement('style'); styleEl.textContent = STYLE; document.head.appendChild(styleEl); /**************************************************** * 4) DOM Setup ****************************************************/ // Big “Send to Aiden” button const button = document.createElement('button'); button.id = 'sendToAidenBtn'; button.textContent = 'Send to Aiden'; document.body.appendChild(button); // Backdrop (shared by login + success modal) const backdrop = document.createElement('div'); backdrop.id = 'aidenBackdrop'; document.body.appendChild(backdrop); // Login form const loginForm = document.createElement('div'); loginForm.id = 'aidenLoginForm'; loginForm.innerHTML = ` <div id="aidenLoginHeader"> <h3>Fellow Aiden Login</h3> <div id="aidenLoginClose">✖</div> </div> <input type="text" id="aidenEmail" placeholder="Email" /> <input type="password" id="aidenPassword" placeholder="Password" /> <button id="aidenLoginButton">Login</button> <div id="aidenMessage"></div> `; document.body.appendChild(loginForm); // Success modal const successModal = document.createElement('div'); successModal.id = 'aidenSuccessModal'; successModal.innerHTML = ` <h2>Profile Created!</h2> <p>Your brew profile has been sent to Aiden.</p> <span id="successDetailsToggle">Show details</span> <div id="successDetails"></div> <button id="successCloseBtn">Close</button> `; document.body.appendChild(successModal); // Refs const loginClose = document.getElementById('aidenLoginClose'); const loginButton = document.getElementById('aidenLoginButton'); const emailField = document.getElementById('aidenEmail'); const passwordField = document.getElementById('aidenPassword'); const messageField = document.getElementById('aidenMessage'); const successDetails = document.getElementById('successDetails'); const successModalEl = document.getElementById('aidenSuccessModal'); const successToggle = document.getElementById('successDetailsToggle'); const successCloseBtn = document.getElementById('successCloseBtn'); /**************************************************** * 5) Utilities ****************************************************/ // Show/hide the backdrop + login form function showLoginForm() { backdrop.style.display = 'block'; loginForm.style.display = 'block'; messageField.textContent = ''; } function hideLoginForm() { backdrop.style.display = 'none'; loginForm.style.display = 'none'; messageField.textContent = ''; } // Show/hide success modal function showSuccessModal() { backdrop.style.display = 'block'; successModalEl.style.display = 'block'; } function hideSuccessModal() { backdrop.style.display = 'none'; successModalEl.style.display = 'none'; successDetails.style.display = 'none'; // ensure collapsed } function getToken() { const token = localStorage.getItem('aiden_access_token'); const timestamp = localStorage.getItem('aiden_token_timestamp'); if (!token || !timestamp) return null; const now = Date.now(); if (now - parseInt(timestamp, 10) > TOKEN_VALIDITY_DURATION) { // Token has expired clearTokens(); return null; } return token; } function storeTokens(access, refresh) { localStorage.setItem('aiden_access_token', access); if (refresh) { localStorage.setItem('aiden_refresh_token', refresh); } localStorage.setItem('aiden_token_timestamp', Date.now().toString()); } function clearTokens() { localStorage.removeItem('aiden_access_token'); localStorage.removeItem('aiden_refresh_token'); localStorage.removeItem('aiden_token_timestamp'); localStorage.removeItem('aiden_brewer_id'); } // Core fetch using doHttpRequest with enhanced error handling function gmFetch(endpoint, { method='GET', body=null, requireAuth=true } = {}) { return new Promise((resolve, reject) => { const url = `${BASE_URL}${endpoint}`; const headers = { 'User-Agent': 'Fellow/5 CFNetwork/1568.300.101 Darwin/24.2.0', 'Content-Type': 'application/json' }; // Only add Authorization if needed if (requireAuth && getToken()) { headers['Authorization'] = `Bearer ${getToken()}`; } doHttpRequest({ method, url, headers, data: body, onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch { resolve(resp.responseText); } } else if (resp.status === 401) { // Unauthorized - Token might be invalid or expired clearTokens(); promptReLogin(`Unauthorized access. Please log in again.`); reject(new Error(`HTTP ${resp.status}: ${resp.responseText}`)); } else { reject(new Error(`HTTP ${resp.status}: ${resp.responseText}`)); } }, onerror: (err) => { reject(new Error(`NetworkError: ${JSON.stringify(err)}`)); } }); }); } // Parse the brew link ID from the current URL function parseBrewLinkID(url) { // pattern: (?:.*?/p/)?([a-zA-Z0-9]+)/?$ const pattern = /(?:.*\/p\/)?([a-zA-Z0-9]+)\/?$/; const match = url.match(pattern); if (!match) { return null; } return match[1]; } // Remove server-only fields function stripServerFields(profile) { SERVER_SIDE_PROFILE_FIELDS.forEach(f => delete profile[f]); return profile; } // Prompt user to re-login with an optional message function promptReLogin(message) { if (message) { alert(message); } showLoginForm(); } /**************************************************** * 6) Core Logic ****************************************************/ // Get (or fetch) the device ID async function getDeviceId() { const existingID = localStorage.getItem('aiden_brewer_id'); if (existingID) return existingID; const devices = await gmFetch(DEVICES_ENDPOINT, { method: 'GET' }); if (!Array.isArray(devices) || devices.length === 0) { throw new Error('No devices found for this account.'); } const device = devices[0]; // assume single brewer localStorage.setItem('aiden_brewer_id', device.id); return device.id; } // Login to Aiden async function loginToAiden(email, password) { const payload = JSON.stringify({ email, password }); const data = await gmFetch(LOGIN_ENDPOINT, { method: 'POST', body: payload, requireAuth: false }); if (!data.accessToken) { throw new Error('No accessToken returned from server.'); } storeTokens(data.accessToken, data.refreshToken || ''); localStorage.removeItem('aiden_brewer_id'); // re-fetch in case changed return true; } // Send the brew profile to Aiden async function sendProfileToAiden() { // 1) Parse brew link from URL const brewId = parseBrewLinkID(window.location.href); if (!brewId) { throw new Error('Invalid brew.link URL or ID format'); } // 2) Fetch shared profile (assume public; set requireAuth: false) const sharedProfile = await gmFetch(SHARED_PROFILE_ENDPOINT(brewId), { method: 'GET', requireAuth: false }); // 3) Strip server-only fields stripServerFields(sharedProfile); // 4) Create profile on user’s device const brewerId = await getDeviceId(); const createdProfile = await gmFetch(PROFILES_ENDPOINT(brewerId), { method: 'POST', body: JSON.stringify(sharedProfile) }); // 5) Show success modal with details showProfileSuccess(createdProfile); } // Show success modal with hidden JSON details function showProfileSuccess(profileData) { successDetails.textContent = JSON.stringify(profileData, null, 2); showSuccessModal(); } /**************************************************** * 7) Automatic Title Replacement ****************************************************/ (async function setDocumentTitle() { try { const brewId = parseBrewLinkID(window.location.href); if (!brewId) return; // Not a valid brew link => do nothing // Fetch shared profile without requiring auth const sharedProfile = await gmFetch(SHARED_PROFILE_ENDPOINT(brewId), { method: 'GET', requireAuth: false }); if (sharedProfile && typeof sharedProfile.title === 'string') { document.title = `${sharedProfile.title} - brew.link`; } } catch (e) { // If any error occurs, silently ignore and keep default title } })(); /**************************************************** * 8) Event Handlers ****************************************************/ // “Send to Aiden” button button.addEventListener('click', async () => { if (!getToken()) { // No valid token => show login showLoginForm(); return; } // If we do have a valid token, attempt the send try { await sendProfileToAiden(); } catch (err) { // Handle specific cases if (err.message.includes('HTTP 401')) { // Already handled in gmFetch, but you can add additional actions here if needed console.error('Unauthorized. Prompting re-login.'); } else { alert(`Failed sending profile: ${err.message}`); } } }); // Close login form loginClose.addEventListener('click', () => { hideLoginForm(); }); // Login button loginButton.addEventListener('click', async () => { const email = emailField.value.trim(); const password = passwordField.value.trim(); if (!email || !password) { messageField.textContent = 'Email and password cannot be empty!'; return; } messageField.textContent = 'Logging in...'; try { await loginToAiden(email, password); messageField.textContent = 'Login successful!'; hideLoginForm(); // Immediately send after successful login await sendProfileToAiden(); } catch (err) { messageField.textContent = `Login failed: ${err.message}`; } }); // Success modal: show/hide details successToggle.addEventListener('click', () => { if (successDetails.style.display === 'none') { successDetails.style.display = 'block'; successToggle.textContent = 'Hide details'; } else { successDetails.style.display = 'none'; successToggle.textContent = 'Show details'; } }); // Success modal: close button successCloseBtn.addEventListener('click', () => { hideSuccessModal(); }); })();