DOI和BibTeX和PDF下载插件

添加按钮来复制DOI、获取文献的BibTeX引用格式并下载PDF

  1. // ==UserScript==
  2. // @name DOI & BibTeX & PDF Plugin
  3. // @name:zh-CN DOI和BibTeX和PDF下载插件
  4. // @description Adds buttons to copy DOI, fetch BibTeX citation, and download PDF from literature pages
  5. // @description:zh-CN 添加按钮来复制DOI、获取文献的BibTeX引用格式并下载PDF
  6. // @version 0.2 // Incremented version to reflect styling changes
  7. // @author Yul
  8. // @license MIT License
  9. // @grant GM_setClipboard
  10. // @grant GM_addStyle
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_download
  13. // @run-at document-end
  14. // @match *://www.astm.org/*
  15. // @match *://www.scirp.org/journal/*
  16. // @match *://direct.mit.edu/neco/*
  17. // @match *://ieeexplore.ieee.org/*document/*
  18. // @match *://ascelibrary.org/doi/*
  19. // @match *://nhess.copernicus.org/articles/*
  20. // @match *://www.cambridge.org/core/journals/*
  21. // @match *://www.mdpi.com/*
  22. // @match *://en.cgsjournals.com/article/doi/*
  23. // @match *://adgeo.copernicus.org/articles/*
  24. // @match *://papers.ssrn.com/*
  25. // @match *://www.sciencedirect.com/science/article/*
  26. // @match *://onlinelibrary.wiley.com/doi/*
  27. // @match *://*.onlinelibrary.wiley.com/doi/*
  28. // @match *://pubs.acs.org/doi/*
  29. // @match *://www.tandfonline.com/doi/*
  30. // @match *://www.beilstein-journals.org/*
  31. // @match *://www.eurekaselect.com/*/article*
  32. // @match *://*.springeropen.com/article*
  33. // @match *://aip.scitation.org/doi/*
  34. // @match *://www.nature.com/articles*
  35. // @match *://*.sciencemag.org/content*
  36. // @match *://journals.aps.org/*/abstract/10*
  37. // @match *://www.nrcresearchpress.com/doi/10*
  38. // @match *://iopscience.iop.org/article/10*
  39. // @match *://www.cell.com/*/fulltext/*
  40. // @match *://journals.lww.com/*
  41. // @match *://*.biomedcentral.com/articles/*
  42. // @match *://journals.sagepub.com/doi/*
  43. // @match *://academic.oup.com/*/article/*
  44. // @match *://www.karger.com/Article/*
  45. // @match *://www.cambridge.org/core/journals/*/article/*
  46. // @match *://www.annualreviews.org/doi/*
  47. // @match *://www.jstage.jst.go.jp/article/*
  48. // @match *://www.hindawi.com/journals/*
  49. // @match *://www.cardiology.theclinics.com/article/*
  50. // @match *://www.liebertpub.com/doi/*
  51. // @match *://thorax.bmj.com/content/*
  52. // @match *://journals.physiology.org/doi/*
  53. // @match *://www.ahajournals.org/doi/*
  54. // @match *://dl.acm.org/doi/*
  55. // @match *://*.asm.org/content/*
  56. // @match *://content.apa.org/*
  57. // @match *://www.thelancet.com/journals/*/article/*
  58. // @match *://jamanetwork.com/journals/*
  59. // @match *://*.aacrjournals.org/content/*
  60. // @match *://royalsocietypublishing.org/doi/*
  61. // @match *://journals.plos.org/*/article*
  62. // @match *://*.psychiatryonline.org/doi/*
  63. // @match *://www.osapublishing.org/*/abstract.cfm*
  64. // @match *://www.thieme-connect.de/products/ejournals/*
  65. // @match *://journals.ametsoc.org/*/article/*
  66. // @match *://www.frontiersin.org/articles/*
  67. // @match *://www.worldscientific.com/doi/*
  68. // @match *://www.nejm.org/doi/*
  69. // @match *://ascopubs.org/doi/*
  70. // @match *://www.jto.org/article/*
  71. // @match *://www.jci.org/articles/*
  72. // @match *://pubmed.ncbi.nlm.nih.gov/*
  73. // @match *://www.spiedigitallibrary.org/conference-*
  74. // @match *://www.ingentaconnect.com/content/*
  75. // @match *://www.taylorfrancis.com/*
  76. // @match *://www.science.org/doi/*
  77. // @match *://www.scinapse.io/papers/*
  78. // @match *://www.semanticscholar.org/paper/*
  79. // @match *://www.researchgate.net/publication/*
  80. // @match *://www.earthdoc.org/content/papers/*
  81. // @match *://era.library.ualberta.ca/items*
  82. // @match *://arxiv.org/abs/*
  83. // @match *://asmedigitalcollection.asme.org/IPC*
  84. // @match *://open.library.ubc.ca/soa/cIRcle/collections/*
  85. // @match *://pubs.geoscienceworld.org/aeg/eeg/article/*
  86. // @match *://othes.univie.ac.at/*
  87. // @match *://www.atlantis-press.com/journals/*
  88. // @match *://www.koreascience.or.kr/article/*
  89. // @match *://www.geenmedical.com/article*
  90. // @match *://www.ncbi.nlm.nih.gov/pmc/articles/*
  91. // @match *://qjegh.lyellcollection.org/content/*
  92. // @match *://cdnsciencepub.com/doi/*
  93. // @match *://ojs.aaai.org//index.php/AAAI/article/*
  94. // @match *://www.ijcai.org/proceedings/*
  95. // @match *://www.scopus.com/record/display.uri*
  96. // @match *://avs.scitation.org/doi/*
  97. // @match *://pubs.rsc.org/*/content/*
  98. // @match *://*.copernicus.org/articles/*
  99. // @match *://europepmc.org/article/*
  100. // @match *://www.futuremedicine.com/doi/*
  101. // @include /^http[s]?:\/\/[\S\s]*webofscience[\S\s]+$/
  102. // @include /^http[s]?:\/\/[\S\s]*springer[\S\s]*/(article|chapter)/
  103. // @include /^http[s]?:\/\/[\S\s]*onepetro.org/[\S\s]+/(article|proceedings)/
  104. // @namespace https://greasyfork.org/users/1479737
  105. // ==/UserScript==
  106. (function() {
  107. 'use strict';
  108.  
  109. // Configuration Constants
  110. const CONFIG = {
  111. MAX_RETRY: 15,
  112. RETRY_INTERVAL: 300,
  113. FEEDBACK_DURATION: 2000,
  114. BIBTEX_TIMEOUT: 10000,
  115. PDF_TIMEOUT: 15000, // For PDF access checks and other PDF operations
  116. };
  117.  
  118. // BibTeX API Services
  119. const BIBTEX_APIS = [
  120. {
  121. name: 'CrossRef',
  122. url: (doi) => `https://api.crossref.org/works/${doi}/transform/application/x-bibtex`,
  123. headers: {'Accept': 'application/x-bibtex'}
  124. },
  125. {
  126. name: 'DOI.org',
  127. url: (doi) => `https://doi.org/${doi}`,
  128. headers: {'Accept': 'application/x-bibtex'}
  129. },
  130. {
  131. name: 'Crosscite',
  132. url: (doi) => `https://citation.crosscite.org/format?doi=${doi}&style=bibtex&lang=en-US`,
  133. headers: {'Accept': 'text/plain'}
  134. }
  135. ];
  136.  
  137. // PDF Download Link Selectors (prioritized)
  138. const PDF_SELECTORS = {
  139. 'generic': [
  140. 'a[href$=".pdf"]', 'a[href*="/pdf/"]', 'a[href*="pdf"]',
  141. 'a[title*="PDF" i]', 'a[title*="Download" i]',
  142. '.pdf-download a', '.download-pdf a', '.full-text-pdf a'
  143. ],
  144. 'specific': {
  145. 'www.nature.com': ['a[data-track-action="download pdf"]', 'a[href*="/pdf/"]', '.c-pdf-download__link'],
  146. 'ieeexplore.ieee.org': ['a[href*="stamp.jsp"]', '.pdf-btn a', 'a[href*="arnumber"][href*="pdf"]'],
  147. 'www.sciencedirect.com': ['a[pdfurl]', '.PdfDownloadButton', 'a[href*="pdfft"]', 'a[data-testid="pdf-link"]'],
  148. 'onlinelibrary.wiley.com': ['a[href*="pdf"]', '.doi-access a', '.pdf-download-btn', 'a[title*="PDF" i]'],
  149. 'link.springer.com': ['a[href*="pdf"]', '.pdf-link', '.c-pdf-download__link', 'a[data-track="click_pdf"]'],
  150. 'journals.plos.org': ['a[id*="downloadPdf"]', '.download a[href*="pdf"]', 'a[href*="article/file"]'],
  151. 'www.ncbi.nlm.nih.gov': ['a[href*="/pmc/articles/"][href*="/pdf/"]', '.pdf-link', 'a[title*="PDF" i]'],
  152. 'pubmed.ncbi.nlm.nih.gov': ['.full-text-links a[href*="pdf"]', 'a[data-ga-action="full_text"]'],
  153. 'journals.aps.org': ['a[href*="/pdf/"]', '.article-nav a[href*="pdf"]'],
  154. 'iopscience.iop.org': ['a[href*="/pdf/"]', '.pdf-download a'],
  155. 'www.tandfonline.com': ['a[href*="pdf"]', '.show-pdf a'],
  156. 'pubs.acs.org': ['a[href*="pdf"]', '.article-pdfLink'],
  157. 'academic.oup.com': ['a[href*="pdf"]', '.al-link-pdf'],
  158. 'www.mdpi.com': ['a[href*="pdf"]', '.download-pdf a'],
  159. 'www.frontiersin.org': ['a[href*="pdf"]', '.download-files a[href*="pdf"]']
  160. }
  161. };
  162.  
  163. // Site-Specific PDF URL Smart Extractors
  164. const SITE_SPECIFIC_PDF_EXTRACTORS = {
  165. 'arxiv.org': () => window.location.pathname.match(/\/abs\/(.+)/) ? `https://arxiv.org/pdf/${window.location.pathname.match(/\/abs\/(.+)/)[1]}.pdf` : null,
  166. 'www.nature.com': () => {
  167. const pdfLink = document.querySelector('a[data-track-action="download pdf"]');
  168. if (pdfLink) return pdfLink.href;
  169. const articleMatch = window.location.pathname.match(/\/articles\/([^\/]+)/);
  170. return articleMatch ? `https://www.nature.com/articles/${articleMatch[1]}.pdf` : null;
  171. },
  172. 'www.sciencedirect.com': () => {
  173. const pdfBtn = document.querySelector('a[pdfurl]');
  174. if (pdfBtn) return pdfBtn.getAttribute('pdfurl');
  175. const scripts = document.querySelectorAll('script[type="application/json"]');
  176. for (const script of scripts) {
  177. try { if (JSON.parse(script.textContent)?.article?.pdfUrl) return JSON.parse(script.textContent).article.pdfUrl; } catch (e) { continue; }
  178. }
  179. return null;
  180. },
  181. 'ieeexplore.ieee.org': () => {
  182. const stampLink = document.querySelector('a[href*="stamp.jsp"]');
  183. if (stampLink) return stampLink.href;
  184. const arnumberMatch = window.location.search.match(/arnumber=(\d+)/);
  185. return arnumberMatch ? `https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=${arnumberMatch[1]}` : null;
  186. },
  187. 'onlinelibrary.wiley.com': () => {
  188. const pdfLink = document.querySelector('a[href*="pdf"][title*="PDF"]'); // More specific
  189. if (pdfLink) return pdfLink.href;
  190. const doiMatch = window.location.pathname.match(/\/doi\/(10\..+)/);
  191. return doiMatch ? `https://onlinelibrary.wiley.com/doi/pdf/${doiMatch[1]}` : null;
  192. },
  193. 'www.ncbi.nlm.nih.gov': () => {
  194. if (window.location.pathname.includes('/pmc/articles/')) {
  195. const pmcMatch = window.location.pathname.match(/(PMC\d+)/);
  196. return pmcMatch ? `https://www.ncbi.nlm.nih.gov/pmc/articles/${pmcMatch[1]}/pdf/` : null;
  197. }
  198. return null;
  199. },
  200. 'journals.plos.org': () => {
  201. const pdfLink = document.querySelector('a[id*="downloadPdf"]');
  202. if (pdfLink) return pdfLink.href;
  203. const doiMatch = window.location.search.match(/id=(10\..+)/); // Assuming ID is DOI
  204. return doiMatch ? `https://journals.plos.org/plosone/article/file?id=${doiMatch[1]}&type=printable` : null;
  205. },
  206. 'link.springer.com': () => {
  207. const pdfLink = document.querySelector('a.c-pdf-download__link, a[data-track="click_pdf"][href*="pdf"]');
  208. if (pdfLink) return pdfLink.href;
  209. const doiMatch = window.location.pathname.match(/\/(10\.[^/]+\/[^/]+)/);
  210. return doiMatch ? `https://link.springer.com/content/pdf/${doiMatch[1]}.pdf` : null;
  211. }
  212. };
  213.  
  214. // State Management
  215. let state = {
  216. timer: null,
  217. retryCount: 0,
  218. currentDOI: null,
  219. currentPDFUrl: null,
  220. widget: null,
  221. bibtexCache: new Map(),
  222. pdfCache: new Map()
  223. };
  224.  
  225. // DOI Extraction Rules
  226. const DOI_SELECTORS = [
  227. 'meta[name="citation_doi"]', 'meta[name="dc.identifier"][scheme="DOI"]',
  228. 'meta[name="dc.Identifier"][scheme="DOI"]', 'meta[name="DC.identifier"][scheme="DOI"]',
  229. 'meta[name="dc.identifier"]', 'meta[name="dc.Identifier"]', 'meta[name="DC.identifier"]',
  230. 'meta[property="og:url"]'
  231. ];
  232. const SITE_SPECIFIC_EXTRACTORS = { // For DOI
  233. 'ieeexplore.ieee.org': () => document.querySelector('div.stats-document-abstract-doi a')?.textContent,
  234. 'www.sciencedirect.com': () => {
  235. const script = document.querySelector('script[type="application/json"][data-iso-key="_0"]');
  236. if (script?.textContent) { try { return JSON.parse(script.textContent)?.article?.doi; } catch (e) { console.warn('Failed to parse ScienceDirect JSON for DOI:', e); } }
  237. },
  238. 'www.researchgate.net': () => document.querySelector("div.research-detail-meta-item a[href*='doi.org/10.'], div.js-publication-details a[href*='doi.org/10.']")?.href,
  239. 'www.webofscience.com': () => document.querySelector('#FullRTa-DOI, app-full-record-summary-item[data-test-id="summary-DOI"] .value')?.textContent,
  240. 'pubmed.ncbi.nlm.nih.gov': () => document.querySelector('span.citation-doi a, a.id-link[data-ga-action="DOI"]')?.textContent,
  241. 'www.ncbi.nlm.nih.gov': () => window.location.pathname.includes('/pmc/articles/') ? document.querySelector('td.doi a, span.doi a')?.textContent : null,
  242. 'arxiv.org': () => document.querySelector('td.tablecell.doi a, div.submission-history dt:contains("DOI:") + dd, div.extra-services div.full-text ul li a[href*="doi.org/10."]')?.textContent || document.querySelector('div.extra-services div.full-text ul li a[href*="doi.org/10."]')?.href,
  243. 'www.semanticscholar.org': () => document.querySelector('span[data-test-id="paper-doi"] a')?.href || document.querySelector('[data-test-id="paper-meta-item"] a[href*="doi.org"]')?.textContent
  244. };
  245.  
  246. // Utility Functions
  247. const utils = {
  248. normalizeHostname: (hostname) => {
  249. if (hostname.includes('webofscience')) return 'www.webofscience.com';
  250. if (hostname.includes('springer.com') || hostname.includes('springerlink.com')) return 'link.springer.com';
  251. if (hostname.includes('onlinelibrary.wiley.com')) return 'onlinelibrary.wiley.com';
  252. return hostname;
  253. },
  254. normalizeDOI: (doi) => {
  255. if (!doi) return null;
  256. let normalized = decodeURIComponent(doi.toString().trim()).replace(/^doi:\s*/i, '').replace(/^https?:\/\/doi\.org\//i, '');
  257. const strictMatch = normalized.match(/10\.\d{4,9}\/[-._;()/:A-Z0-9]+$/i);
  258. if (strictMatch) return strictMatch[0];
  259. const looseMatch = normalized.match(/10\.[^\s]+/);
  260. return looseMatch && normalized.startsWith("10.") ? looseMatch[0] : null;
  261. },
  262. debounce: (func, delay) => {
  263. let timeoutId;
  264. return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(null, args), delay); };
  265. },
  266. cleanBibTeX: (bibtex) => {
  267. if (!bibtex) return null;
  268. return bibtex.replace(/^\s+|\s+$/g, '').replace(/\n\s*\n/g, '\n').replace(/\s+/g, ' ').replace(/,\s*}/g, '\n}').replace(/,\s*([a-zA-Z])/g, ',\n $1');
  269. },
  270. generatePDFFilename: (doi, title) => {
  271. let filename = '';
  272. if (title) { filename = title.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 50); }
  273. else if (doi) { filename = doi.replace(/[/\\:*?"<>|]/g, '_'); }
  274. else { filename = `paper_${Date.now()}`; }
  275. return `${filename}.pdf`;
  276. },
  277. isValidPDFUrl: (url) => {
  278. if (!url) return false;
  279. try { new URL(url, window.location.href); } catch { return false; }
  280. const excludePatterns = [/\.(jpg|jpeg|png|gif|svg|css|js|html)$/i, /javascript:/i, /mailto:/i, /#$/];
  281. return !excludePatterns.some(pattern => pattern.test(url));
  282. },
  283. checkPDFAccess: async (url) => {
  284. return new Promise((resolve) => {
  285. GM_xmlhttpRequest({
  286. method: 'HEAD', url: url, timeout: CONFIG.PDF_TIMEOUT / 3, // Shorter timeout for HEAD
  287. onload: (response) => {
  288. const isAccessible = response.status === 200 || response.status === 302;
  289. const contentType = response.responseHeaders?.toLowerCase() || "";
  290. const isPDF = contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf');
  291. resolve(isAccessible && isPDF);
  292. },
  293. onerror: () => resolve(false), ontimeout: () => resolve(false)
  294. });
  295. });
  296. }
  297. };
  298.  
  299. // DOI Extractor
  300. const extractDOI = () => {
  301. const hostname = utils.normalizeHostname(window.location.hostname);
  302. const siteExtractor = SITE_SPECIFIC_EXTRACTORS[hostname];
  303. if (siteExtractor) { const doi = siteExtractor(); if (doi) return utils.normalizeDOI(doi); }
  304. for (const selector of DOI_SELECTORS) {
  305. const element = document.querySelector(selector);
  306. if (element?.content) { const doi = utils.normalizeDOI(element.content); if (doi) return doi; }
  307. }
  308. const doiLink = document.querySelector('a[href*="doi.org/10."]');
  309. if (doiLink?.href) return utils.normalizeDOI(doiLink.href);
  310. return null;
  311. };
  312.  
  313. // BibTeX Fetching
  314. const fetchBibTeX = async (doi) => {
  315. if (state.bibtexCache.has(doi)) return state.bibtexCache.get(doi);
  316. for (const api of BIBTEX_APIS) {
  317. try {
  318. const result = await new Promise((resolve, reject) => {
  319. const timeout = setTimeout(() => reject(new Error('Timeout')), CONFIG.BIBTEX_TIMEOUT);
  320. GM_xmlhttpRequest({
  321. method: 'GET', url: api.url(doi), headers: api.headers, timeout: CONFIG.BIBTEX_TIMEOUT,
  322. onload: (response) => {
  323. clearTimeout(timeout);
  324. if (response.status === 200 && response.responseText) {
  325. const cleaned = utils.cleanBibTeX(response.responseText);
  326. if (cleaned && cleaned.includes('@')) resolve(cleaned); else reject(new Error('Invalid BibTeX format'));
  327. } else reject(new Error(`HTTP ${response.status}`));
  328. },
  329. onerror: (error) => { clearTimeout(timeout); reject(error); },
  330. ontimeout: () => { clearTimeout(timeout); reject(new Error('Request timeout')); }
  331. });
  332. });
  333. state.bibtexCache.set(doi, result); console.log(`BibTeX fetched successfully from ${api.name}`); return result;
  334. } catch (error) { console.warn(`${api.name} failed:`, error.message); continue; }
  335. }
  336. throw new Error('All BibTeX APIs failed');
  337. };
  338.  
  339. // --- PDF Specific Functions ---
  340. const findBestPDFUrl = async () => {
  341. const hostname = utils.normalizeHostname(window.location.hostname);
  342. const candidates = [];
  343.  
  344. const sitePdfExtractor = SITE_SPECIFIC_PDF_EXTRACTORS[hostname];
  345. if (sitePdfExtractor) { const url = sitePdfExtractor(); if (url && utils.isValidPDFUrl(url)) candidates.push({ url, priority: 10, source: 'site-specific-extractor' });}
  346.  
  347. const siteSelectors = PDF_SELECTORS.specific[hostname];
  348. if (siteSelectors) {
  349. for (let i = 0; i < siteSelectors.length; i++) {
  350. try {
  351. document.querySelectorAll(siteSelectors[i]).forEach(el => {
  352. const url = el.href || el.getAttribute('pdfurl') || el.getAttribute('data-pdf-url');
  353. if (url && utils.isValidPDFUrl(url)) candidates.push({ url, priority: 9 - i, source: `site-selector: ${siteSelectors[i]}`});
  354. });
  355. } catch (e) { console.warn(`PDF selector failed: ${siteSelectors[i]}`, e); }
  356. }
  357. }
  358.  
  359. PDF_SELECTORS.generic.forEach((selector, index) => {
  360. try {
  361. document.querySelectorAll(selector).forEach(el => {
  362. const url = el.href || el.getAttribute('pdfurl');
  363. if (url && utils.isValidPDFUrl(url)) {
  364. let priority = 6 - index;
  365. if (url.toLowerCase().includes('pdf') || url.endsWith('.pdf')) priority += 2;
  366. const text = el.textContent?.toLowerCase() || '';
  367. if (text.includes('pdf') || text.includes('download')) priority += 1;
  368. candidates.push({ url, priority, source: `generic: ${selector}`});
  369. }
  370. });
  371. } catch (e) { console.warn(`Generic PDF selector failed: ${selector}`, e); }
  372. });
  373.  
  374. if (state.currentDOI) {
  375. generateDOIBasedPDFUrls(state.currentDOI).forEach(url => candidates.push({ url, priority: 3, source: 'doi-based' }));
  376. }
  377.  
  378. candidates.sort((a, b) => b.priority - a.priority);
  379. const uniqueCandidates = Array.from(new Map(candidates.map(c => [c.url, c])).values());
  380. console.log('PDF candidates found:', uniqueCandidates.map(c => `${c.url} (P:${c.priority}, S:${c.source})`));
  381.  
  382. for (const candidate of uniqueCandidates.slice(0, 5)) { // Check top 5
  383. if (await utils.checkPDFAccess(candidate.url)) { console.log(`Best PDF URL selected (accessible): ${candidate.url} (${candidate.source})`); return candidate.url; }
  384. }
  385. return uniqueCandidates.length > 0 ? uniqueCandidates[0].url : null; // Fallback to highest priority if none are confirmed accessible
  386. };
  387.  
  388. const generateDOIBasedPDFUrls = (doi) => { // Simplified
  389. if (!doi) return [];
  390. const doiParts = doi.split('/');
  391. const suffix = doiParts.length > 1 ? doiParts[1] : doi;
  392. return [
  393. `https://www.nature.com/articles/${suffix}.pdf`,
  394. `https://link.springer.com/content/pdf/${doi}.pdf`,
  395. `https://onlinelibrary.wiley.com/doi/pdf/${doi}`
  396. // Add more patterns if known and reliable
  397. ].filter(url => utils.isValidPDFUrl(url));
  398. };
  399.  
  400. const deepScanForPDF = () => {
  401. const potentialLinks = [];
  402. document.querySelectorAll('a[href]').forEach(link => {
  403. const href = link.href.toLowerCase(); const text = link.textContent.toLowerCase();
  404. const pdfKeywords = ['pdf', 'download', 'full text', 'full-text', 'view pdf', 'get pdf'];
  405. if (pdfKeywords.some(k => href.includes(k) || text.includes(k)) && utils.isValidPDFUrl(link.href)) {
  406. let score = 1;
  407. if (href.endsWith('.pdf')) score += 5; if (href.includes('/pdf/')) score += 3;
  408. if (text.includes('pdf')) score += 2; if (text.includes('download')) score += 2;
  409. if (link.download) score += 3;
  410. potentialLinks.push({ url: link.href, score });
  411. }
  412. });
  413. potentialLinks.sort((a, b) => b.score - a.score);
  414. return potentialLinks.length > 0 ? potentialLinks[0].url : null;
  415. };
  416.  
  417. const heuristicPDFSearch = async () => {
  418. const embeds = document.querySelectorAll('embed[src*="pdf"], object[data*="pdf"], iframe[src*="pdf"]');
  419. for (const embed of embeds) { const src = embed.src || embed.data; if (src && utils.isValidPDFUrl(src)) return src; }
  420. const scripts = document.querySelectorAll('script:not([src])');
  421. for (const script of scripts) {
  422. const text = script.textContent; const pdfMatches = text.match(/["'](https?:\/\/[^"']*\.pdf[^"']*?)["']/gi);
  423. if (pdfMatches) { for (const match of pdfMatches) { const url = match.slice(1, -1); if (utils.isValidPDFUrl(url)) return url; } }
  424. }
  425. const dataElements = document.querySelectorAll('[data-pdf-url], [data-download-url], [data-file-url]');
  426. for (const el of dataElements) { const url = el.dataset.pdfUrl || el.dataset.downloadUrl || el.dataset.fileUrl; if (url && utils.isValidPDFUrl(url)) return url; }
  427. return null;
  428. };
  429.  
  430. const downloadPDF = async (pdfUrl, button) => {
  431. if (!pdfUrl) { showCopyFeedback(button, '无PDF链接', true); return; }
  432. try {
  433. const title = document.querySelector('meta[name="citation_title"]')?.content || document.querySelector('h1')?.textContent || document.title;
  434. const filename = utils.generatePDFFilename(state.currentDOI, title);
  435. if (typeof GM_download !== 'undefined') {
  436. GM_download({ url: pdfUrl, name: filename, saveAs: true }); // saveAs: true to prompt user
  437. showCopyFeedback(button, '下载中...', false); console.log(`Downloading PDF: ${pdfUrl} as ${filename}`); return;
  438. }
  439. const link = document.createElement('a'); link.href = pdfUrl; link.download = filename; link.target = '_blank';
  440. document.body.appendChild(link); link.click(); document.body.removeChild(link);
  441. showCopyFeedback(button, '已启动下载', false); console.log(`PDF download initiated: ${pdfUrl}`);
  442. } catch (error) { console.error('PDF download failed:', error); showCopyFeedback(button, '下载失败', true); }
  443. };
  444.  
  445. const enhancedDownloadPDF = async (button) => {
  446. const originalText = button.textContent;
  447. button.textContent = 'PDF'; button.disabled = true;
  448. try {
  449. let pdfUrl = state.currentPDFUrl || state.pdfCache.get(window.location.href);
  450. if (!pdfUrl) pdfUrl = await findBestPDFUrl();
  451. if (!pdfUrl) pdfUrl = deepScanForPDF();
  452. if (!pdfUrl) pdfUrl = await heuristicPDFSearch();
  453.  
  454. if (pdfUrl) {
  455. state.currentPDFUrl = pdfUrl; state.pdfCache.set(window.location.href, pdfUrl);
  456. await downloadPDF(pdfUrl, button); // This will call showCopyFeedback
  457. } else {
  458. showCopyFeedback(button, '未找到PDF', true); // This restores button
  459. }
  460. } catch (error) {
  461. console.error('Enhanced PDF download failed:', error);
  462. showCopyFeedback(button, 'PDF错误', true); // This restores button
  463. }
  464. // If showCopyFeedback was called, button state is handled.
  465. // If an error occurred before showCopyFeedback, or if it didn't restore for some reason:
  466. if (button.textContent === 'PDF') { // Check if it's still in intermediate state
  467. button.textContent = originalText;
  468. button.disabled = false;
  469. }
  470. };
  471. // --- End PDF Specific Functions ---
  472.  
  473. // Clipboard Operations
  474. const copyToClipboard = async (text, button, successMsg = 'Copied') => {
  475. try {
  476. if (typeof GM_setClipboard !== 'undefined') { GM_setClipboard(text); showCopyFeedback(button, successMsg); return; }
  477. if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); showCopyFeedback(button, successMsg); return; }
  478. throw new Error('No clipboard API available');
  479. } catch (error) { console.error('Failed to copy:', error); showCopyFeedback(button, '复制失败', true); }
  480. };
  481.  
  482. // Feedback Display
  483. const showCopyFeedback = (button, message, isError = false) => {
  484. if (!button) return;
  485. const originalText = button.dataset.originalText || button.textContent; // Store original text if not already
  486. if (!button.dataset.originalText) button.dataset.originalText = button.textContent;
  487.  
  488. button.textContent = message;
  489. const baseClass = button.className.replace(/ copied-feedback| error-feedback/g, '');
  490. button.className = baseClass + (isError ? ' error-feedback' : ' copied-feedback');
  491. button.disabled = true;
  492.  
  493. setTimeout(() => {
  494. button.textContent = originalText;
  495. button.className = baseClass;
  496. button.disabled = false;
  497. delete button.dataset.originalText; // Clean up
  498. }, CONFIG.FEEDBACK_DURATION);
  499. };
  500.  
  501. // BibTeX Button Click Handler
  502. const handleBibTeXClick = async (button) => {
  503. if (!state.currentDOI) { showCopyFeedback(button, '无DOI', true); return; }
  504.  
  505. const originalText = button.textContent;
  506. button.textContent = 'BibTeX';
  507. button.disabled = true;
  508.  
  509. try {
  510. const bibtex = await fetchBibTeX(state.currentDOI);
  511. await copyToClipboard(bibtex, button, 'BibTeX已复制'); // copyToClipboard calls showCopyFeedback
  512. } catch (error) {
  513. console.error('Failed to fetch BibTeX:', error);
  514. showCopyFeedback(button, '获取失败', true); // This will restore button
  515. }
  516. // If button text is still "获取BibTeX...", restore it (e.g. if copyToClipboard failed silently before its own showCopyFeedback)
  517. if (button.textContent === 'BibTeX') {
  518. button.textContent = originalText;
  519. button.disabled = false;
  520. }
  521. };
  522.  
  523. // UI Styling
  524. const createStyles = () => {
  525. GM_addStyle(`
  526. #doi-widget-container {
  527. position: fixed; bottom: 20px; right: 20px;
  528. background-color: #0C344E; border-radius: 12px;
  529. box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 2147483647;
  530. display: none; overflow: hidden; text-align: center; color: white;
  531. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  532. min-width: 100px; /* Adjusted min-width for potentially longer text */
  533. }
  534. .widget-button {
  535. display: block; width: 100%; padding: 10px 15px;
  536. font-size: 14px; font-weight: 600; color: white;
  537. border: none; cursor: pointer; transition: all 0.2s ease;
  538. outline: none; border-bottom: 1px solid rgba(255,255,255,0.1);
  539. }
  540. .widget-button:last-of-type { border-bottom: none; } /* Applies to last button before copyright */
  541. #doi-widget-button { background-color: #118ab2; border-radius: 12px 12px 0 0; }
  542. #doi-widget-button:hover { background-color: #0f7ea1; transform: translateY(-1px); }
  543. #bibtex-widget-button { background-color: #06d6a0; border-radius: 0; }
  544. #bibtex-widget-button:hover { background-color: #05c290; transform: translateY(-1px); }
  545. #pdf-widget-button { background-color: #ff9f1c; border-radius: 0; } /* Orange for PDF */
  546. #pdf-widget-button:hover { background-color: #e68a00; transform: translateY(-1px); } /* Darker orange */
  547. .widget-button:active { transform: scale(0.98); }
  548. .widget-button:disabled { opacity: 0.7; cursor: not-allowed; transform: none; }
  549. .widget-button.copied-feedback { background-color: #28a745 !important; }
  550. .widget-button.error-feedback { background-color: #dc3545 !important; }
  551. #doi-widget-copyright {
  552. padding: 8px 12px; font-size: 11px; background-color: #0C344E;
  553. color: #a0d8ef; border-radius: 0 0 12px 12px;
  554. }
  555. @media (max-width: 768px) {
  556. #doi-widget-container { bottom: 15px; right: 15px; min-width: 90px; }
  557. .widget-button { padding: 10px 12px; font-size: 13px; }
  558. }
  559. `);
  560. };
  561.  
  562. // UI Widget Creation
  563. const createWidget = () => {
  564. if (document.getElementById('doi-widget-container')) return;
  565. const container = document.createElement('div'); container.id = 'doi-widget-container';
  566.  
  567. const doiButton = document.createElement('button');
  568. doiButton.id = 'doi-widget-button'; doiButton.className = 'widget-button';
  569. doiButton.textContent = 'DOI'; doiButton.title = '点击复制 DOI';
  570. doiButton.addEventListener('click', () => { if (state.currentDOI) copyToClipboard(state.currentDOI, doiButton, 'DOI已复制'); else showCopyFeedback(doiButton, '无DOI', true); });
  571.  
  572. const bibtexButton = document.createElement('button');
  573. bibtexButton.id = 'bibtex-widget-button'; bibtexButton.className = 'widget-button';
  574. bibtexButton.textContent = 'BibTeX'; bibtexButton.title = '点击获取并复制 BibTeX';
  575. bibtexButton.addEventListener('click', () => handleBibTeXClick(bibtexButton));
  576.  
  577. const pdfButton = document.createElement('button');
  578. pdfButton.id = 'pdf-widget-button'; pdfButton.className = 'widget-button';
  579. pdfButton.textContent = 'PDF'; pdfButton.title = '点击下载 PDF';
  580. pdfButton.addEventListener('click', () => enhancedDownloadPDF(pdfButton));
  581.  
  582. const copyright = document.createElement('div');
  583. copyright.id = 'doi-widget-copyright'; copyright.textContent = `Yul © ${new Date().getFullYear()}`;
  584.  
  585. container.appendChild(doiButton); container.appendChild(bibtexButton); container.appendChild(pdfButton);
  586. container.appendChild(copyright);
  587. document.body.appendChild(container);
  588. state.widget = container;
  589. };
  590.  
  591. // Widget Visibility Control
  592. const showWidget = () => { if (state.widget) state.widget.style.display = 'block'; };
  593. const hideWidget = () => { if (state.widget) state.widget.style.display = 'none'; };
  594.  
  595. // DOI Detection and Widget Activation
  596. const attemptExtractDOI = () => {
  597. state.retryCount++; const doi = extractDOI();
  598. if (doi) {
  599. clearInterval(state.timer); state.currentDOI = doi; console.log('DOI found:', doi);
  600. // Pre-fetch PDF URL in background if DOI is found
  601. findBestPDFUrl().then(pdfUrl => { if (pdfUrl) { state.currentPDFUrl = pdfUrl; state.pdfCache.set(window.location.href, pdfUrl); console.log('PDF URL pre-extracted:', pdfUrl);}});
  602. showWidget();
  603. } else if (state.retryCount >= CONFIG.MAX_RETRY) {
  604. clearInterval(state.timer); console.log('DOI not found after', CONFIG.MAX_RETRY, 'attempts');
  605. // Optionally show widget even if DOI not found, for PDF download attempts
  606. // For now, keeping original behavior: hide if no DOI. PDF button will rely on page scan.
  607. // To enable PDF button even without DOI, call showWidget() here or make it always visible.
  608. // For this modification, let's keep it tied to DOI presence for consistency with original style.
  609. // If you want PDF to always be available, remove hideWidget() or call showWidget()
  610. hideWidget(); // Original behavior
  611. }
  612. };
  613. const resetAndStart = utils.debounce(() => {
  614. console.log('DOI & BibTeX & PDF Plugin: Initializing/Resetting...');
  615. if (state.timer) clearInterval(state.timer);
  616. state.retryCount = 0; state.currentDOI = null; state.currentPDFUrl = null; // Reset PDF URL too
  617. // Do not hide widget immediately, attemptExtractDOI will manage visibility
  618. // hideWidget(); // This would hide it on SPA navigation before DOI is found
  619. state.timer = setInterval(attemptExtractDOI, CONFIG.RETRY_INTERVAL);
  620. attemptExtractDOI(); // Try once immediately
  621. }, 250); // Slightly shorter debounce for SPA
  622.  
  623. // Navigation and Mutation Observer
  624. const setupNavigationListener = () => {
  625. const originalPushState = history.pushState; const originalReplaceState = history.replaceState;
  626. history.pushState = function(...args) { originalPushState.apply(this, args); resetAndStart(); };
  627. history.replaceState = function(...args) { originalReplaceState.apply(this, args); resetAndStart(); };
  628. window.addEventListener('popstate', resetAndStart);
  629. if (window.MutationObserver) {
  630. const observer = new MutationObserver(utils.debounce(() => {
  631. // Only reset if DOI is not found or URL changed significantly
  632. // This prevents excessive resets on minor DOM changes
  633. if (!state.currentDOI || state.lastObservedURL !== window.location.href) {
  634. state.lastObservedURL = window.location.href;
  635. resetAndStart();
  636. }
  637. }, 1000));
  638. observer.observe(document.body, { childList: true, subtree: true });
  639. state.lastObservedURL = window.location.href; // Initialize
  640. }
  641. };
  642.  
  643. // Initialization
  644. const init = () => {
  645. createStyles();
  646. createWidget(); // Creates the widget but it's hidden by default CSS
  647. setupNavigationListener();
  648. resetAndStart(); // This will attempt to find DOI and show/hide widget
  649. };
  650.  
  651. // Script Execution Start
  652. if (document.readyState === 'loading') {
  653. document.addEventListener('DOMContentLoaded', init);
  654. } else {
  655. init();
  656. }
  657. })();