Greasy Fork is available in English.
Fetch BibTeX from Google Scholar on any site, with Overleaf integration and mirror configuration. (CSP Compliant)
当前为
// ==UserScript== // @name Universal Scholar BibTeX // @namespace com.jw23.overleaf // @version 2.1.0 // @description Fetch BibTeX from Google Scholar on any site, with Overleaf integration and mirror configuration. (CSP Compliant) // @author jw23 (Refactored) // @match *://*/* // @require https://cdn.jsdelivr.net/npm/@floating-ui/[email protected] // @require https://cdn.jsdelivr.net/npm/@floating-ui/[email protected] // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/simple-notify.min.js // @resource notifycss https://cdn.jsdelivr.net/npm/simple-notify/dist/simple-notify.css // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @grant GM_getResourceText // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_openInTab // @license MIT // ==/UserScript== (function () { 'use strict'; // ========================================================================= // 1. Configuration & State // ========================================================================= const CONSTANTS = { DEFAULT_MIRRORS: [ "https://scholar.google.com.hk", "https://scholar.lanfanshu.cn", "https://xs.vygc.top", "https://scholar.google.com" ], STORAGE_KEYS: { ENABLED: 'config_enabled', MIRROR: 'config_mirror', MIRROR_LIST: 'config_mirror_list', POS_X: 'ui_pos_x', POS_Y: 'ui_pos_y' } }; const State = { isEnabled: GM_getValue(CONSTANTS.STORAGE_KEYS.ENABLED, true), currentMirror: GM_getValue(CONSTANTS.STORAGE_KEYS.MIRROR, CONSTANTS.DEFAULT_MIRRORS[0]), customMirrors: GM_getValue(CONSTANTS.STORAGE_KEYS.MIRROR_LIST, CONSTANTS.DEFAULT_MIRRORS), isOverleaf: window.location.hostname === 'www.overleaf.com', lang: navigator.language.startsWith('zh') ? 'zh' : 'en' }; // ========================================================================= // 2. I18N // ========================================================================= const Dictionary = { zh: { title: "学术搜索助手", placeholder: "输入论文标题...", search: "搜索", settings: "设置", loading: "加载中...", no_result: "未找到相关文章", copy_success: "复制成功", copy_desc: "BibTeX 已复制到剪贴板", copy_fail: "复制失败", fail_desc: "无法获取 BibTeX,请检查网络或验证码", mirror_label: "Google 学术镜像地址", save: "保存", reset: "重置", toggle_on: "已开启显示 (刷新生效)", toggle_off: "已关闭显示 (刷新生效)", menu_toggle: "切换开启/关闭", verify_needed: "需要验证", verify_desc: "点击此处在新标签页完成验证", custom_url: "自定义网址 (如 https://site.com)", back: "返回" }, en: { title: "Scholar Helper", placeholder: "Search paper title...", search: "Search", settings: "Settings", loading: "Loading...", no_result: "No articles found", copy_success: "Copied", copy_desc: "BibTeX copied to clipboard", copy_fail: "Failed", fail_desc: "Cannot fetch BibTeX. Check network/captcha.", mirror_label: "Google Scholar Mirror URL", save: "Save", reset: "Reset", toggle_on: "Enabled (Reload to apply)", toggle_off: "Disabled (Reload to apply)", menu_toggle: "Toggle On/Off", verify_needed: "Verification Needed", verify_desc: "Click here to verify captcha in new tab", custom_url: "Custom URL (e.g. https://site.com)", back: "Back" } }; const t = (key) => Dictionary[State.lang][key] || key; // ========================================================================= // 3. Helper Functions (DOM & Icons) - CSP SAFE // ========================================================================= /** * Create DOM Element safely * @param {string} tag - Tag name * @param {object} attrs - Attributes (className, id, style, etc.) * @param {Array} children - Child elements or strings * @returns {HTMLElement} */ function el(tag, attrs = {}, children = []) { const element = document.createElement(tag); Object.entries(attrs).forEach(([key, val]) => { if (key === 'style' && typeof val === 'object') { Object.assign(element.style, val); } else if (key === 'className') { element.className = val; } else if (key.startsWith('on') && typeof val === 'function') { element.addEventListener(key.substring(2).toLowerCase(), val); } else { element.setAttribute(key, val); } }); children.forEach(child => { if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { element.appendChild(child); } }); return element; } /** * Create SVG Icon safely * @param {string} pathData - SVG Path data * @param {number} size - Size * @returns {SVGElement} */ function icon(pathData, size = 24) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", size); svg.setAttribute("height", size); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); // If pathData is complex (multiple paths), assumes passed as array, else string const paths = Array.isArray(pathData) ? pathData : [pathData]; paths.forEach(d => { const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", d); // Handling fill vs stroke based on common icon styles (simplified) if(d.includes("M12 3L1")) { // Scholar icon specific fix for fill path.setAttribute("fill", "currentColor"); path.setAttribute("stroke", "none"); } svg.appendChild(path); }); return svg; } const Icons = { scholar: icon("M12 3L1 9l11 6 9-4.91V17h2V9M5 13.18v4L12 21l7-3.82v-4L12 17l-7-3.82z"), search: icon(["M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35"], 20), settings: icon(["M12 15a3 3 0 100-6 3 3 0 000 6z", "M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 01 2-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"], 18), back: icon("M19 12H5M12 19l-7-7 7-7", 18) }; // ========================================================================= // 4. Styles // ========================================================================= const STYLES = ` .usb-container { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: #333; z-index: 2147483647; } .usb-float-btn { position: fixed; width: 48px; height: 48px; background: #4285f4; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 12px rgba(0,0,0,0.2); cursor: move; transition: transform 0.2s, background 0.2s; user-select: none; } .usb-float-btn:hover { background: #3367d6; transform: scale(1.05); } .usb-popup { position: absolute; width: 320px; background: white; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.15); border: 1px solid #e0e0e0; display: none; flex-direction: column; overflow: hidden; font-size: 14px; } .usb-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #f8f9fa; border-bottom: 1px solid #eee; } .usb-title { font-weight: 600; color: #4285f4; } .usb-icon-btn { cursor: pointer; padding: 4px; border-radius: 4px; color: #5f6368; display: flex; align-items: center; } .usb-icon-btn:hover { background: #e8eaed; color: #333; } .usb-content { padding: 12px; max-height: 400px; overflow-y: auto; } .usb-search-box { display: flex; align-items: center; margin-bottom: 10px; border: 1px solid #dfe1e5; border-radius: 20px; padding: 4px 12px; } .usb-search-input { flex: 1; border: none; outline: none; padding: 6px; font-size: 14px; background: transparent; } .usb-search-btn { cursor: pointer; color: #4285f4; padding: 4px; } .usb-result-item { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.1s; } .usb-result-item:hover { background: #f1f3f4; } .usb-result-item:last-child { border-bottom: none; } .usb-res-title { font-weight: 500; margin-bottom: 4px; color: #1a73e8; line-height: 1.4; } .usb-res-author { color: #5f6368; font-size: 12px; } .usb-settings-view { display: none; flex-direction: column; gap: 10px; } .usb-label { font-size: 12px; font-weight: 500; color: #5f6368; margin-bottom: 4px; } .usb-select, .usb-input { width: 100%; padding: 8px; border: 1px solid #dfe1e5; border-radius: 4px; margin-bottom: 8px; box-sizing: border-box; } .usb-btn-primary { background: #4285f4; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; width: 100%; } .usb-btn-primary:hover { background: #3367d6; } .usb-msg { text-align: center; color: #5f6368; padding: 20px; } .usb-error { color: #d93025; } .usb-hidden { display: none; } `; // ========================================================================= // 5. API Logic // ========================================================================= const API = { getUrl: (path) => `${State.currentMirror}${path}`, async search(query) { const url = API.getUrl(`/scholar?hl=${State.lang}&q=${encodeURIComponent(query)}`); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, onload: (res) => { if (res.status !== 200) return reject(new Error("Network Error")); const parser = new DOMParser(); const doc = parser.parseFromString(res.responseText, 'text/html'); if (doc.querySelector('#gs_captcha_c') || doc.title.includes("Captcha")) { return reject(new Error("CAPTCHA_REQUIRED")); } const items = doc.querySelectorAll('div[data-cid]'); const results = []; items.forEach((item, idx) => { if (idx > 5) return; const titleEl = item.querySelector("h3"); const authorEl = item.querySelector("div.gs_a"); if (titleEl && item.getAttribute('data-cid')) { results.push({ id: item.getAttribute('data-cid'), title: titleEl.innerText, author: authorEl ? authorEl.innerText : "Unknown" }); } }); resolve(results); }, onerror: (err) => reject(err) }); }); }, async getBibTex(cid) { const refUrl = API.getUrl(`/scholar?q=info:${cid}:scholar.google.com/&output=cite&scirp=1&hl=${State.lang}`); const refPage = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: refUrl, onload: (res) => resolve(res.responseText), onerror: reject }); }); const parser = new DOMParser(); const doc = parser.parseFromString(refPage, 'text/html'); const bibAnchor = doc.querySelector(".gs_citi"); if (!bibAnchor) throw new Error("BibTeX Link not found"); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: bibAnchor.href, onload: (res) => resolve(res.responseText), onerror: reject }); }); } }; // ========================================================================= // 6. UI Implementation (CSP Safe) // ========================================================================= class UI { constructor() { this.container = document.createElement('div'); this.container.className = 'usb-container'; document.body.appendChild(this.container); GM_addStyle(GM_getResourceText('notifycss')); GM_addStyle(STYLES); this.isShow = false; this.popup = null; this.elements = {}; // Cache UI elements } init() { if (State.isOverleaf) { this.injectOverleaf(); } if (!State.isOverleaf) { this.renderFloatingButton(); } this.createPopup(); } // --- Overleaf Specific --- injectOverleaf() { const observer = new MutationObserver((_, obs) => { const target = document.querySelector('div.ol-cm-toolbar-button-group.ol-cm-toolbar-end'); if (target && !document.getElementById('usb-ol-btn')) { const btn = el('div', { id: 'usb-ol-btn', className: 'ol-cm-toolbar-button', style: { display: 'flex', justifyContent: 'center', alignItems: 'center' }, title: t('title'), onclick: () => this.togglePopup(btn) }, [Icons.scholar.cloneNode(true)]); target.appendChild(btn); } }); observer.observe(document.body, { childList: true, subtree: true }); } // --- Floating Button --- renderFloatingButton() { const btn = el('div', { className: 'usb-float-btn', title: t('title') }, [Icons.scholar.cloneNode(true)]); let posX = GM_getValue(CONSTANTS.STORAGE_KEYS.POS_X, 20); let posY = GM_getValue(CONSTANTS.STORAGE_KEYS.POS_Y, window.innerHeight - 80); btn.style.left = `${posX}px`; btn.style.top = `${posY}px`; // Drag Logic let isDragging = false; let startX, startY, initialLeft, initialTop; const onMove = (mv) => { if (Math.abs(mv.clientX - startX) > 5 || Math.abs(mv.clientY - startY) > 5) isDragging = true; btn.style.left = `${initialLeft + mv.clientX - startX}px`; btn.style.top = `${initialTop + mv.clientY - startY}px`; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (isDragging) { GM_setValue(CONSTANTS.STORAGE_KEYS.POS_X, parseInt(btn.style.left)); GM_setValue(CONSTANTS.STORAGE_KEYS.POS_Y, parseInt(btn.style.top)); } }; btn.addEventListener('mousedown', (e) => { isDragging = false; startX = e.clientX; startY = e.clientY; initialLeft = btn.offsetLeft; initialTop = btn.offsetTop; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); btn.addEventListener('click', () => { if (!isDragging) this.togglePopup(btn); }); this.container.appendChild(btn); } // --- CSP Safe Popup Creation --- createPopup() { // Header Elements const settingsBtn = el('div', { className: 'usb-icon-btn', title: t('settings') }, [Icons.settings.cloneNode(true)]); const header = el('div', { className: 'usb-header' }, [ el('span', { className: 'usb-title' }, [t('title')]), el('div', { style: { display: 'flex', gap: '8px' } }, [settingsBtn]) ]); // Search View Elements const searchInput = el('input', { className: 'usb-search-input', placeholder: t('placeholder') }); const searchBtn = el('div', { className: 'usb-search-btn' }, [Icons.search.cloneNode(true)]); const searchBox = el('div', { className: 'usb-search-box' }, [searchInput, searchBtn]); const resultsBox = el('div', { className: 'usb-results' }); const searchView = el('div', { id: 'usb-view-search' }, [searchBox, resultsBox]); // Settings View Elements const backBtn = el('div', { style: { marginBottom: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer', color: '#4285f4' } }, [Icons.back.cloneNode(true), el('span', { style: { marginLeft: '4px' } }, [t('back')])]); const mirrorSelect = el('select', { className: 'usb-select' }); const mirrorCustom = el('input', { className: 'usb-input', placeholder: t('custom_url') }); const saveBtn = el('button', { className: 'usb-btn-primary' }, [t('save')]); const settingsView = el('div', { className: 'usb-settings-view' }, [ backBtn, el('div', { className: 'usb-label' }, [t('mirror_label')]), mirrorSelect, mirrorCustom, saveBtn ]); // Assemble Popup const content = el('div', { className: 'usb-content' }, [searchView, settingsView]); this.popup = el('div', { className: 'usb-popup' }, [header, content]); this.container.appendChild(this.popup); // Store References this.elements = { searchInput, searchBtn, resultsBox, searchView, settingsView, mirrorSelect, mirrorCustom, saveBtn, settingsBtn, backBtn }; this.bindEvents(); } bindEvents() { const { searchInput, searchBtn, settingsBtn, backBtn, saveBtn, searchView, settingsView, mirrorSelect, mirrorCustom } = this.elements; const doSearch = () => this.handleSearch(searchInput.value); searchBtn.onclick = doSearch; searchInput.addEventListener('keydown', (e) => { if(e.key === 'Enter') doSearch(); }); settingsBtn.onclick = () => { searchView.style.display = 'none'; settingsView.style.display = 'flex'; this.populateSettings(); }; backBtn.onclick = () => { settingsView.style.display = 'none'; searchView.style.display = 'block'; }; mirrorSelect.onchange = () => { mirrorCustom.value = mirrorSelect.value; }; saveBtn.onclick = () => { let newVal = mirrorCustom.value.trim() || mirrorSelect.value; if (newVal.endsWith('/')) newVal = newVal.slice(0, -1); if (!newVal.startsWith('http')) { new Notify({ status: 'warning', text: 'Invalid URL', type: 'filled' }); return; } State.currentMirror = newVal; GM_setValue(CONSTANTS.STORAGE_KEYS.MIRROR, newVal); if (!State.customMirrors.includes(newVal)) { State.customMirrors.push(newVal); GM_setValue(CONSTANTS.STORAGE_KEYS.MIRROR_LIST, State.customMirrors); } new Notify({ status: 'success', text: t('save'), type: 'filled' }); backBtn.click(); }; } populateSettings() { const { mirrorSelect, mirrorCustom } = this.elements; mirrorSelect.innerHTML = ''; // Clean (standard standard property for select options) State.customMirrors.forEach(m => { const opt = el('option', { value: m }, [m]); if (m === State.currentMirror) opt.selected = true; mirrorSelect.appendChild(opt); }); mirrorCustom.value = State.currentMirror; } async handleSearch(query) { if (!query) return; const { resultsBox } = this.elements; // Clear results using DOM method while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); } resultsBox.appendChild(el('div', { className: 'usb-msg' }, [t('loading')])); try { const results = await API.search(query); while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); } if (results.length === 0) { resultsBox.appendChild(el('div', { className: 'usb-msg' }, [t('no_result')])); return; } results.forEach(item => { const itemEl = el('div', { className: 'usb-result-item' }, [ el('div', { className: 'usb-res-title' }, [item.title]), el('div', { className: 'usb-res-author' }, [item.author]) ]); itemEl.onclick = () => this.handleFetchBib(item.id, itemEl); resultsBox.appendChild(itemEl); }); } catch (err) { while (resultsBox.firstChild) { resultsBox.removeChild(resultsBox.firstChild); } if (err.message === "CAPTCHA_REQUIRED") { const link = el('div', { style: { fontSize: '12px', marginTop: '5px', cursor: 'pointer', textDecoration: 'underline' }, onclick: () => GM_openInTab(API.getUrl('/'), { active: true }) }, [t('verify_desc')]); const msg = el('div', { className: 'usb-msg usb-error' }, [ el('div', {}, [t('verify_needed')]), link ]); resultsBox.appendChild(msg); } else { resultsBox.appendChild(el('div', { className: 'usb-msg usb-error' }, [`Error: ${err.message}`])); } } } async handleFetchBib(id, element) { const originalBg = element.style.background; element.style.background = "#e8f0fe"; element.style.opacity = "0.7"; try { const bib = await API.getBibTex(id); GM_setClipboard(bib); new Notify({ status: 'success', title: t('copy_success'), text: t('copy_desc'), effect: 'slide', type: 'filled', autoclose: true }); } catch (err) { new Notify({ status: 'error', title: t('copy_fail'), text: t('fail_desc'), effect: 'slide', type: 'filled' }); } finally { element.style.background = originalBg; element.style.opacity = "1"; } } togglePopup(referenceEl) { if (this.isShow) { this.popup.style.display = 'none'; this.isShow = false; } else { this.popup.style.display = 'flex'; this.isShow = true; this.updatePopupPosition(referenceEl); setTimeout(() => this.elements.searchInput.focus(), 100); } } updatePopupPosition(referenceEl) { if (!referenceEl || !this.popup) return; FloatingUIDOM.computePosition(referenceEl, this.popup, { placement: 'bottom-start', middleware: [FloatingUICore.offset(8), FloatingUICore.flip(), FloatingUICore.shift({ padding: 10 })], }).then(({ x, y }) => { Object.assign(this.popup.style, { left: `${x}px`, top: `${y}px` }); }); } } // ========================================================================= // 7. Init // ========================================================================= GM_registerMenuCommand(t('menu_toggle'), () => { const newState = !State.isEnabled; GM_setValue(CONSTANTS.STORAGE_KEYS.ENABLED, newState); new Notify({ status: newState ? 'success' : 'warning', title: t('settings'), text: newState ? t('toggle_on') : t('toggle_off'), type: 'filled' }); }); if (State.isEnabled) { const ui = new UI(); ui.init(); } })();