├── .editorconfig ├── .eslintrc ├── LICENSE.txt ├── README.md ├── f8-event-screenshot-small-ds.png └── fb-event-export.user.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.js] 9 | indent_style = tab 10 | indent_size = 4 11 | 12 | [.jshintrc,.eslintrc] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*Makefile*] 17 | indent_style = tab 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | }, 4 | "rules": { 5 | "comma-dangle": [0, "always-multiline"], 6 | "indent": [0, "tab"], 7 | //"indent": [2, "tab", {"VariableDeclarator": 0}], 8 | "quotes": [1, "single"], 9 | "linebreak-style": [2, "unix"], 10 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"], 11 | "semi": [2, "always"] 12 | }, 13 | "env": { 14 | "browser": true 15 | }, 16 | "extends": "eslint:recommended" 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015, 2017 Boris Joffe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Facebook Event Exporter 2 | ======================= 3 | 4 | ### Install from [GreasyFork](https://greasyfork.org/en/scripts/14782-facebook-event-exporter) 5 | 6 | Features 7 | -------- 8 | - Export individual Facebook events (including ones that you're not "Interested" in or invited to) to Google Calendar 9 | - Full event text and event URL is included in the event description 10 | 11 | ![Facebook F8 Event screenshot](f8-event-screenshot-small-ds.png) 12 | 13 | Requires 14 | -------- 15 | One of the following: 16 | - [TamperMonkey](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo?hl=en) with [Chrome](https://www.google.com/chrome/browser/) 17 | - [GreaseMonkey](https://addons.mozilla.org/en-US/firefox/addon/greasemonkey/) with [Firefox](https://www.mozilla.org/firefox) 18 | - [ViolentMonkey](https://addons.opera.com/en/extensions/details/violent-monkey/) / [TamperMonkey](https://addons.opera.com/en/extensions/details/tampermonkey-beta/?display=en) with [Opera](http://www.opera.com/) - untested 19 | 20 | Known Issues 21 | ----------- 22 | - Google Calendar times are displayed in UTC instead of local time 23 | - "About" tab has to be open for the "Export Event" button to show up (will not show up if "Discussion" tab open) 24 | - Refreshing the page is sometimes necessary if navigating from a non-event Facebook page 25 | 26 | License 27 | ------- 28 | MIT 29 | -------------------------------------------------------------------------------- /f8-event-screenshot-small-ds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/borisjoffe/facebook-event-export-button/0a425a0350ae4a3d5e1094c39a42cf12e8e48b8e/f8-event-screenshot-small-ds.png -------------------------------------------------------------------------------- /fb-event-export.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Facebook Event Exporter 3 | // @namespace http://boris.joff3.com 4 | // @version 1.3.11 5 | // @description Export Facebook events 6 | // @author Boris Joffe 7 | // @match https://www.facebook.com/* 8 | // @grant unsafeWindow 9 | // ==/UserScript== 10 | /* jshint -W097 */ 11 | /* globals console*/ 12 | /* eslint-disable no-console, no-unused-vars */ 13 | 'use strict'; 14 | 15 | /* 16 | The MIT License (MIT) 17 | 18 | Copyright (c) 2015, 2017, 2018 Boris Joffe 19 | 20 | Permission is hereby granted, free of charge, to any person obtaining a copy 21 | of this software and associated documentation files (the "Software"), to deal 22 | in the Software without restriction, including without limitation the rights 23 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 24 | copies of the Software, and to permit persons to whom the Software is 25 | furnished to do so, subject to the following conditions: 26 | 27 | The above copyright notice and this permission notice shall be included in 28 | all copies or substantial portions of the Software. 29 | 30 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 31 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 32 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 33 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 34 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 35 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 36 | THE SOFTWARE. 37 | */ 38 | 39 | 40 | // Util 41 | var 42 | qs = document.querySelector.bind(document), 43 | qsa = document.querySelectorAll.bind(document), 44 | err = console.error.bind(console), 45 | log = console.log.bind(console), 46 | euc = encodeURIComponent; 47 | 48 | var DEBUG = false; 49 | function dbg() { 50 | if (DEBUG) 51 | console.log.apply(console, arguments); 52 | 53 | return arguments[0]; 54 | } 55 | 56 | function qsv(elmStr, parent) { 57 | var elm = parent ? parent.querySelector(elmStr) : qs(elmStr); 58 | if (!elm) err('(qs) Could not get element -', elmStr); 59 | return elm; 60 | } 61 | 62 | function qsav(elmStr, parent) { 63 | var elm = parent ? parent.querySelectorAll(elmStr) : qsa(elmStr); 64 | if (!elm) err('(qsa) Could not get element -', elmStr); 65 | return elm; 66 | } 67 | 68 | /* 69 | function setProp(parent, path, val) { 70 | if (!parent || typeof parent !== 'object') 71 | return; 72 | path = Array.isArray(path) ? Array.from(path) : path.split('.'); 73 | var child, prop; 74 | while (path.length > 1) { 75 | prop = path.shift(); 76 | child = parent[prop]; 77 | if (!child || typeof child !== 'object') 78 | parent[prop] = {}; 79 | parent = parent[prop]; 80 | } 81 | parent[path.shift()] = val; 82 | } 83 | 84 | function getProp(obj, path, defaultValue) { 85 | path = Array.isArray(path) ? Array.from(path) : path.split('.'); 86 | var prop = obj; 87 | 88 | while (path.length && obj) { 89 | prop = obj[path.shift()]; 90 | } 91 | 92 | return prop != null ? prop : defaultValue; 93 | } 94 | 95 | */ 96 | 97 | // ==== Scrape ===== 98 | 99 | 100 | // == Title == 101 | 102 | function getTitle() { 103 | // only include the first host for brevity 104 | return document.title + ' (' + getHostedByText()[0] + ')'; 105 | } 106 | 107 | 108 | // == Dates == 109 | 110 | function convertDateString(dateObj) { 111 | return dateObj.toISOString() 112 | .replace(/-/g, '') 113 | .replace(/:/g, '') 114 | .replace('.000Z', ''); 115 | } 116 | 117 | function getDates() { 118 | return qsv('#event_time_info ._2ycp') 119 | .getAttribute('content') 120 | .split(' to ') 121 | .map(date => new Date(date)) 122 | .map(convertDateString); 123 | } 124 | 125 | function getStartDate() { return getDates()[0]; } 126 | function getEndDate() { return getDates()[1]; } 127 | 128 | 129 | // == Location / Address == 130 | 131 | function getLocation() { 132 | var hovercard = qsv('[data-hovercard]', qs('#event_summary')); 133 | return hovercard ? hovercard.innerText : ''; 134 | } 135 | 136 | function getAddress() { 137 | var hovercard = qsv('[data-hovercard]', qs('#event_summary')), 138 | addr = qsv('#u_0_1h'); 139 | if (hovercard) 140 | return hovercard.nextSibling.innerText || 'No Address Specified'; 141 | else if (addr) 142 | return addr.innerText; 143 | else 144 | // certain addresses like GPS coordinates 145 | // e.g. https://facebook.com/events/199708740636288/ 146 | // HACK: don't have a unique way to get the text (matches time and address - address is second) 147 | return Array.from(qsav('._5xhk')).slice(-1)[0].innerText; 148 | } 149 | 150 | function getLocationAndAddress() { 151 | return getLocation() ? 152 | (getLocation() + ', ' + getAddress()) 153 | : getAddress(); 154 | } 155 | 156 | 157 | // == Description == 158 | 159 | function getDescription() { 160 | var seeMore = qsv('.see_more_link'); 161 | if (seeMore) 162 | seeMore.click(); // expand description 163 | 164 | return location.href + 165 | '\n\n' + 166 | qsv('[data-testid="event-permalink-details"]').innerText; 167 | // Zip text array with links array? 168 | //'\n\nHosted By:\n' + 169 | //getHostedByText().join(', ') + '\n' + getHostedByLinks().join('\n') + 170 | } 171 | 172 | function getHostedByText() { 173 | var el = qsv('._5gnb [content]'); 174 | var text = el.getAttribute('content'); 175 | if (text.lastIndexOf(' & ') !== -1) 176 | text = text.substr(0, text.lastIndexOf(' & ')); // chop off trailing ' & ' 177 | 178 | return text.split(' & '); 179 | } 180 | 181 | 182 | // ==== Make Export URL ===== 183 | function makeExportUrl() { 184 | console.time('makeExportUrl'); 185 | var ev = { 186 | title : getTitle(), 187 | startDate : getStartDate(), 188 | endDate : getEndDate() || getStartDate(), // set to startDate if undefined 189 | locAndAddr : getLocationAndAddress(), 190 | description : getDescription() 191 | }; 192 | 193 | var totalLength = 0; 194 | for (var prop in ev) if (ev.hasOwnProperty(prop)) { 195 | ev[prop] = euc(dbg(ev[prop], ' - ' + prop)); 196 | totalLength += ev[prop].length; 197 | } 198 | 199 | // max is about 8200 chars but allow some slack for the base URL 200 | const MAX_URL_LENGTH = 8000; 201 | 202 | console.info('event props totalLength', totalLength); 203 | if (totalLength > MAX_URL_LENGTH) { 204 | var numCharsOverLimit = totalLength - MAX_URL_LENGTH; 205 | var maxEventDescriptionChars = ev.description.length - numCharsOverLimit; 206 | 207 | // will only happen if event title or location is extremely long 208 | // FIXME: truncate event title / location if necessary 209 | if (maxEventDescriptionChars < 1) { 210 | console.warn('maxEventDescriptionChars is', maxEventDescriptionChars); 211 | } 212 | 213 | console.warn('Event description truncated from', ev.description.length, 'characters to', maxEventDescriptionChars, 'characters'); 214 | 215 | ev.description = ev.description.substr(0, maxEventDescriptionChars) + '...'; 216 | } 217 | 218 | 219 | // gcal format - http://stackoverflow.com/questions/10488831/link-to-add-to-google-calendar 220 | 221 | // Create link, use UTC timezone to be compatible with toISOString() 222 | var exportUrl = 'https://calendar.google.com/calendar/render?action=TEMPLATE&text=[TITLE]&dates=[STARTDATE]/[ENDDATE]&details=[DETAILS]&location=[LOCATION]&ctz=UTC'; 223 | 224 | exportUrl = exportUrl 225 | .replace('[TITLE]', ev.title) 226 | .replace('[STARTDATE]', ev.startDate) 227 | .replace('[ENDDATE]', ev.endDate) 228 | .replace('[LOCATION]', ev.locAndAddr) 229 | .replace('[DETAILS]', ev.description); 230 | 231 | console.info('exportUrl length =', exportUrl.length); 232 | 233 | console.timeEnd('makeExportUrl'); 234 | return dbg(exportUrl, ' - Export URL'); 235 | } 236 | 237 | 238 | function addExportLink() { 239 | console.time('addExportLink'); 240 | log('Event Exporter running'); 241 | 242 | var 243 | evBarElm = qsv('#event_button_bar'), 244 | exportElmLink = qsv('a', evBarElm), 245 | exportElmParent = exportElmLink.parentNode; 246 | 247 | exportElmLink = exportElmLink.cloneNode(); 248 | exportElmLink.href = makeExportUrl(); 249 | exportElmLink.textContent = 'Export Event'; 250 | 251 | // Disable Facebook event listeners (that are attached due to cloning element) 252 | exportElmLink.removeAttribute('ajaxify'); 253 | exportElmLink.removeAttribute('rel'); 254 | exportElmLink.removeAttribute('data-onclick'); 255 | 256 | // Open in new tab 257 | exportElmLink.target = '_blank'; 258 | 259 | exportElmParent.appendChild(exportElmLink); 260 | 261 | var evBarLinks = qsav('a', evBarElm); 262 | Array.from(evBarLinks).forEach(function (a) { 263 | // fix styles 264 | a.style.display = 'inline-block'; 265 | }); 266 | console.timeEnd('addExportLink'); 267 | } 268 | 269 | 270 | (function (oldPushState) { 271 | // monkey patch pushState so that script works when navigating around Facebook 272 | window.history.pushState = function () { 273 | dbg('running pushState'); 274 | oldPushState.apply(window.history, arguments); 275 | setTimeout(addExportLinkWhenLoaded, 1000); 276 | }; 277 | dbg('monkey patched pushState'); 278 | })(window.history.pushState); 279 | 280 | // onpopstate is sometimes null causing the following error: 281 | // 'Cannot set property onpopstate of # which has only a getter' 282 | if (window.onpopstate) { 283 | window.onpopstate = function () { 284 | dbg('pop state event fired'); 285 | setTimeout(addExportLinkWhenLoaded, 1000); 286 | }; 287 | } else { 288 | dbg('Unable to set "onpopstate" event', window.onpopstate); 289 | } 290 | 291 | function addExportLinkWhenLoaded() { 292 | if (location.href.indexOf('/events/') === -1) { 293 | dbg('not an event page. skipping...'); 294 | return; 295 | } else if (!qs('#event_button_bar') || !qs('#event_summary')) { 296 | // not loaded 297 | dbg('page not loaded...'); 298 | setTimeout(addExportLinkWhenLoaded, 1000); 299 | } else { 300 | // loaded 301 | dbg('page loaded...adding link'); 302 | addExportLink(); 303 | } 304 | } 305 | 306 | var onLoad = addExportLinkWhenLoaded; 307 | 308 | window.addEventListener('load', onLoad, true); 309 | --------------------------------------------------------------------------------