├── .npmignore ├── lib ├── twitter.js ├── utils.js ├── router.js ├── ogp.js ├── oembed.js ├── tags.js └── index.js ├── .travis.yml ├── .gitignore ├── package.json ├── LICENSE ├── bench.js ├── test ├── tags.js ├── twitter.js ├── oembed.js ├── ogp.js └── index.js ├── README.md └── providers.json /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | !.npmignore 4 | !providers.json 5 | -------------------------------------------------------------------------------- /lib/twitter.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkh44/metaphor/master/lib/twitter.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "4" 5 | - "6" 6 | - "node" 7 | 8 | sudo: false 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | dump.rdb 3 | node_modules 4 | npm-shrinkwrap.json 5 | .idea 6 | .DS_Store 7 | */.DS_Store 8 | */*/.DS_Store 9 | ._* 10 | */._* 11 | */*/._* 12 | coverage.* 13 | config.json 14 | vault.json 15 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | 6 | // Declare internals 7 | 8 | const internals = {}; 9 | 10 | 11 | exports.parse = function (payload) { 12 | 13 | try { 14 | return JSON.parse(payload.toString()); 15 | } 16 | catch (err) { 17 | return null; 18 | } 19 | }; 20 | 21 | 22 | exports.copy = function (from, to, keys, source) { 23 | 24 | to = to || {}; 25 | let used = false; 26 | keys.forEach((key) => { 27 | 28 | if (from[key]) { 29 | to[key] = from[key]; 30 | used = true; 31 | } 32 | }); 33 | 34 | if (used && 35 | source) { 36 | 37 | to.sources.push(source); 38 | } 39 | 40 | return to; 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "metaphor", 3 | "description": "Open Graph, Twitter Card, and oEmbed Metadata Collector", 4 | "version": "3.5.3", 5 | "repository": "git://github.com/hueniverse/metaphor", 6 | "main": "lib/index.js", 7 | "keywords": [ 8 | "oembed", 9 | "ogp", 10 | "open graph", 11 | "twitter card", 12 | "description", 13 | "embed" 14 | ], 15 | "engines": { 16 | "node": ">=4.x.x" 17 | }, 18 | "dependencies": { 19 | "content": "3.x.x", 20 | "hoek": "4.x.x", 21 | "htmlparser2": "3.x.x", 22 | "items": "2.x.x", 23 | "joi": "9.x.x", 24 | "wreck": "8.x.x" 25 | }, 26 | "devDependencies": { 27 | "code": "3.x.x", 28 | "lab": "10.x.x" 29 | }, 30 | "scripts": { 31 | "test": "node node_modules/lab/bin/lab -a code -t 100 -L -m 15000", 32 | "test-cov-html": "node node_modules/lab/bin/lab -a code -r html -o coverage.html -m 15000" 33 | }, 34 | "license": "BSD-3-Clause" 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Eran Hammer and Project contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * The names of any contributors may not be used to endorse or promote 12 | products derived from this software without specific prior written 13 | permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | * * * 27 | 28 | The complete list of contributors can be found at: https://github.com/hueniverse/metaphor/graphs/contributors 29 | -------------------------------------------------------------------------------- /bench.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Bench = require('bench'); 4 | const Metaphor = require('.'); 5 | const Wreck = require('wreck'); 6 | 7 | 8 | const parse = function (document) { 9 | 10 | // Grab the head 11 | 12 | const head = document.match(/]*>([\s\S]*)<\/head\s*>/); 13 | if (!head) { 14 | return []; 15 | } 16 | 17 | // Remove scripts 18 | 19 | const scripts = head[1].split(''); // 'something' -> [' 35 | 36 | 37 | 38 | 39 | 40 | 41 | `; 42 | 43 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 44 | 45 | expect(description).to.equal({ 46 | title: 'The Rock', 47 | type: 'video.movie', 48 | url: 'http://www.imdb.com/title/tt0117500/', 49 | image: { url: 'http://ia.media-imdb.com/images/rock.jpg' }, 50 | sources: ['ogp'] 51 | }); 52 | 53 | done(); 54 | }); 55 | }); 56 | 57 | it('handles name/value attributes', (done) => { 58 | 59 | const html = ` 60 | 61 | The Rock (1996) 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | `; 70 | 71 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 72 | 73 | expect(description).to.equal({ 74 | title: 'The Rock', 75 | type: 'video.movie', 76 | url: 'http://www.imdb.com/title/tt0117500/', 77 | image: { url: 'http://ia.media-imdb.com/images/rock.jpg' }, 78 | sources: ['ogp'] 79 | }); 80 | 81 | done(); 82 | }); 83 | }); 84 | 85 | it('handles missing icon link href attributes', (done) => { 86 | 87 | const html = ` 88 | 89 | The Rock (1996) 90 | 91 | 92 | 93 | 94 | `; 95 | 96 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 97 | 98 | expect(description).to.equal({ 99 | type: 'website', 100 | url: 'http://www.imdb.com/title/tt0117500/' 101 | }); 102 | 103 | done(); 104 | }); 105 | }); 106 | 107 | it('sets default icon link sizes attribute', (done) => { 108 | 109 | const html = ` 110 | 111 | The Rock (1996) 112 | 113 | 114 | 115 | 116 | `; 117 | 118 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 119 | 120 | expect(description).to.equal({ 121 | type: 'website', 122 | url: 'http://www.imdb.com/title/tt0117500/', 123 | icon: { any: 'http://example.com/', smallest: 'http://example.com/' }, 124 | sources: ['resource'] 125 | }); 126 | 127 | done(); 128 | }); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /lib/ogp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Joi = require('joi'); 6 | 7 | 8 | // Declare internals 9 | 10 | const internals = { 11 | subs: { 12 | image: 'url', 13 | audio: 'url', 14 | video: 'url', 15 | locale: 'primary' 16 | }, 17 | tags: { 18 | article: ['published_time', 'modified_time', 'expiration_time', 'section', 'tag'], // author 19 | audio: ['url', 'secure_url', 'type'], 20 | book: ['isbn', 'release_date', 'tag'], // author 21 | description: true, 22 | determiner: true, 23 | image: ['url', 'secure_url', 'width', 'height', 'type'], 24 | locale: ['primary', 'alternate'], 25 | music: ['duration', 'release_date'], // album, album:disk, album:track, musician, song, song:disc, song:track, creator 26 | profile: ['first_name', 'last_name', 'username', 'gender'], 27 | restrictions: ['age', 'country:allowed', 'country:disallowed', 'content'], 28 | rich_attachment: true, 29 | see_also: true, 30 | site_name: true, 31 | title: true, 32 | ttl: true, 33 | type: true, 34 | updated_time: true, 35 | url: true, 36 | video: ['url', 'secure_url', 'width', 'height', 'type', 'tag', 'duration', 'release_date'] // actor, actor:role, director, writer, series 37 | }, 38 | types: [ 39 | 'article', 40 | 'book', 41 | 'books.author', 42 | 'books.book', 43 | 'books.genre', 44 | 'business.business', 45 | 'fitness.course', 46 | 'game.achievement', 47 | 'music', 48 | 'music.album', 49 | 'music.playlist', 50 | 'music.radio_station', 51 | 'music.song', 52 | 'photo', 53 | 'place', 54 | 'product', 55 | 'product.group', 56 | 'product.item', 57 | 'profile', 58 | 'restaurant', 59 | 'restaurant.menu', 60 | 'restaurant.menu_item', 61 | 'restaurant.menu_section', 62 | 'restaurant.restaurant', 63 | 'video', 64 | 'video.episode', 65 | 'video.movie', 66 | 'video.other', 67 | 'video.tv_show', 68 | 'website' 69 | ] 70 | }; 71 | 72 | 73 | internals.urlRule = Joi.string().uri({ scheme: ['http', 'https'] }); 74 | 75 | 76 | exports.describe = function (tags) { 77 | 78 | const properties = {}; 79 | let last = null; 80 | for (let i = 0; i < tags.length; ++i) { 81 | const tag = tags[i]; 82 | const key = tag.key; 83 | let sub = tag.sub; 84 | let value = tag.value; 85 | 86 | const objectKey = internals.subs[key]; 87 | if (!sub) { 88 | sub = objectKey; 89 | } 90 | else if (['width', 'height', 'duration', 'age'].indexOf(sub) !== -1) { 91 | value = parseInt(value, 10); 92 | if (isNaN(value)) { 93 | last = null; 94 | continue; 95 | } 96 | } 97 | 98 | // Lookup tag 99 | 100 | const def = internals.tags[key]; 101 | if (!def) { 102 | last = null; 103 | continue; 104 | } 105 | 106 | if (def === true) { 107 | if (sub) { 108 | last = null; 109 | continue; 110 | } 111 | } 112 | else if (def.indexOf(sub) === -1) { 113 | last = null; 114 | continue; 115 | } 116 | 117 | // Process tag 118 | 119 | if (sub === objectKey) { 120 | sub = undefined; 121 | } 122 | 123 | if (sub) { 124 | if (last === key && 125 | objectKey) { 126 | 127 | const object = properties[key]; 128 | if (!object) { 129 | last = null; 130 | continue; 131 | } 132 | 133 | const prev = (Array.isArray(object) ? object[object.length - 1] : object); 134 | if (prev[sub] && 135 | sub !== 'secure_url') { 136 | 137 | prev[sub] = [].concat(prev[sub]); 138 | prev[sub].push(value); 139 | } 140 | else { 141 | if (sub === 'secure_url') { 142 | sub = 'url'; 143 | } 144 | 145 | prev[sub] = value; 146 | } 147 | } 148 | } 149 | else { 150 | if (objectKey) { 151 | value = { [objectKey]: value }; 152 | } 153 | 154 | if (properties[key]) { 155 | properties[key] = [].concat(properties[key]); 156 | properties[key].push(value); 157 | } 158 | else { 159 | properties[key] = value; 160 | } 161 | } 162 | 163 | last = key; 164 | } 165 | 166 | const found = !!Object.keys(properties).length; 167 | 168 | if (properties.type && 169 | properties.type.indexOf(':') !== -1) { 170 | 171 | properties.custom_type = properties.type; 172 | const parts = properties.custom_type.split(':'); 173 | const lastPart = parts[parts.length - 1]; 174 | properties.type = (internals.types.indexOf(lastPart) !== -1 ? lastPart : 'custom'); 175 | } 176 | else if (internals.types.indexOf(properties.type) === -1) { 177 | properties.type = 'website'; 178 | } 179 | 180 | properties.sources = (found ? ['ogp'] : []); 181 | 182 | if (properties.url) { 183 | const result = internals.urlRule.validate(properties.url); 184 | if (result.error) { 185 | delete properties.url; 186 | } 187 | } 188 | 189 | return properties; 190 | }; 191 | -------------------------------------------------------------------------------- /lib/oembed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Querystring = require('querystring'); 6 | const Url = require('url'); 7 | const Joi = require('joi'); 8 | const Wreck = require('wreck'); 9 | const Router = require('./router'); 10 | const Utils = require('./utils'); 11 | 12 | 13 | // Declare internals 14 | 15 | const internals = {}; 16 | 17 | 18 | internals.oembedSchema = Joi.object({ 19 | type: Joi.valid('photo', 'video', 'link', 'rich').required(), 20 | title: Joi.string(), 21 | site_name: Joi.string(), 22 | 23 | html: Joi.string().when('type', { is: ['rich', 'video'], then: Joi.required() }), 24 | url: Joi.string().uri({ scheme: ['http', 'https'] }).when('type', { is: 'photo', then: Joi.required() }), 25 | width: Joi.number().min(1).when('type', { is: Joi.not('link'), then: Joi.required() }), 26 | height: Joi.number().min(1).allow(null).when('type', { is: Joi.not('link'), then: Joi.required() }), 27 | 28 | thumbnail_url: Joi.string().uri({ scheme: ['http', 'https'] }), 29 | thumbnail_width: Joi.number().min(1), 30 | thumbnail_height: Joi.number().min(1), 31 | 32 | version: Joi.string().valid('1.0').required(), 33 | author_name: Joi.string(), 34 | author_url: Joi.string(), 35 | provider_url: Joi.string(), 36 | cache_age: Joi.number() 37 | }) 38 | .rename('provider_name', 'site_name') 39 | .unknown(); 40 | 41 | 42 | exports.describe = function (resource, url, options, next) { 43 | 44 | /* 45 | https://publish.twitter.com/oembed?url=https://twitter.com/sideway/status/626158822705401856 46 | 47 | { 48 | "author_name": "Sideway", 49 | "author_url": "https://twitter.com/sideway", 50 | "cache_age": "3153600000", 51 | "height": null, 52 | "html": "

First steps https://t.co/XvSn7XSI2G

— Sideway (@sideway) July 28, 2015
\n", 53 | "provider_name": "Twitter", 54 | "provider_url": "https://twitter.com", 55 | "type": "rich", 56 | "url": "https://twitter.com/sideway/status/626158822705401856", 57 | "version": "1.0", 58 | "width": 550 59 | } 60 | */ 61 | 62 | if (url) { 63 | const uri = Url.parse(url, true); 64 | delete uri.href; 65 | delete uri.path; 66 | delete uri.search; 67 | uri.query.format = 'json'; 68 | if (options.maxHeight) { 69 | uri.query.maxheight = options.maxHeight; 70 | } 71 | 72 | if (options.maxWidth) { 73 | uri.query.maxwidth = options.maxWidth; 74 | } 75 | 76 | url = Url.format(uri); 77 | } 78 | else if (options.router) { 79 | url = options.router.match(resource, options); 80 | } 81 | 82 | if (!url) { 83 | return next({}); 84 | } 85 | 86 | Wreck.get(url, { redirects: 1 }, (err, res, payload) => { 87 | 88 | if (err || 89 | res.statusCode !== 200) { 90 | 91 | return next({}); 92 | } 93 | 94 | const raw = Utils.parse(payload); 95 | if (!raw) { 96 | return next({}); 97 | } 98 | 99 | internals.oembedSchema.validate(raw, (err, oembed) => { 100 | 101 | if (err) { 102 | return next({}); 103 | } 104 | 105 | const thumbnail = (!oembed.thumbnail_url ? null : { 106 | url: oembed.thumbnail_url, 107 | width: oembed.thumbnail_width, 108 | height: oembed.thumbnail_height 109 | }); 110 | 111 | const description = { 112 | site_name: oembed.site_name, 113 | thumbnail 114 | }; 115 | 116 | if (oembed.type === 'link') { 117 | description.url = oembed.url; 118 | } 119 | else { 120 | description.embed = Utils.copy(oembed, null, ['type', 'height', 'width', 'url', 'html']); 121 | } 122 | 123 | return next(description); 124 | }); 125 | }); 126 | }; 127 | 128 | 129 | exports.providers = function (providers) { 130 | 131 | return new internals.Router(providers); 132 | }; 133 | 134 | 135 | internals.Router = class extends Router { 136 | constructor(providers) { 137 | 138 | super(); 139 | 140 | providers.forEach((provider) => { 141 | 142 | /* 143 | { 144 | "provider_name": "Alpha App Net", 145 | "provider_url": "https:\/\/alpha.app.net\/browse\/posts\/", 146 | "endpoints": [ 147 | { 148 | "schemes": [ 149 | "https:\/\/alpha.app.net\/*\/post\/*", 150 | "https:\/\/photos.app.net\/*\/*" 151 | ], 152 | "url": "https:\/\/alpha-api.app.net\/oembed", 153 | "formats": [ 154 | "json" 155 | ] 156 | } 157 | ] 158 | } 159 | */ 160 | 161 | provider.endpoints.forEach((endpoint) => { 162 | 163 | const url = endpoint.url.replace('{format}', 'json'); 164 | 165 | if (!endpoint.schemes) { 166 | return this.add(provider.provider_url, url); 167 | } 168 | 169 | endpoint.schemes.forEach((scheme) => this.add(scheme, url)); 170 | }); 171 | }); 172 | } 173 | 174 | match(url, options) { 175 | 176 | options = options || {}; 177 | 178 | const service = this.lookup(url); 179 | if (!service) { 180 | return null; 181 | } 182 | 183 | const query = { url, format: 'json' }; 184 | if (options.maxHeight) { 185 | query.maxheight = options.maxHeight; 186 | } 187 | 188 | if (options.maxWidth) { 189 | query.maxwidth = options.maxWidth; 190 | } 191 | 192 | return `${service}?${Querystring.stringify(query)}`; 193 | } 194 | }; 195 | -------------------------------------------------------------------------------- /lib/tags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Url = require('url'); 6 | const HtmlParser2 = require('htmlparser2'); 7 | 8 | 9 | // Declare internals 10 | 11 | const internals = {}; 12 | 13 | 14 | exports.parse = function (document, base, next) { 15 | 16 | /* 17 | 18 | 19 | The Rock (1996) 20 | ... 21 | 22 | 23 | 24 | 25 | ... 26 | 27 | 28 | 29 | 30 | ... 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ... 43 | 44 | 45 | ... 46 | 47 | */ 48 | 49 | const tags = { og: [], twitter: [], meta: [] }; 50 | let oembedLink = null; 51 | let smallestIcon = Infinity; 52 | 53 | const parser = new HtmlParser2.Parser({ 54 | onopentag: function (name, attributes) { 55 | 56 | if (name === 'body') { 57 | parser.reset(); 58 | return; 59 | } 60 | 61 | if (name === 'meta') { 62 | const property = attributes.property || attributes.name; 63 | const value = attributes.content || attributes.value; 64 | if (!property || 65 | !value) { 66 | 67 | return; 68 | } 69 | 70 | if (['author', 'description'].indexOf(property) !== -1) { 71 | tags.meta[property] = value; 72 | return; 73 | } 74 | 75 | const parsed = property.match(/^(og|twitter):([^:]*)(?:\:(.*))?$/); 76 | if (parsed) { 77 | tags[parsed[1]].push({ 78 | key: parsed[2], 79 | sub: parsed[3], 80 | value 81 | }); 82 | } 83 | 84 | return; 85 | } 86 | 87 | if (name === 'link' && 88 | attributes.href && 89 | attributes.rel) { 90 | 91 | const href = Url.resolve(base, attributes.href); 92 | const rels = attributes.rel.split(' '); 93 | for (let i = 0; i < rels.length; ++i) { 94 | let match = true; 95 | switch (rels[i]) { 96 | case 'alternate': 97 | case 'alternative': 98 | if (attributes.type === 'application/json+oembed') { 99 | oembedLink = href; 100 | } 101 | break; 102 | 103 | case 'icon': 104 | if (!attributes.sizes || 105 | attributes.sizes === 'any' || 106 | attributes.sizes.match(/^\d+x\d+$/)) { 107 | 108 | tags.meta.icon = tags.meta.icon || {}; 109 | let sizes = attributes.sizes || 'any'; 110 | if (sizes !== 'any') { 111 | sizes = parseInt(sizes.split('x')[0], 10); 112 | if (sizes < smallestIcon) { 113 | smallestIcon = sizes; 114 | } 115 | } 116 | 117 | if (!tags.meta.icon[sizes]) { 118 | tags.meta.icon[sizes] = href; 119 | } 120 | } 121 | break; 122 | default: 123 | match = false; 124 | break; 125 | } 126 | 127 | if (match) { 128 | break; 129 | } 130 | } 131 | } 132 | }, 133 | onend: function () { 134 | 135 | if (tags.meta.icon) { 136 | tags.meta.icon.smallest = tags.meta.icon[smallestIcon !== Infinity ? smallestIcon : 'any']; 137 | } 138 | 139 | return next(tags, oembedLink); 140 | } 141 | }, { decodeEntities: true }); 142 | 143 | parser.write(document); 144 | parser.end(); 145 | }; 146 | -------------------------------------------------------------------------------- /test/twitter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Metaphor = require('..'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Test shortcuts 16 | 17 | const lab = exports.lab = Lab.script(); 18 | const describe = lab.describe; 19 | const it = lab.it; 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Open Graph', () => { 24 | 25 | describe('describe()', () => { 26 | 27 | it('handles Twitter account id value', (done) => { 28 | 29 | const html = ` 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | `; 42 | 43 | Metaphor.parse(html, 'https://example.com', {}, (description) => { 44 | 45 | expect(description).to.equal({ 46 | url: 'https://example.com', 47 | type: 'website', 48 | description: 'The House energy and water bill failed after conservatives voted against their own legislation rather than acquiesce to a bipartisan amendment.', 49 | title: 'G.O.P. Opposition to Gay Rights Provision Derails Spending Bill', 50 | twitter: { 51 | site_username: '@nytimes', 52 | creator_id: '261289053' 53 | }, 54 | sources: ['twitter'] 55 | }); 56 | 57 | done(); 58 | }); 59 | }); 60 | 61 | it('handles missing image url', (done) => { 62 | 63 | const html = ` 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | `; 74 | 75 | Metaphor.parse(html, 'https://example.com', {}, (description) => { 76 | 77 | expect(description).to.equal({ 78 | url: 'https://example.com', 79 | type: 'website', 80 | twitter: { 81 | site_username: '@nytimes' 82 | }, 83 | sources: ['twitter'] 84 | }); 85 | 86 | done(); 87 | }); 88 | }); 89 | 90 | it('ignores unknown app', (done) => { 91 | 92 | const html = ` 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | `; 104 | 105 | Metaphor.parse(html, 'https://example.com', {}, (description) => { 106 | 107 | expect(description).to.equal({ 108 | url: 'https://example.com', 109 | type: 'website', 110 | description: 'The House energy and water bill failed after conservatives voted against their own legislation rather than acquiesce to a bipartisan amendment.', 111 | title: 'G.O.P. Opposition to Gay Rights Provision Derails Spending Bill', 112 | twitter: { site_username: '@nytimes' }, 113 | sources: ['twitter'] 114 | }); 115 | 116 | done(); 117 | }); 118 | }); 119 | 120 | it('ignores missing app sub key', (done) => { 121 | 122 | const html = ` 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | `; 134 | 135 | Metaphor.parse(html, 'https://example.com', {}, (description) => { 136 | 137 | expect(description).to.equal({ 138 | url: 'https://example.com', 139 | type: 'website', 140 | description: 'The House energy and water bill failed after conservatives voted against their own legislation rather than acquiesce to a bipartisan amendment.', 141 | title: 'G.O.P. Opposition to Gay Rights Provision Derails Spending Bill', 142 | twitter: { site_username: '@nytimes' }, 143 | sources: ['twitter'] 144 | }); 145 | 146 | done(); 147 | }); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # metaphor 2 | 3 | Open Graph, Twitter Card, and oEmbed Metadata Collector 4 | 5 | [![Build Status](https://secure.travis-ci.org/hueniverse/metaphor.svg)](http://travis-ci.org/hueniverse/metaphor) 6 | 7 | **metaphor** uses three web protocols to obtain information about web resources for the purpose of embedding smaller 8 | versions of those resources in other web resources or applications. It is very common for applications to expand 9 | links into a formatted preview of the link destination. However, obtaining this information requires using multiple 10 | protocols to ensure maximum coverage. 11 | 12 | This module uses the [Open Graph protocol](http://ogp.me/), [Twitter Cards](https://dev.twitter.com/cards/overview), 13 | the [oEmbed protocol](http://oembed.com/), and information gathered from the resource HTML markup and HTTP headers. 14 | It takes an optimistic approach, trying to gather information from as many sources as possible. 15 | 16 | ## Usage 17 | 18 | ```js 19 | const Metaphor = require('..'); 20 | 21 | const engine = new Metaphor.Engine(); 22 | engine.describe('https://www.youtube.com/watch?v=cWDdd5KKhts', (description) => { 23 | 24 | /* 25 | { 26 | site_name: 'YouTube', 27 | url: 'https://www.youtube.com/watch?v=cWDdd5KKhts', 28 | title: 'Cheese Shop Sketch - Monty Python\'s Flying Circus', 29 | image: { url: 'https://i.ytimg.com/vi/cWDdd5KKhts/maxresdefault.jpg' }, 30 | description: 'Subscribe to the Official Monty Python Channel here - http://smarturl.it/SubscribeToPython Cleese plays an erudite customer attempting to purchase some chees...', 31 | type: 'video', 32 | video: [ 33 | { 34 | url: 'https://www.youtube.com/embed/cWDdd5KKhts', 35 | type: 'text/html', 36 | width: '480', 37 | height: '360' 38 | }, 39 | { 40 | url: 'https://www.youtube.com/v/cWDdd5KKhts?version=3&autohide=1', 41 | type: 'application/x-shockwave-flash', 42 | width: '480', 43 | height: '360', 44 | tag: ['Monty Python', 'Python (Monty) Pictures Limited', 'Comedy', 'flying circus', 'monty pythons flying circus', 'john cleese', 'micael palin', 'eric idle', 'terry jones', 'graham chapman', 'terry gilliam', 'funny', 'comedy', 'animation', '60s animation', 'humor', 'humour', 'sketch show', 'british comedy', 'cheese shop', 'monty python cheese', 'cheese shop sketch', 'cleese cheese', 'cheese'] 45 | } 46 | ], 47 | thumbnail: { 48 | url: 'https://i.ytimg.com/vi/cWDdd5KKhts/hqdefault.jpg', 49 | width: 480, 50 | height: 360 51 | }, 52 | embed: { 53 | type: 'video', 54 | height: 344, 55 | width: 459, 56 | html: '' 57 | }, 58 | app: { 59 | iphone: { 60 | name: 'YouTube', 61 | id: '544007664', 62 | url: 'vnd.youtube://www.youtube.com/watch?v=cWDdd5KKhts&feature=applinks' 63 | }, 64 | ipad: { 65 | name: 'YouTube', 66 | id: '544007664', 67 | url: 'vnd.youtube://www.youtube.com/watch?v=cWDdd5KKhts&feature=applinks' 68 | }, 69 | googleplay: { 70 | name: 'YouTube', 71 | id: 'com.google.android.youtube', 72 | url: 'https://www.youtube.com/watch?v=cWDdd5KKhts' 73 | } 74 | }, 75 | player: { 76 | url: 'https://www.youtube.com/embed/cWDdd5KKhts', 77 | width: '480', 78 | height: '360' 79 | }, 80 | twitter: { site_username: '@youtube' }, 81 | icon: { 82 | '32': 'https://s.ytimg.com/yts/img/favicon_32-vfl8NGn4k.png', 83 | '48': 'https://s.ytimg.com/yts/img/favicon_48-vfl1s0rGh.png', 84 | '96': 'https://s.ytimg.com/yts/img/favicon_96-vfldSA3ca.png', 85 | '144': 'https://s.ytimg.com/yts/img/favicon_144-vflWmzoXw.png', 86 | smallest: 'https://s.ytimg.com/yts/img/favicon_32-vfl8NGn4k.png' 87 | }, 88 | preview: 'Cheese Shop Sketch - Monty Python\'s Flying Circus
Subscribe to the Official Monty Python Channel here - http://smarturl.it/SubscribeToPython Cleese plays an erudite customer attempting to purchase some chees...
', 89 | sources: ['ogp', 'resource', 'oembed', 'twitter'] 90 | } 91 | */ 92 | }); 93 | ``` 94 | 95 | ## API 96 | 97 | ### `new Metaphor.Engine([options])` 98 | 99 | A reusable engine used to set global processing settings for each description where: 100 | - `options` - optional settings where: 101 | - `providers` - if `true`, the [oEmbed](http://oembed.com/) providers list file is used to look up 102 | resources when oEmbed [discovery](http://oembed.com/#section4) doesn't work. The module ships with 103 | a copy of the providers.json file. To use a different provider list, pass an array compatible with 104 | the providers.json format. If `false`, oEmbed usage will be limited to discovery only. Defaults to 105 | `true`. 106 | - `whitelist` - an optional array of HTTP or HTTPS URLs allowed to be described. The URLs use the 107 | format `{scheme}://{domain}/{path}` where: 108 | - `scheme` - must be `'http'` or `'https'`. The module will ignore which protocol is specified 109 | and will describe both schemes. 110 | - `domain` - the domain name with an optional `*.` prefix. The `'www.'` prefix is automatically 111 | removed and ignored. 112 | - `path` - if specified, but be `'*'` or a path where at least one segment is `'*'`. 113 | - `maxWidth` - an optional integer passed to the oEmbed endpoint to limit the maximum width of elements 114 | in the description. While the protocol requires providers to comply, many do not so this is at best 115 | a recommendation. 116 | - `maxHeight` - an optional integer passed to the oEmbed endpoint to limit the maximum height of elements 117 | in the description. While the protocol requires providers to comply, many do not so this is at best 118 | a recommendation. 119 | - `preview` - can be set to: 120 | - `true` - a HTML preview is generated and returned in `description.preview`. This is the default. 121 | - `false` - no HTML preview is generated. 122 | - a function with the signature `function(description, options, callback)` where: 123 | - `description` - the resource description document. 124 | - `options` - the engine settings. 125 | - `callback` - the callback method using the signature `function(preview)` where: 126 | - `preview` - the HTML preview string or `null` to skip setting a preview. 127 | - `maxSize` - the maximum image size in bytes allowed to be included in an image preview. The limit 128 | is only enforced when creating a `preview` (ignored when `preview` is disabled). When set, an HTTP 129 | HEAD request is made to each image URL to obtain its size. Defaults to `false`. 130 | - `css` - if set to a URL, it is used as a style sheet link in the `preview`. Defaults to `false` (no link). 131 | - `script` - if set to a URL, it is used as a script link in the `preview`. Defaults to `false` (no link). 132 | 133 | ### `engine.describe(url, callback)` 134 | 135 | Described a web resource where: 136 | - `url` - the resource HTTP or HTTPS URL. 137 | - `callback` - the callback function using the signature `function(description)` where: 138 | - `description` - the **metaphor** description object. 139 | 140 | Note that the `describe()` method does not return errors. Errors are optimistically ignored and best effort 141 | is made to use as many sources for describing the `url`. The `description` always includes the following 142 | two properties: 143 | - `type` - set to `'website'` when the type is unknown. 144 | - `url` - the resource canonical URL, set to the requested `url` when no source can be utilized. 145 | 146 | When resources are successfully used, the `description` includes the `sources` property set to an array 147 | of one or more of: 148 | - `'resource'` - information was gathered from the resource HTML page. 149 | - `'ogp'` - Open Graph protocol tags (`og:`) used. 150 | - `'twitter'` - Twitter Card tags (`twitter:`) used. 151 | - `'oembed'` - oEmbed service used. 152 | -------------------------------------------------------------------------------- /test/oembed.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Metaphor = require('..'); 8 | const Wreck = require('wreck'); 9 | const Providers = require('../providers.json'); 10 | 11 | 12 | // Declare internals 13 | 14 | const internals = {}; 15 | 16 | 17 | // Test shortcuts 18 | 19 | const lab = exports.lab = Lab.script(); 20 | const describe = lab.describe; 21 | const it = lab.it; 22 | const expect = Code.expect; 23 | 24 | 25 | describe('OEmbed', () => { 26 | 27 | describe('describe()', () => { 28 | 29 | it('ignores invalid oembed response (request error)', { parallel: false }, (done) => { 30 | 31 | const html = ` 32 | 33 | 34 | 35 | `; 36 | 37 | const orig = Wreck.get; 38 | Wreck.get = (url, options, next) => { 39 | 40 | Wreck.get = orig; 41 | next(null, { statusCode: 400 }, ''); 42 | }; 43 | 44 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 45 | 46 | expect(description).to.equal({ 47 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 48 | type: 'website' 49 | }); 50 | 51 | done(); 52 | }); 53 | }); 54 | 55 | it('ignores invalid oembed response (network error)', { parallel: false }, (done) => { 56 | 57 | const html = ` 58 | 59 | 60 | 61 | `; 62 | 63 | const orig = Wreck.get; 64 | Wreck.get = (url, options, next) => { 65 | 66 | Wreck.get = orig; 67 | next(new Error('Cannot reach host')); 68 | }; 69 | 70 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 71 | 72 | expect(description).to.equal({ 73 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 74 | type: 'website' 75 | }); 76 | 77 | done(); 78 | }); 79 | }); 80 | 81 | it('ignores invalid oembed response (wrong version)', { parallel: false }, (done) => { 82 | 83 | const html = ` 84 | 85 | 86 | 87 | `; 88 | 89 | const oembed = { 90 | type: 'link', 91 | version: '2.0', 92 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 93 | provider_name: 'Twitter' 94 | }; 95 | 96 | const orig = Wreck.get; 97 | Wreck.get = (url, options, next) => { 98 | 99 | Wreck.get = orig; 100 | next(null, { statusCode: 200 }, JSON.stringify(oembed)); 101 | }; 102 | 103 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 104 | 105 | expect(description).to.equal({ 106 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 107 | type: 'website' 108 | }); 109 | 110 | done(); 111 | }); 112 | }); 113 | 114 | it('ignores invalid oembed response (invalid payload)', { parallel: false }, (done) => { 115 | 116 | const html = ` 117 | 118 | 119 | 120 | `; 121 | 122 | const orig = Wreck.get; 123 | Wreck.get = (url, options, next) => { 124 | 125 | Wreck.get = orig; 126 | next(null, { statusCode: 200 }, '{'); 127 | }; 128 | 129 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 130 | 131 | expect(description).to.equal({ 132 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 133 | type: 'website' 134 | }); 135 | 136 | done(); 137 | }); 138 | }); 139 | }); 140 | 141 | describe('match()', () => { 142 | 143 | it('returns a full service endpoint', (done) => { 144 | 145 | const router = Metaphor.oembed.providers(Providers); 146 | const resource = 'http://nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html?rref=collection%252Fnewseventcollection%252FPresidential+Election+2016&contentId=&mediaId=&referrer=http%3A%2F%2Fwww.nytimes.com%2F%3Faction%3Dclick%26contentCollection%3DPolitics%26region%3DTopBar%26module%3DHomePage-Button%26pgtype%3Darticle%26WT.z_jog%3D1%26hF%3Dt%26vS%3Dundefined&priority=true&action=click&contentCollection=Politics&module=Collection®ion=Marginalia&src=me&version=newsevent&pgtype=article'; 147 | const url = router.match(resource); 148 | expect(url).to.equal(`https://www.nytimes.com/svc/oembed/json/?url=${encodeURIComponent(resource)}&format=json`); 149 | done(); 150 | }); 151 | 152 | it('returns a full service endpoint (options)', (done) => { 153 | 154 | const router = Metaphor.oembed.providers(Providers); 155 | const resource = 'http://nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html?rref=collection%252Fnewseventcollection%252FPresidential+Election+2016&contentId=&mediaId=&referrer=http%3A%2F%2Fwww.nytimes.com%2F%3Faction%3Dclick%26contentCollection%3DPolitics%26region%3DTopBar%26module%3DHomePage-Button%26pgtype%3Darticle%26WT.z_jog%3D1%26hF%3Dt%26vS%3Dundefined&priority=true&action=click&contentCollection=Politics&module=Collection®ion=Marginalia&src=me&version=newsevent&pgtype=article'; 156 | const url = router.match(resource, { maxWidth: 250, maxHeight: 120 }); 157 | expect(url).to.equal(`https://www.nytimes.com/svc/oembed/json/?url=${encodeURIComponent(resource)}&format=json&maxheight=120&maxwidth=250`); 158 | done(); 159 | }); 160 | 161 | it('returns a null on mismatching url', (done) => { 162 | 163 | const router = Metaphor.oembed.providers(Providers); 164 | const resource = 'http://example.com'; 165 | const url = router.match(resource, { maxWidth: 250, maxHeight: 120 }); 166 | expect(url).to.be.null(); 167 | done(); 168 | }); 169 | }); 170 | 171 | describe('lookup()', () => { 172 | 173 | it('parses oembed.com providers json file', (done) => { 174 | 175 | const router = Metaphor.oembed.providers(Providers); 176 | const url = router.lookup('http://nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html?rref=collection%252Fnewseventcollection%252FPresidential+Election+2016&contentId=&mediaId=&referrer=http%3A%2F%2Fwww.nytimes.com%2F%3Faction%3Dclick%26contentCollection%3DPolitics%26region%3DTopBar%26module%3DHomePage-Button%26pgtype%3Darticle%26WT.z_jog%3D1%26hF%3Dt%26vS%3Dundefined&priority=true&action=click&contentCollection=Politics&module=Collection®ion=Marginalia&src=me&version=newsevent&pgtype=article'); 177 | expect(url).to.equal('https://www.nytimes.com/svc/oembed/json/'); 178 | done(); 179 | }); 180 | 181 | it('matches resource (www)', (done) => { 182 | 183 | const router = Metaphor.oembed.providers(Providers); 184 | const url = router.lookup('http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html?rref=collection%252Fnewseventcollection%252FPresidential+Election+2016&contentId=&mediaId=&referrer=http%3A%2F%2Fwww.nytimes.com%2F%3Faction%3Dclick%26contentCollection%3DPolitics%26region%3DTopBar%26module%3DHomePage-Button%26pgtype%3Darticle%26WT.z_jog%3D1%26hF%3Dt%26vS%3Dundefined&priority=true&action=click&contentCollection=Politics&module=Collection®ion=Marginalia&src=me&version=newsevent&pgtype=article'); 185 | expect(url).to.equal('https://www.nytimes.com/svc/oembed/json/'); 186 | done(); 187 | }); 188 | 189 | it('matches resource (wildcard)', (done) => { 190 | 191 | const router = Metaphor.oembed.providers(Providers); 192 | const url = router.lookup('http://hammer-family.smugmug.com/Scotch/Bruichladdich/i-LLqFWHM'); 193 | expect(url).to.equal('http://api.smugmug.com/services/oembed/'); 194 | done(); 195 | }); 196 | 197 | it('matches resource (path)', (done) => { 198 | 199 | const router = Metaphor.oembed.providers(Providers); 200 | const url = router.lookup('https://photos.app.net/z/y'); 201 | expect(url).to.equal('https://alpha-api.app.net/oembed'); 202 | done(); 203 | }); 204 | 205 | it('fails to find a match (path)', (done) => { 206 | 207 | const router = Metaphor.oembed.providers(Providers); 208 | const url = router.lookup('https://alpha.app.net/z/y'); 209 | expect(url).to.be.null(); 210 | done(); 211 | }); 212 | 213 | it('fails to find a match (short)', (done) => { 214 | 215 | const router = Metaphor.oembed.providers(Providers); 216 | const url = router.lookup('http://streamonecloud.net/embed/x'); 217 | expect(url).to.be.null(); 218 | done(); 219 | }); 220 | 221 | it('fails to find a match (long)', (done) => { 222 | 223 | const router = Metaphor.oembed.providers(Providers); 224 | const url = router.lookup('http://x.content.streamonecloud.net/embed/x'); 225 | expect(url).to.be.null(); 226 | done(); 227 | }); 228 | 229 | it('fails to find a match (longer)', (done) => { 230 | 231 | const router = Metaphor.oembed.providers(Providers); 232 | const url = router.lookup('http://y.x.content.streamonecloud.net/embed/x'); 233 | expect(url).to.be.null(); 234 | done(); 235 | }); 236 | 237 | it('ignores invalid endpoint scheme', (done) => { 238 | 239 | const router = Metaphor.oembed.providers([ 240 | { 241 | provider_name: 'Test provider', 242 | provider_url: 'https:\/\/example.com\/', 243 | endpoints: [ 244 | { 245 | schemes: ['ftp:\/\/example.com\/*\/post\/*'], 246 | url: 'https:\/\/example.com\/oembed' 247 | } 248 | ] 249 | } 250 | ]); 251 | 252 | expect(router._domains.subs).to.be.empty(); 253 | done(); 254 | }); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Url = require('url'); 6 | const Content = require('content'); 7 | const Hoek = require('hoek'); 8 | const Items = require('items'); 9 | const Joi = require('joi'); 10 | const Wreck = require('wreck'); 11 | const Oembed = require('./oembed'); 12 | const Ogp = require('./ogp'); 13 | const Providers = require('../providers.json'); 14 | const Router = require('./router'); 15 | const Tags = require('./tags'); 16 | const Twitter = require('./twitter'); 17 | const Utils = require('./utils'); 18 | 19 | 20 | // Declare internals 21 | 22 | const internals = {}; 23 | 24 | 25 | exports.oembed = { providers: Oembed.providers }; 26 | 27 | 28 | internals.schema = Joi.object({ 29 | maxWidth: Joi.number().integer().min(1), 30 | maxHeight: Joi.number().integer().min(1), 31 | maxSize: Joi.number().integer().min(1).allow(false).default(false), 32 | providers: Joi.array().allow(true, false).default(true), 33 | whitelist: Joi.array().items(Joi.string()).min(1), 34 | preview: Joi.func().allow(true, false).default(true), 35 | css: Joi.string().allow(false), 36 | script: Joi.string().allow(false), 37 | redirect: Joi.string() 38 | }); 39 | 40 | 41 | exports.Engine = class { 42 | constructor(options) { 43 | 44 | this.settings = Joi.attempt(options || {}, internals.schema); 45 | if (this.settings.providers === true) { 46 | this.settings.providers = Providers; 47 | } 48 | 49 | if (this.settings.providers) { 50 | this.settings.router = Oembed.providers(this.settings.providers); 51 | } 52 | 53 | if (this.settings.whitelist) { 54 | this._whitelist = new Router(); 55 | this.settings.whitelist.forEach((url) => this._whitelist.add(url, true)); 56 | } 57 | 58 | if (this.settings.preview === true) { 59 | this.settings.preview = internals.preview; 60 | } 61 | } 62 | 63 | describe(url, callback) { 64 | 65 | if (!this._whitelist || 66 | this._whitelist.lookup(url)) { 67 | 68 | return this._describe(url, callback); 69 | } 70 | 71 | return this._preview({ type: 'website', url }, Hoek.nextTick(callback)); 72 | } 73 | 74 | _describe(url, callback) { 75 | 76 | let req = null; 77 | const jar = {}; 78 | 79 | const setup = { 80 | headers: { 81 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36' 82 | }, 83 | redirects: 5, 84 | redirect303: true, 85 | redirected: (statusCode, location, redirectionReq) => { 86 | 87 | req = redirectionReq; 88 | }, 89 | beforeRedirect: (method, code, location, resHeaders, redirectOptions, next) => { 90 | 91 | const formatCookies = () => { 92 | 93 | let header = ''; 94 | Object.keys(jar).forEach((name) => { 95 | 96 | header += `${header ? '; ' : ''}${name}=${jar[name]}`; 97 | }); 98 | 99 | redirectOptions.headers = redirectOptions.headers || {}; 100 | redirectOptions.headers.cookie = header; 101 | return next(); 102 | }; 103 | 104 | const cookies = resHeaders['set-cookie']; 105 | if (!cookies) { 106 | return formatCookies(); 107 | } 108 | 109 | cookies.forEach((cookie) => { 110 | 111 | const parts = cookie.split(';', 1)[0].split('=', 2); 112 | jar[parts[0]] = parts[1]; 113 | return formatCookies(); 114 | }); 115 | } 116 | }; 117 | 118 | req = Wreck.request('GET', url, setup, (err, res) => { 119 | 120 | if (err || 121 | res.statusCode !== 200 || 122 | !res.headers['content-type']) { 123 | 124 | req.abort(); 125 | 126 | if (this.settings.router) { 127 | Oembed.describe(url, null, this.settings, (oembed) => { 128 | 129 | const description = { type: 'website', url }; 130 | internals.fill(description, oembed, ['site_name', 'thumbnail', 'embed'], 'oembed'); 131 | return this._preview(description, callback); 132 | }); 133 | 134 | return; 135 | } 136 | 137 | return this._preview({ type: 'website', url }, callback); 138 | } 139 | 140 | const type = Content.type(res.headers['content-type']); 141 | if (type.isBoom) { 142 | return this._preview({ type: 'website', url }, callback); 143 | } 144 | 145 | if (type.mime === 'text/html') { 146 | Wreck.read(res, {}, (err, payload) => { 147 | 148 | if (err) { 149 | return this._preview({ type: 'website', url }, callback); 150 | } 151 | 152 | return exports.parse(payload.toString(), url, this.settings, (description) => this._preview(description, callback)); 153 | }); 154 | 155 | return; 156 | } 157 | 158 | req.abort(); 159 | 160 | if (type.mime.match(/^image\/\w+$/)) { 161 | const description = { 162 | type: 'website', 163 | url, 164 | site_name: 'Image', 165 | embed: { 166 | type: 'photo', 167 | url 168 | }, 169 | sources: ['resource'] 170 | }; 171 | 172 | const contentLength = res.headers['content-length']; 173 | if (contentLength) { 174 | description.embed.size = parseInt(contentLength, 10); 175 | } 176 | 177 | return this._preview(description, callback); 178 | } 179 | 180 | return this._preview({ type: 'website', url }, callback); 181 | }); 182 | } 183 | 184 | _preview(description, callback) { 185 | 186 | if (!description.site_name) { 187 | const uri = Url.parse(description.url); 188 | const parts = uri.hostname.split('.'); 189 | description.site_name = (parts.length >= 2 && parts[parts.length - 1] === 'com' ? parts[parts.length - 2].replace(/^\w/, ($0) => $0.toUpperCase()) : uri.hostname); 190 | } 191 | 192 | if (!this.settings.preview) { 193 | return callback(description); 194 | } 195 | 196 | internals.sizes(description, () => { 197 | 198 | this.settings.preview(description, this.settings, (preview) => { 199 | 200 | if (preview) { 201 | description.preview = preview; 202 | } 203 | 204 | return callback(description); 205 | }); 206 | }); 207 | } 208 | }; 209 | 210 | 211 | exports.parse = function (document, url, options, next) { 212 | 213 | Tags.parse(document, url, (tags, oembedLink) => { 214 | 215 | // Parse tags 216 | 217 | const description = Ogp.describe(tags.og); // Use Open Graph as base 218 | const twitter = Twitter.describe(tags.twitter); 219 | 220 | // Obtain and parse OEmbed description 221 | 222 | Oembed.describe(url, oembedLink, options, (oembed) => { 223 | 224 | // Combine descriptions 225 | 226 | description.url = description.url || oembed.url || url; 227 | 228 | internals.fill(description, oembed, ['site_name'], 'oembed'); 229 | internals.fill(description, twitter, ['description', 'title', 'image'], 'twitter'); 230 | internals.fill(description, tags.meta, ['description', 'author', 'icon'], 'resource'); 231 | 232 | Utils.copy(oembed, description, ['thumbnail', 'embed'], 'oembed'); 233 | Utils.copy(twitter, description, ['app', 'player', 'twitter'], 'twitter'); 234 | 235 | if (description.sources.length) { 236 | description.sources = Hoek.unique(description.sources); 237 | } 238 | else { 239 | delete description.sources; 240 | } 241 | 242 | return next(description); 243 | }); 244 | }); 245 | }; 246 | 247 | 248 | internals.fill = function (description, from, fields, source) { 249 | 250 | let used = false; 251 | fields.forEach((field) => { 252 | 253 | if (!description[field] && 254 | from[field]) { 255 | 256 | description[field] = from[field]; 257 | used = true; 258 | } 259 | }); 260 | 261 | if (used) { 262 | description.sources = description.sources || []; 263 | description.sources.push(source); 264 | } 265 | }; 266 | 267 | 268 | internals.preview = function (description, options, callback) { 269 | 270 | const icon = (description.icon ? description.icon.smallest : ''); 271 | const image = internals.image(description, options); 272 | const url = (options.redirect ? `${options.redirect}${encodeURIComponent(description.url)}` : description.url); 273 | const html = ` 274 | 275 | 276 | 277 | ${description.title ? '' + description.title + '' : ''} 278 | ${options.css ? '' : ''} 279 | ${options.script ? '' : ''} 280 | 281 | 282 |
283 |
284 | ${icon ? '' : '
'} 285 | ${description.site_name !== 'Image' ? '
' + description.site_name + '
' : ''} 286 | 287 |
${description.title || description.url}
288 |
289 |
290 |
291 |
292 | ${description.description || ''} 293 |
294 | ${image ? '
' : '
'} 295 |
296 |
297 | 298 | `; 299 | 300 | return callback(html.replace(/\n\s+/g, '')); 301 | }; 302 | 303 | 304 | internals.image = function (description, options) { 305 | 306 | const images = internals.images(description); 307 | if (!images.length) { 308 | return ''; 309 | } 310 | 311 | if (!options.maxSize) { 312 | return images[0].url; 313 | } 314 | 315 | for (let i = 0; i < images.length; ++i) { 316 | const image = images[i]; 317 | if (image.size && 318 | image.size <= options.maxSize) { 319 | 320 | return image.url; 321 | } 322 | } 323 | 324 | return ''; 325 | }; 326 | 327 | 328 | internals.images = function (description) { 329 | 330 | let images = []; 331 | 332 | if (description.thumbnail) { 333 | images.push(description.thumbnail); 334 | } 335 | 336 | if (description.embed && 337 | description.embed.type === 'photo') { 338 | 339 | images.push(description.embed); 340 | } 341 | 342 | if (description.image) { 343 | images = images.concat(description.image); 344 | } 345 | 346 | return images; 347 | }; 348 | 349 | 350 | internals.sizes = function (description, callback) { 351 | 352 | const each = (image, next) => { 353 | 354 | if (image.size) { 355 | return next(); 356 | } 357 | 358 | Wreck.request('HEAD', image.url, {}, (err, res) => { 359 | 360 | if (err) { 361 | return next(); 362 | } 363 | 364 | const contentLength = res.headers['content-length']; 365 | if (contentLength) { 366 | image.size = parseInt(contentLength, 10); 367 | } 368 | 369 | Wreck.read(res, null, next); // Flush out any payload 370 | }); 371 | }; 372 | 373 | const images = internals.images(description); 374 | Items.parallel(images, each, callback); 375 | }; 376 | -------------------------------------------------------------------------------- /test/ogp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Metaphor = require('..'); 8 | 9 | 10 | // Declare internals 11 | 12 | const internals = {}; 13 | 14 | 15 | // Test shortcuts 16 | 17 | const lab = exports.lab = Lab.script(); 18 | const describe = lab.describe; 19 | const it = lab.it; 20 | const expect = Code.expect; 21 | 22 | 23 | describe('Open Graph', () => { 24 | 25 | describe('describe()', () => { 26 | 27 | it('supports multiple images', (done) => { 28 | 29 | const html = ` 30 | 31 | The Rock (1996) 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | `; 47 | 48 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 49 | 50 | expect(description).to.equal({ 51 | title: 'The Rock', 52 | type: 'video.movie', 53 | url: 'http://www.imdb.com/title/tt0117500/', 54 | image: [ 55 | { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 56 | { url: 'http://ia.media-imdb.com/images/rock2.jpg' } 57 | ], 58 | sources: ['ogp'] 59 | }); 60 | 61 | done(); 62 | }); 63 | }); 64 | 65 | it('supports multiple images with sub attributes', (done) => { 66 | 67 | const html = ` 68 | 69 | The Rock (1996) 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | `; 87 | 88 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 89 | 90 | expect(description).to.equal({ 91 | title: 'The Rock', 92 | type: 'video.movie', 93 | url: 'http://www.imdb.com/title/tt0117500/', 94 | image: [ 95 | { url: 'http://ia.media-imdb.com/images/rock1.jpg', width: 500, height: 330 }, 96 | { url: 'https://ia.media-imdb.com/images/rock2.jpg' } 97 | ], 98 | locale: { 99 | primary: 'en_GB', 100 | alternate: ['fr_FR', 'es_ES'] 101 | }, 102 | sources: ['ogp'] 103 | }); 104 | 105 | done(); 106 | }); 107 | }); 108 | 109 | it('sets default type', (done) => { 110 | 111 | const html = ` 112 | 113 | 114 | 115 | 116 | 117 | `; 118 | 119 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 120 | 121 | expect(description).to.equal({ 122 | title: 'The Rock', 123 | type: 'website', 124 | url: 'http://www.imdb.com/title/tt0117500/', 125 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 126 | sources: ['ogp'] 127 | }); 128 | 129 | done(); 130 | }); 131 | }); 132 | 133 | it('ignores unknown custom sub type', (done) => { 134 | 135 | const html = ` 136 | 137 | 138 | 139 | 140 | 141 | 142 | `; 143 | 144 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 145 | 146 | expect(description).to.equal({ 147 | title: 'The Rock', 148 | type: 'custom', 149 | custom_type: 'custom:unknown', 150 | url: 'http://www.imdb.com/title/tt0117500/', 151 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 152 | sources: ['ogp'] 153 | }); 154 | 155 | done(); 156 | }); 157 | }); 158 | 159 | it('ignore sub properties in the wrong place', (done) => { 160 | 161 | const html = ` 162 | 163 | The Rock (1996) 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | `; 177 | 178 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 179 | 180 | expect(description).to.equal({ 181 | title: 'The Rock', 182 | type: 'video.movie', 183 | url: 'http://www.imdb.com/title/tt0117500/', 184 | image: [ 185 | { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 186 | { url: 'http://ia.media-imdb.com/images/rock2.jpg' } 187 | ], 188 | sources: ['ogp'] 189 | }); 190 | 191 | done(); 192 | }); 193 | }); 194 | 195 | it('ignores invalid url', (done) => { 196 | 197 | const html = ` 198 | 199 | 200 | 201 | 202 | 203 | `; 204 | 205 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 206 | 207 | expect(description).to.equal({ 208 | title: 'The Rock', 209 | type: 'website', 210 | url: 'http://www.imdb.com/title/tt0117500/', 211 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 212 | sources: ['ogp'] 213 | }); 214 | 215 | done(); 216 | }); 217 | }); 218 | 219 | it('handles missing image', (done) => { 220 | 221 | const html = ` 222 | 223 | 224 | 225 | 226 | `; 227 | 228 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 229 | 230 | expect(description).to.equal({ 231 | title: 'The Rock', 232 | type: 'website', 233 | url: 'http://www.imdb.com/title/tt0117500/', 234 | sources: ['ogp'] 235 | }); 236 | 237 | done(); 238 | }); 239 | }); 240 | 241 | it('handles missing url', (done) => { 242 | 243 | const html = ` 244 | 245 | 246 | 247 | 248 | `; 249 | 250 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 251 | 252 | expect(description).to.equal({ 253 | title: 'The Rock', 254 | type: 'website', 255 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 256 | url: 'http://www.imdb.com/title/tt0117500/', 257 | sources: ['ogp'] 258 | }); 259 | 260 | done(); 261 | }); 262 | }); 263 | 264 | it('handles missing title', (done) => { 265 | 266 | const html = ` 267 | 268 | 269 | 270 | 271 | `; 272 | 273 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 274 | 275 | expect(description).to.equal({ 276 | type: 'website', 277 | url: 'http://www.imdb.com/title/tt0117500/', 278 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 279 | sources: ['ogp'] 280 | }); 281 | 282 | done(); 283 | }); 284 | }); 285 | 286 | it('handles duplicate image url', (done) => { 287 | 288 | const html = ` 289 | 290 | 291 | 292 | 293 | 294 | 295 | `; 296 | 297 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 298 | 299 | expect(description).to.equal({ 300 | type: 'website', 301 | url: 'http://www.imdb.com/title/tt0117500/', 302 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 303 | sources: ['ogp'] 304 | }); 305 | 306 | done(); 307 | }); 308 | }); 309 | 310 | it('handles multiple subs with missing root', (done) => { 311 | 312 | const html = ` 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | `; 322 | 323 | Metaphor.parse(html, 'http://www.imdb.com/title/tt0117500/', {}, (description) => { 324 | 325 | expect(description).to.equal({ 326 | title: 'The Rock', 327 | type: 'video', 328 | url: 'http://www.imdb.com/title/tt0117500/', 329 | image: { url: 'http://ia.media-imdb.com/images/rock1.jpg' }, 330 | sources: ['ogp'] 331 | }); 332 | 333 | done(); 334 | }); 335 | }); 336 | }); 337 | }); 338 | -------------------------------------------------------------------------------- /providers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "provider_name": "23HQ", 4 | "provider_url": "http:\/\/www.23hq.com", 5 | "endpoints": [ 6 | { 7 | "schemes": [ 8 | "http:\/\/www.23hq.com\/*\/photo\/*" 9 | ], 10 | "url": "http:\/\/www.23hq.com\/23\/oembed" 11 | } 12 | ] 13 | }, 14 | { 15 | "provider_name": "Alpha App Net", 16 | "provider_url": "https:\/\/alpha.app.net\/browse\/posts\/", 17 | "endpoints": [ 18 | { 19 | "schemes": [ 20 | "https:\/\/alpha.app.net\/*\/post\/*", 21 | "https:\/\/photos.app.net\/*\/*" 22 | ], 23 | "url": "https:\/\/alpha-api.app.net\/oembed", 24 | "formats": [ 25 | "json" 26 | ] 27 | } 28 | ] 29 | }, 30 | { 31 | "provider_name": "amCharts Live Editor", 32 | "provider_url": "http:\/\/live.amcharts.com\/", 33 | "endpoints": [ 34 | { 35 | "schemes": [ 36 | "http:\/\/live.amcharts.com\/*" 37 | ], 38 | "url": "http:\/\/live.amcharts.com\/oembed" 39 | } 40 | ] 41 | }, 42 | { 43 | "provider_name": "Animatron", 44 | "provider_url": "https:\/\/www.animatron.com\/", 45 | "endpoints": [ 46 | { 47 | "schemes": [ 48 | "https:\/\/www.animatron.com\/project\/*", 49 | "https:\/\/animatron.com\/project\/*" 50 | ], 51 | "url": "https:\/\/animatron.com\/oembed\/json", 52 | "discovery": true 53 | } 54 | ] 55 | }, 56 | { 57 | "provider_name": "Animoto", 58 | "provider_url": "http:\/\/animoto.com\/", 59 | "endpoints": [ 60 | { 61 | "schemes": [ 62 | "http:\/\/animoto.com\/play\/*" 63 | ], 64 | "url": "http:\/\/animoto.com\/oembeds\/create" 65 | } 66 | ] 67 | }, 68 | { 69 | "provider_name": "AudioSnaps", 70 | "provider_url": "http:\/\/audiosnaps.com", 71 | "endpoints": [ 72 | { 73 | "schemes": [ 74 | "http:\/\/audiosnaps.com\/k\/*" 75 | ], 76 | "url": "http:\/\/audiosnaps.com\/service\/oembed", 77 | "discovery": true 78 | } 79 | ] 80 | }, 81 | { 82 | "provider_name": "Blackfire.io", 83 | "provider_url": "https:\/\/blackfire.io", 84 | "endpoints": [ 85 | { 86 | "schemes": [ 87 | "https:\/\/blackfire.io\/profiles\/*\/graph", 88 | "https:\/\/blackfire.io\/profiles\/compare\/*\/graph" 89 | ], 90 | "url": "https:\/\/blackfire.io\/oembed", 91 | "discovery": true 92 | } 93 | ] 94 | }, 95 | { 96 | "provider_name": "Cacoo", 97 | "provider_url": "https:\/\/cacoo.com", 98 | "endpoints": [ 99 | { 100 | "schemes": [ 101 | "https:\/\/cacoo.com\/diagrams\/*" 102 | ], 103 | "url": "http:\/\/cacoo.com\/oembed.{format}" 104 | } 105 | ] 106 | }, 107 | { 108 | "provider_name": "CatBoat", 109 | "provider_url": "http:\/\/img.catbo.at\/", 110 | "endpoints": [ 111 | { 112 | "schemes": [ 113 | "http:\/\/img.catbo.at\/*" 114 | ], 115 | "url": "http:\/\/img.catbo.at\/oembed.json", 116 | "formats": [ 117 | "json" 118 | ] 119 | } 120 | ] 121 | }, 122 | { 123 | "provider_name": "ChartBlocks", 124 | "provider_url": "http:\/\/www.chartblocks.com\/", 125 | "endpoints": [ 126 | { 127 | "schemes": [ 128 | "http:\/\/public.chartblocks.com\/c\/*" 129 | ], 130 | "url": "http:\/\/embed.chartblocks.com\/1.0\/oembed" 131 | } 132 | ] 133 | }, 134 | { 135 | "provider_name": "chirbit.com", 136 | "provider_url": "http:\/\/www.chirbit.com\/", 137 | "endpoints": [ 138 | { 139 | "schemes": [ 140 | "http:\/\/chirb.it\/*" 141 | ], 142 | "url": "http:\/\/chirb.it\/oembed.{format}", 143 | "discovery": true 144 | } 145 | ] 146 | }, 147 | { 148 | "provider_name": "CircuitLab", 149 | "provider_url": "https:\/\/www.circuitlab.com\/", 150 | "endpoints": [ 151 | { 152 | "schemes": [ 153 | "https:\/\/www.circuitlab.com\/circuit\/*" 154 | ], 155 | "url": "https:\/\/www.circuitlab.com\/circuit\/oembed\/", 156 | "discovery": true 157 | } 158 | ] 159 | }, 160 | { 161 | "provider_name": "Clyp", 162 | "provider_url": "http:\/\/clyp.it\/", 163 | "endpoints": [ 164 | { 165 | "schemes": [ 166 | "http:\/\/clyp.it\/*", 167 | "http:\/\/clyp.it\/playlist\/*" 168 | ], 169 | "url": "http:\/\/api.clyp.it\/oembed\/", 170 | "discovery": true 171 | } 172 | ] 173 | }, 174 | { 175 | "provider_name": "Codepen", 176 | "provider_url": "https:\/\/codepen.io", 177 | "endpoints": [ 178 | { 179 | "schemes": [ 180 | "http:\/\/codepen.io\/*", 181 | "https:\/\/codepen.io\/*" 182 | ], 183 | "url": "http:\/\/codepen.io\/api\/oembed" 184 | } 185 | ] 186 | }, 187 | { 188 | "provider_name": "Codepoints", 189 | "provider_url": "https:\/\/codepoints.net", 190 | "endpoints": [ 191 | { 192 | "schemes": [ 193 | "http:\/\/codepoints.net\/*", 194 | "https:\/\/codepoints.net\/*", 195 | "http:\/\/www.codepoints.net\/*", 196 | "https:\/\/www.codepoints.net\/*" 197 | ], 198 | "url": "https:\/\/codepoints.net\/api\/v1\/oembed", 199 | "discovery": true 200 | } 201 | ] 202 | }, 203 | { 204 | "provider_name": "CollegeHumor", 205 | "provider_url": "http:\/\/www.collegehumor.com\/", 206 | "endpoints": [ 207 | { 208 | "schemes": [ 209 | "http:\/\/www.collegehumor.com\/video\/*" 210 | ], 211 | "url": "http:\/\/www.collegehumor.com\/oembed.{format}", 212 | "discovery": true 213 | } 214 | ] 215 | }, 216 | { 217 | "provider_name": "Coub", 218 | "provider_url": "http:\/\/coub.com\/", 219 | "endpoints": [ 220 | { 221 | "schemes": [ 222 | "http:\/\/coub.com\/view\/*", 223 | "http:\/\/coub.com\/embed\/*" 224 | ], 225 | "url": "http:\/\/coub.com\/api\/oembed.{format}" 226 | } 227 | ] 228 | }, 229 | { 230 | "provider_name": "Crowd Ranking", 231 | "provider_url": "http:\/\/crowdranking.com", 232 | "endpoints": [ 233 | { 234 | "schemes": [ 235 | "http:\/\/crowdranking.com\/*\/*" 236 | ], 237 | "url": "http:\/\/crowdranking.com\/api\/oembed.{format}" 238 | } 239 | ] 240 | }, 241 | { 242 | "provider_name": "Daily Mile", 243 | "provider_url": "http:\/\/www.dailymile.com", 244 | "endpoints": [ 245 | { 246 | "schemes": [ 247 | "http:\/\/www.dailymile.com\/people\/*\/entries\/*" 248 | ], 249 | "url": "http:\/\/api.dailymile.com\/oembed?format=json", 250 | "formats": [ 251 | "json" 252 | ] 253 | } 254 | ] 255 | }, 256 | { 257 | "provider_name": "Dailymotion", 258 | "provider_url": "http:\/\/www.dailymotion.com", 259 | "endpoints": [ 260 | { 261 | "schemes": [ 262 | "http:\/\/www.dailymotion.com\/video\/*" 263 | ], 264 | "url": "http:\/\/www.dailymotion.com\/services\/oembed", 265 | "discovery": true 266 | } 267 | ] 268 | }, 269 | { 270 | "provider_name": "Deviantart.com", 271 | "provider_url": "http:\/\/www.deviantart.com", 272 | "endpoints": [ 273 | { 274 | "schemes": [ 275 | "http:\/\/*.deviantart.com\/art\/*", 276 | "http:\/\/*.deviantart.com\/*#\/d*", 277 | "http:\/\/fav.me\/*", 278 | "http:\/\/sta.sh\/*" 279 | ], 280 | "url": "http:\/\/backend.deviantart.com\/oembed" 281 | } 282 | ] 283 | }, 284 | { 285 | "provider_name": "Didacte", 286 | "provider_url": "https:\/\/www.didacte.com\/", 287 | "endpoints": [ 288 | { 289 | "schemes": [ 290 | "https:\/\/*.didacte.com\/a\/course\/*" 291 | ], 292 | "url": "https:\/\/*.didacte.com\/cards\/oembed'", 293 | "discovery": true, 294 | "formats": [ 295 | "json" 296 | ] 297 | } 298 | ] 299 | }, 300 | { 301 | "provider_name": "Dipity", 302 | "provider_url": "http:\/\/www.dipity.com", 303 | "endpoints": [ 304 | { 305 | "schemes": [ 306 | "http:\/\/www.dipity.com\/*\/*\/" 307 | ], 308 | "url": "http:\/\/www.dipity.com\/oembed\/timeline\/" 309 | } 310 | ] 311 | }, 312 | { 313 | "provider_name": "Docs", 314 | "provider_url": "https:\/\/www.docs.com", 315 | "endpoints": [ 316 | { 317 | "schemes": [ 318 | "https:\/\/docs.com\/*", 319 | "https:\/\/www.docs.com\/*" 320 | ], 321 | "url": "https:\/\/docs.com\/api\/oembed", 322 | "discovery": true 323 | } 324 | ] 325 | }, 326 | { 327 | "provider_name": "Dotsub", 328 | "provider_url": "http:\/\/dotsub.com\/", 329 | "endpoints": [ 330 | { 331 | "schemes": [ 332 | "http:\/\/dotsub.com\/view\/*" 333 | ], 334 | "url": "http:\/\/dotsub.com\/services\/oembed" 335 | } 336 | ] 337 | }, 338 | { 339 | "provider_name": "edocr", 340 | "provider_url": "http:\/\/www.edocr.com", 341 | "endpoints": [ 342 | { 343 | "schemes": [ 344 | "http:\/\/edocr.com\/docs\/*" 345 | ], 346 | "url": "http:\/\/edocr.com\/api\/oembed" 347 | } 348 | ] 349 | }, 350 | { 351 | "provider_name": "EgliseInfo", 352 | "provider_url": "http:\/\/egliseinfo.catholique.fr\/", 353 | "endpoints": [ 354 | { 355 | "schemes": [ 356 | "http:\/\/egliseinfo.catholique.fr\/*" 357 | ], 358 | "url": "http:\/\/egliseinfo.catholique.fr\/api\/oembed", 359 | "discovery": true 360 | } 361 | ] 362 | }, 363 | { 364 | "provider_name": "Embed Articles", 365 | "provider_url": "http:\/\/embedarticles.com\/", 366 | "endpoints": [ 367 | { 368 | "schemes": [ 369 | "http:\/\/embedarticles.com\/*" 370 | ], 371 | "url": "http:\/\/embedarticles.com\/oembed\/" 372 | } 373 | ] 374 | }, 375 | { 376 | "provider_name": "Embedly", 377 | "provider_url": "http:\/\/api.embed.ly\/", 378 | "endpoints": [ 379 | { 380 | "url": "http:\/\/api.embed.ly\/1\/oembed" 381 | } 382 | ] 383 | }, 384 | { 385 | "provider_name": "Flickr", 386 | "provider_url": "http:\/\/www.flickr.com\/", 387 | "endpoints": [ 388 | { 389 | "schemes": [ 390 | "http:\/\/*.flickr.com\/photos\/*", 391 | "http:\/\/flic.kr\/p\/*" 392 | ], 393 | "url": "http:\/\/www.flickr.com\/services\/oembed\/", 394 | "discovery": true 395 | } 396 | ] 397 | }, 398 | { 399 | "provider_name": "FOX SPORTS Australia", 400 | "provider_url": "http:\/\/www.foxsports.com.au", 401 | "endpoints": [ 402 | { 403 | "schemes": [ 404 | "http:\/\/fiso.foxsports.com.au\/isomorphic-widget\/*", 405 | "https:\/\/fiso.foxsports.com.au\/isomorphic-widget\/*" 406 | ], 407 | "url": "https:\/\/fiso.foxsports.com.au\/oembed" 408 | } 409 | ] 410 | }, 411 | { 412 | "provider_name": "FunnyOrDie", 413 | "provider_url": "http:\/\/www.funnyordie.com\/", 414 | "endpoints": [ 415 | { 416 | "schemes": [ 417 | "http:\/\/www.funnyordie.com\/videos\/*" 418 | ], 419 | "url": "http:\/\/www.funnyordie.com\/oembed.{format}" 420 | } 421 | ] 422 | }, 423 | { 424 | "provider_name": "Geograph Britain and Ireland", 425 | "provider_url": "https:\/\/www.geograph.org.uk\/", 426 | "endpoints": [ 427 | { 428 | "schemes": [ 429 | "http:\/\/*.geograph.org.uk\/*", 430 | "http:\/\/*.geograph.co.uk\/*", 431 | "http:\/\/*.geograph.ie\/*", 432 | "http:\/\/*.wikimedia.org\/*_geograph.org.uk_*" 433 | ], 434 | "url": "http:\/\/api.geograph.org.uk\/api\/oembed" 435 | } 436 | ] 437 | }, 438 | { 439 | "provider_name": "Geograph Channel Islands", 440 | "provider_url": "http:\/\/channel-islands.geograph.org\/", 441 | "endpoints": [ 442 | { 443 | "schemes": [ 444 | "http:\/\/*.geograph.org.gg\/*", 445 | "http:\/\/*.geograph.org.je\/*", 446 | "http:\/\/channel-islands.geograph.org\/*", 447 | "http:\/\/channel-islands.geographs.org\/*", 448 | "http:\/\/*.channel.geographs.org\/*" 449 | ], 450 | "url": "http:\/\/www.geograph.org.gg\/api\/oembed" 451 | } 452 | ] 453 | }, 454 | { 455 | "provider_name": "Geograph Germany", 456 | "provider_url": "http:\/\/geo-en.hlipp.de\/", 457 | "endpoints": [ 458 | { 459 | "schemes": [ 460 | "http:\/\/geo-en.hlipp.de\/*", 461 | "http:\/\/geo.hlipp.de\/*", 462 | "http:\/\/germany.geograph.org\/*" 463 | ], 464 | "url": "http:\/\/geo.hlipp.de\/restapi.php\/api\/oembed" 465 | } 466 | ] 467 | }, 468 | { 469 | "provider_name": "Getty Images", 470 | "provider_url": "http:\/\/www.gettyimages.com\/", 471 | "endpoints": [ 472 | { 473 | "schemes": [ 474 | "http:\/\/gty.im\/*" 475 | ], 476 | "url": "http:\/\/embed.gettyimages.com\/oembed", 477 | "formats": [ 478 | "json" 479 | ] 480 | } 481 | ] 482 | }, 483 | { 484 | "provider_name": "Gfycat", 485 | "provider_url": "https:\/\/gfycat.com\/", 486 | "endpoints": [ 487 | { 488 | "schemes": [ 489 | "http:\/\/gfycat.com\/*", 490 | "http:\/\/www.gfycat.com\/*", 491 | "https:\/\/gfycat.com\/*", 492 | "https:\/\/www.gfycat.com\/*" 493 | ], 494 | "url": "https:\/\/api.gfycat.com\/v1\/oembed", 495 | "discovery": true 496 | } 497 | ] 498 | }, 499 | { 500 | "provider_name": "HuffDuffer", 501 | "provider_url": "http:\/\/huffduffer.com", 502 | "endpoints": [ 503 | { 504 | "schemes": [ 505 | "http:\/\/huffduffer.com\/*\/*" 506 | ], 507 | "url": "http:\/\/huffduffer.com\/oembed" 508 | } 509 | ] 510 | }, 511 | { 512 | "provider_name": "Hulu", 513 | "provider_url": "http:\/\/www.hulu.com\/", 514 | "endpoints": [ 515 | { 516 | "schemes": [ 517 | "http:\/\/www.hulu.com\/watch\/*" 518 | ], 519 | "url": "http:\/\/www.hulu.com\/api\/oembed.{format}" 520 | } 521 | ] 522 | }, 523 | { 524 | "provider_name": "iFixit", 525 | "provider_url": "http:\/\/www.iFixit.com", 526 | "endpoints": [ 527 | { 528 | "schemes": [ 529 | "http:\/\/www.ifixit.com\/Guide\/View\/*" 530 | ], 531 | "url": "http:\/\/www.ifixit.com\/Embed" 532 | } 533 | ] 534 | }, 535 | { 536 | "provider_name": "IFTTT", 537 | "provider_url": "http:\/\/www.ifttt.com\/", 538 | "endpoints": [ 539 | { 540 | "schemes": [ 541 | "http:\/\/ifttt.com\/recipes\/*" 542 | ], 543 | "url": "http:\/\/www.ifttt.com\/oembed\/", 544 | "discovery": true 545 | } 546 | ] 547 | }, 548 | { 549 | "provider_name": "Infogram", 550 | "provider_url": "https:\/\/infogr.am\/", 551 | "endpoints": [ 552 | { 553 | "schemes": [ 554 | "https:\/\/infogr.am\/*" 555 | ], 556 | "url": "https:\/\/infogr.am\/oembed" 557 | } 558 | ] 559 | }, 560 | { 561 | "provider_name": "Instagram", 562 | "provider_url": "https:\/\/instagram.com", 563 | "endpoints": [ 564 | { 565 | "schemes": [ 566 | "http:\/\/instagram.com\/p\/*", 567 | "http:\/\/instagr.am\/p\/*", 568 | "https:\/\/instagram.com\/p\/*", 569 | "https:\/\/instagr.am\/p\/*" 570 | ], 571 | "url": "http:\/\/api.instagram.com\/oembed", 572 | "formats": [ 573 | "json" 574 | ] 575 | } 576 | ] 577 | }, 578 | { 579 | "provider_name": "iSnare Articles", 580 | "provider_url": "https:\/\/www.isnare.com\/", 581 | "endpoints": [ 582 | { 583 | "schemes": [ 584 | "https:\/\/www.isnare.com\/*" 585 | ], 586 | "url": "https:\/\/www.isnare.com\/oembed\/" 587 | } 588 | ] 589 | }, 590 | { 591 | "provider_name": "Kickstarter", 592 | "provider_url": "http:\/\/www.kickstarter.com", 593 | "endpoints": [ 594 | { 595 | "schemes": [ 596 | "http:\/\/www.kickstarter.com\/projects\/*" 597 | ], 598 | "url": "http:\/\/www.kickstarter.com\/services\/oembed" 599 | } 600 | ] 601 | }, 602 | { 603 | "provider_name": "Kitchenbowl", 604 | "provider_url": "http:\/\/www.kitchenbowl.com", 605 | "endpoints": [ 606 | { 607 | "schemes": [ 608 | "http:\/\/www.kitchenbowl.com\/recipe\/*" 609 | ], 610 | "url": "http:\/\/www.kitchenbowl.com\/oembed", 611 | "discovery": true 612 | } 613 | ] 614 | }, 615 | { 616 | "provider_name": "LearningApps.org", 617 | "provider_url": "http:\/\/learningapps.org\/", 618 | "endpoints": [ 619 | { 620 | "schemes": [ 621 | "http:\/\/learningapps.org\/*" 622 | ], 623 | "url": "http:\/\/learningapps.org\/oembed.php", 624 | "discovery": true 625 | } 626 | ] 627 | }, 628 | { 629 | "provider_name": "Meetup", 630 | "provider_url": "http:\/\/www.meetup.com", 631 | "endpoints": [ 632 | { 633 | "schemes": [ 634 | "http:\/\/meetup.com\/*", 635 | "http:\/\/meetu.ps\/*" 636 | ], 637 | "url": "https:\/\/api.meetup.com\/oembed", 638 | "formats": [ 639 | "json" 640 | ] 641 | } 642 | ] 643 | }, 644 | { 645 | "provider_name": "MixCloud", 646 | "provider_url": "http:\/\/mixcloud.com\/", 647 | "endpoints": [ 648 | { 649 | "schemes": [ 650 | "http:\/\/www.mixcloud.com\/*\/*\/" 651 | ], 652 | "url": "http:\/\/www.mixcloud.com\/oembed\/" 653 | } 654 | ] 655 | }, 656 | { 657 | "provider_name": "Moby Picture", 658 | "provider_url": "http:\/\/www.mobypicture.com", 659 | "endpoints": [ 660 | { 661 | "schemes": [ 662 | "http:\/\/www.mobypicture.com\/user\/*\/view\/*", 663 | "http:\/\/moby.to\/*" 664 | ], 665 | "url": "http:\/\/api.mobypicture.com\/oEmbed" 666 | } 667 | ] 668 | }, 669 | { 670 | "provider_name": "nfb.ca", 671 | "provider_url": "http:\/\/www.nfb.ca\/", 672 | "endpoints": [ 673 | { 674 | "schemes": [ 675 | "http:\/\/*.nfb.ca\/film\/*" 676 | ], 677 | "url": "http:\/\/www.nfb.ca\/remote\/services\/oembed\/", 678 | "discovery": true 679 | } 680 | ] 681 | }, 682 | { 683 | "provider_name": "Office Mix", 684 | "provider_url": "http:\/\/mix.office.com\/", 685 | "endpoints": [ 686 | { 687 | "schemes": [ 688 | "https:\/\/mix.office.com\/watch\/*", 689 | "https:\/\/mix.office.com\/embed\/*" 690 | ], 691 | "url": "https:\/\/mix.office.com\/oembed", 692 | "discovery": true 693 | } 694 | ] 695 | }, 696 | { 697 | "provider_name": "Official FM", 698 | "provider_url": "http:\/\/official.fm", 699 | "endpoints": [ 700 | { 701 | "schemes": [ 702 | "http:\/\/official.fm\/tracks\/*", 703 | "http:\/\/official.fm\/playlists\/*" 704 | ], 705 | "url": "http:\/\/official.fm\/services\/oembed.{format}" 706 | } 707 | ] 708 | }, 709 | { 710 | "provider_name": "On Aol", 711 | "provider_url": "http:\/\/on.aol.com\/", 712 | "endpoints": [ 713 | { 714 | "schemes": [ 715 | "http:\/\/on.aol.com\/video\/*" 716 | ], 717 | "url": "http:\/\/on.aol.com\/api" 718 | } 719 | ] 720 | }, 721 | { 722 | "provider_name": "Ora TV", 723 | "provider_url": "http:\/\/www.ora.tv\/", 724 | "endpoints": [ 725 | { 726 | "discovery": true, 727 | "url": "https:\/\/www.ora.tv\/oembed\/*?format={format}" 728 | } 729 | ] 730 | }, 731 | { 732 | "provider_name": "Oumy", 733 | "provider_url": "https:\/\/www.oumy.com\/", 734 | "endpoints": [ 735 | { 736 | "schemes": [ 737 | "https:\/\/www.oumy.com\/v\/*" 738 | ], 739 | "url": "https:\/\/www.oumy.com\/oembed", 740 | "discovery": true 741 | } 742 | ] 743 | }, 744 | { 745 | "provider_name": "Pastery", 746 | "provider_url": "https:\/\/www.pastery.net", 747 | "endpoints": [ 748 | { 749 | "schemes": [ 750 | "http:\/\/pastery.net\/*", 751 | "https:\/\/pastery.net\/*", 752 | "http:\/\/www.pastery.net\/*", 753 | "https:\/\/www.pastery.net\/*" 754 | ], 755 | "url": "https:\/\/www.pastery.net\/oembed", 756 | "discovery": true 757 | } 758 | ] 759 | }, 760 | { 761 | "provider_name": "Poll Daddy", 762 | "provider_url": "http:\/\/polldaddy.com", 763 | "endpoints": [ 764 | { 765 | "schemes": [ 766 | "http:\/\/*.polldaddy.com\/s\/*", 767 | "http:\/\/*.polldaddy.com\/poll\/*", 768 | "http:\/\/*.polldaddy.com\/ratings\/*" 769 | ], 770 | "url": "http:\/\/polldaddy.com\/oembed\/" 771 | } 772 | ] 773 | }, 774 | { 775 | "provider_name": "Portfolium", 776 | "provider_url": "https:\/\/portfolium.com", 777 | "endpoints": [ 778 | { 779 | "schemes": [ 780 | "https:\/\/portfolium.com\/entry\/*" 781 | ], 782 | "url": "https:\/\/api.portfolium.com\/oembed" 783 | } 784 | ] 785 | }, 786 | { 787 | "provider_name": "Quiz.biz", 788 | "provider_url": "http:\/\/www.quiz.biz\/", 789 | "endpoints": [ 790 | { 791 | "schemes": [ 792 | "http:\/\/www.quiz.biz\/quizz-*.html" 793 | ], 794 | "url": "http:\/\/www.quiz.biz\/api\/oembed", 795 | "discovery": true 796 | } 797 | ] 798 | }, 799 | { 800 | "provider_name": "Quizz.biz", 801 | "provider_url": "http:\/\/www.quizz.biz\/", 802 | "endpoints": [ 803 | { 804 | "schemes": [ 805 | "http:\/\/www.quizz.biz\/quizz-*.html" 806 | ], 807 | "url": "http:\/\/www.quizz.biz\/api\/oembed", 808 | "discovery": true 809 | } 810 | ] 811 | }, 812 | { 813 | "provider_name": "RapidEngage", 814 | "provider_url": "https:\/\/rapidengage.com", 815 | "endpoints": [ 816 | { 817 | "schemes": [ 818 | "https:\/\/rapidengage.com\/s\/*" 819 | ], 820 | "url": "https:\/\/rapidengage.com\/api\/oembed" 821 | } 822 | ] 823 | }, 824 | { 825 | "provider_name": "Rdio", 826 | "provider_url": "http:\/\/rdio.com\/", 827 | "endpoints": [ 828 | { 829 | "schemes": [ 830 | "http:\/\/*.rdio.com\/artist\/*", 831 | "http:\/\/*.rdio.com\/people\/*" 832 | ], 833 | "url": "http:\/\/www.rdio.com\/api\/oembed\/" 834 | } 835 | ] 836 | }, 837 | { 838 | "provider_name": "ReleaseWire", 839 | "provider_url": "http:\/\/www.releasewire.com\/", 840 | "endpoints": [ 841 | { 842 | "schemes": [ 843 | "http:\/\/rwire.com\/*" 844 | ], 845 | "url": "http:\/\/publisher.releasewire.com\/oembed\/", 846 | "discovery": true 847 | } 848 | ] 849 | }, 850 | { 851 | "provider_name": "RepubHub", 852 | "provider_url": "http:\/\/repubhub.icopyright.net\/", 853 | "endpoints": [ 854 | { 855 | "schemes": [ 856 | "http:\/\/repubhub.icopyright.net\/freePost.act?*" 857 | ], 858 | "url": "http:\/\/repubhub.icopyright.net\/oembed.act", 859 | "discovery": true 860 | } 861 | ] 862 | }, 863 | { 864 | "provider_name": "ReverbNation", 865 | "provider_url": "https:\/\/www.reverbnation.com\/", 866 | "endpoints": [ 867 | { 868 | "schemes": [ 869 | "https:\/\/www.reverbnation.com\/*", 870 | "https:\/\/www.reverbnation.com\/*\/songs\/*" 871 | ], 872 | "url": "https:\/\/www.reverbnation.com\/oembed", 873 | "discovery": true 874 | } 875 | ] 876 | }, 877 | { 878 | "provider_name": "Roomshare", 879 | "provider_url": "http:\/\/roomshare.jp", 880 | "endpoints": [ 881 | { 882 | "schemes": [ 883 | "http:\/\/roomshare.jp\/post\/*", 884 | "http:\/\/roomshare.jp\/en\/post\/*" 885 | ], 886 | "url": "http:\/\/roomshare.jp\/en\/oembed.{format}" 887 | } 888 | ] 889 | }, 890 | { 891 | "provider_name": "Sapo Videos", 892 | "provider_url": "http:\/\/videos.sapo.pt", 893 | "endpoints": [ 894 | { 895 | "schemes": [ 896 | "http:\/\/videos.sapo.pt\/*" 897 | ], 898 | "url": "http:\/\/videos.sapo.pt\/oembed" 899 | } 900 | ] 901 | }, 902 | { 903 | "provider_name": "Screenr", 904 | "provider_url": "http:\/\/www.screenr.com\/", 905 | "endpoints": [ 906 | { 907 | "schemes": [ 908 | "http:\/\/www.screenr.com\/*\/" 909 | ], 910 | "url": "http:\/\/www.screenr.com\/api\/oembed.{format}" 911 | } 912 | ] 913 | }, 914 | { 915 | "provider_name": "Scribd", 916 | "provider_url": "http:\/\/www.scribd.com\/", 917 | "endpoints": [ 918 | { 919 | "schemes": [ 920 | "http:\/\/www.scribd.com\/doc\/*" 921 | ], 922 | "url": "http:\/\/www.scribd.com\/services\/oembed\/" 923 | } 924 | ] 925 | }, 926 | { 927 | "provider_name": "ShortNote", 928 | "provider_url": "https:\/\/www.shortnote.jp\/", 929 | "endpoints": [ 930 | { 931 | "schemes": [ 932 | "https:\/\/www.shortnote.jp\/view\/notes\/*" 933 | ], 934 | "url": "https:\/\/www.shortnote.jp\/oembed\/", 935 | "discovery": true 936 | } 937 | ] 938 | }, 939 | { 940 | "provider_name": "Shoudio", 941 | "provider_url": "http:\/\/shoudio.com", 942 | "endpoints": [ 943 | { 944 | "schemes": [ 945 | "http:\/\/shoudio.com\/*", 946 | "http:\/\/shoud.io\/*" 947 | ], 948 | "url": "http:\/\/shoudio.com\/api\/oembed" 949 | } 950 | ] 951 | }, 952 | { 953 | "provider_name": "Show the Way, actionable location info", 954 | "provider_url": "https:\/\/showtheway.io", 955 | "endpoints": [ 956 | { 957 | "schemes": [ 958 | "https:\/\/showtheway.io\/to\/*" 959 | ], 960 | "url": "https:\/\/showtheway.io\/oembed", 961 | "discovery": true 962 | } 963 | ] 964 | }, 965 | { 966 | "provider_name": "Silk", 967 | "provider_url": "http:\/\/www.silk.co\/", 968 | "endpoints": [ 969 | { 970 | "schemes": [ 971 | "http:\/\/*.silk.co\/explore\/*", 972 | "https:\/\/*.silk.co\/explore\/*", 973 | "http:\/\/*.silk.co\/s\/embed\/*", 974 | "https:\/\/*.silk.co\/s\/embed\/*" 975 | ], 976 | "url": "http:\/\/www.silk.co\/oembed\/", 977 | "discovery": true 978 | } 979 | ] 980 | }, 981 | { 982 | "provider_name": "Sketchfab", 983 | "provider_url": "http:\/\/sketchfab.com", 984 | "endpoints": [ 985 | { 986 | "schemes": [ 987 | "http:\/\/sketchfab.com\/models\/*", 988 | "https:\/\/sketchfab.com\/models\/*", 989 | "https:\/\/sketchfab.com\/*\/folders\/*" 990 | ], 991 | "url": "http:\/\/sketchfab.com\/oembed", 992 | "formats": [ 993 | "json" 994 | ] 995 | } 996 | ] 997 | }, 998 | { 999 | "provider_name": "SlideShare", 1000 | "provider_url": "http:\/\/www.slideshare.net\/", 1001 | "endpoints": [ 1002 | { 1003 | "schemes": [ 1004 | "http:\/\/www.slideshare.net\/*\/*", 1005 | "http:\/\/fr.slideshare.net\/*\/*", 1006 | "http:\/\/de.slideshare.net\/*\/*", 1007 | "http:\/\/es.slideshare.net\/*\/*", 1008 | "http:\/\/pt.slideshare.net\/*\/*" 1009 | ], 1010 | "url": "http:\/\/www.slideshare.net\/api\/oembed\/2", 1011 | "discovery": true 1012 | } 1013 | ] 1014 | }, 1015 | { 1016 | "provider_name": "SmugMug", 1017 | "provider_url": "http:\/\/www.smugmug.com\/", 1018 | "endpoints": [ 1019 | { 1020 | "schemes": [ 1021 | "http:\/\/*.smugmug.com\/*" 1022 | ], 1023 | "url": "http:\/\/api.smugmug.com\/services\/oembed\/", 1024 | "discovery": true 1025 | } 1026 | ] 1027 | }, 1028 | { 1029 | "provider_name": "SoundCloud", 1030 | "provider_url": "http:\/\/soundcloud.com\/", 1031 | "endpoints": [ 1032 | { 1033 | "schemes": [ 1034 | "http:\/\/soundcloud.com\/*" 1035 | ], 1036 | "url": "https:\/\/soundcloud.com\/oembed" 1037 | } 1038 | ] 1039 | }, 1040 | { 1041 | "provider_name": "SpeakerDeck", 1042 | "provider_url": "https:\/\/speakerdeck.com", 1043 | "endpoints": [ 1044 | { 1045 | "schemes": [ 1046 | "http:\/\/speakerdeck.com\/*\/*", 1047 | "https:\/\/speakerdeck.com\/*\/*" 1048 | ], 1049 | "url": "https:\/\/speakerdeck.com\/oembed.json", 1050 | "discovery": true, 1051 | "formats": [ 1052 | "json" 1053 | ] 1054 | } 1055 | ] 1056 | }, 1057 | { 1058 | "provider_name": "Streamable", 1059 | "provider_url": "https:\/\/streamable.com\/", 1060 | "endpoints": [ 1061 | { 1062 | "schemes": [ 1063 | "http:\/\/streamable.com\/*", 1064 | "https:\/\/streamable.com\/*" 1065 | ], 1066 | "url": "https:\/\/api.streamable.com\/oembed.json", 1067 | "discovery": true 1068 | } 1069 | ] 1070 | }, 1071 | { 1072 | "provider_name": "StreamOneCloud", 1073 | "provider_url": "https:\/\/www.streamone.nl", 1074 | "endpoints": [ 1075 | { 1076 | "schemes": [ 1077 | "https:\/\/content.streamonecloud.net\/embed\/*" 1078 | ], 1079 | "url": "https:\/\/content.streamonecloud.net\/oembed", 1080 | "discovery": true 1081 | } 1082 | ] 1083 | }, 1084 | { 1085 | "provider_name": "Sway", 1086 | "provider_url": "https:\/\/www.sway.com", 1087 | "endpoints": [ 1088 | { 1089 | "schemes": [ 1090 | "https:\/\/sway.com\/*", 1091 | "https:\/\/www.sway.com\/*" 1092 | ], 1093 | "url": "https:\/\/sway.com\/api\/v1.0\/oembed", 1094 | "discovery": true 1095 | } 1096 | ] 1097 | }, 1098 | { 1099 | "provider_name": "Ted", 1100 | "provider_url": "http:\/\/ted.com", 1101 | "endpoints": [ 1102 | { 1103 | "schemes": [ 1104 | "http:\/\/ted.com\/talks\/*" 1105 | ], 1106 | "url": "http:\/\/www.ted.com\/talks\/oembed.{format}" 1107 | } 1108 | ] 1109 | }, 1110 | { 1111 | "provider_name": "The New York Times", 1112 | "provider_url": "https:\/\/www.nytimes.com", 1113 | "endpoints": [ 1114 | { 1115 | "url": "https:\/\/www.nytimes.com\/svc\/oembed\/{format}\/", 1116 | "discovery": true 1117 | } 1118 | ] 1119 | }, 1120 | { 1121 | "provider_name": "They Said So", 1122 | "provider_url": "https:\/\/theysaidso.com\/", 1123 | "endpoints": [ 1124 | { 1125 | "schemes": [ 1126 | "https:\/\/theysaidso.com\/image\/*" 1127 | ], 1128 | "url": "https:\/\/theysaidso.com\/extensions\/oembed\/", 1129 | "discovery": true 1130 | } 1131 | ] 1132 | }, 1133 | { 1134 | "provider_name": "Topy", 1135 | "provider_url": "http:\/\/www.topy.se\/", 1136 | "endpoints": [ 1137 | { 1138 | "schemes": [ 1139 | "http:\/\/www.topy.se\/image\/*" 1140 | ], 1141 | "url": "http:\/\/www.topy.se\/oembed\/", 1142 | "discovery": true 1143 | } 1144 | ] 1145 | }, 1146 | { 1147 | "provider_name": "Ustream", 1148 | "provider_url": "http:\/\/www.ustream.tv", 1149 | "endpoints": [ 1150 | { 1151 | "schemes": [ 1152 | "http:\/\/*.ustream.tv\/*", 1153 | "http:\/\/*.ustream.com\/*" 1154 | ], 1155 | "url": "http:\/\/www.ustream.tv\/oembed", 1156 | "formats": [ 1157 | "json" 1158 | ] 1159 | } 1160 | ] 1161 | }, 1162 | { 1163 | "provider_name": "Uttles", 1164 | "provider_url": "http:\/\/uttles.com", 1165 | "endpoints": [ 1166 | { 1167 | "schemes": [ 1168 | "http:\/\/uttles.com\/uttle\/*" 1169 | ], 1170 | "url": "http:\/\/uttles.com\/api\/reply\/oembed", 1171 | "discovery": true 1172 | } 1173 | ] 1174 | }, 1175 | { 1176 | "provider_name": "Verse", 1177 | "provider_url": "http:\/\/verse.media\/", 1178 | "endpoints": [ 1179 | { 1180 | "url": "http:\/\/verse.media\/services\/oembed\/" 1181 | } 1182 | ] 1183 | }, 1184 | { 1185 | "provider_name": "Viddler", 1186 | "provider_url": "http:\/\/www.viddler.com\/", 1187 | "endpoints": [ 1188 | { 1189 | "schemes": [ 1190 | "http:\/\/www.viddler.com\/v\/*" 1191 | ], 1192 | "url": "http:\/\/www.viddler.com\/oembed\/" 1193 | } 1194 | ] 1195 | }, 1196 | { 1197 | "provider_name": "VideoJug", 1198 | "provider_url": "http:\/\/www.videojug.com", 1199 | "endpoints": [ 1200 | { 1201 | "schemes": [ 1202 | "http:\/\/www.videojug.com\/film\/*", 1203 | "http:\/\/www.videojug.com\/interview\/*" 1204 | ], 1205 | "url": "http:\/\/www.videojug.com\/oembed.{format}" 1206 | } 1207 | ] 1208 | }, 1209 | { 1210 | "provider_name": "Vimeo", 1211 | "provider_url": "https:\/\/vimeo.com\/", 1212 | "endpoints": [ 1213 | { 1214 | "schemes": [ 1215 | "https:\/\/vimeo.com\/*", 1216 | "https:\/\/vimeo.com\/album\/*\/video\/*", 1217 | "https:\/\/vimeo.com\/channels\/*\/*", 1218 | "https:\/\/vimeo.com\/groups\/*\/videos\/*", 1219 | "https:\/\/vimeo.com\/ondemand\/*\/*", 1220 | "https:\/\/player.vimeo.com\/video\/*" 1221 | ], 1222 | "url": "https:\/\/vimeo.com\/api\/oembed.{format}", 1223 | "discovery": true 1224 | } 1225 | ] 1226 | }, 1227 | { 1228 | "provider_name": "Vine", 1229 | "provider_url": "https:\/\/vine.co\/", 1230 | "endpoints": [ 1231 | { 1232 | "schemes": [ 1233 | "http:\/\/vine.co\/v\/*", 1234 | "https:\/\/vine.co\/v\/*" 1235 | ], 1236 | "url": "https:\/\/vine.co\/oembed.json", 1237 | "discovery": true 1238 | } 1239 | ] 1240 | }, 1241 | { 1242 | "provider_name": "Wiredrive", 1243 | "provider_url": "https:\/\/www.wiredrive.com\/", 1244 | "endpoints": [ 1245 | { 1246 | "schemes": [ 1247 | "https:\/\/*.wiredrive.com\/*" 1248 | ], 1249 | "url": "http:\/\/*.wiredrive.com\/present-oembed\/", 1250 | "formats": [ 1251 | "json" 1252 | ], 1253 | "discovery": true 1254 | } 1255 | ] 1256 | }, 1257 | { 1258 | "provider_name": "WordPress.com", 1259 | "provider_url": "http:\/\/wordpress.com\/", 1260 | "endpoints": [ 1261 | { 1262 | "url": "http:\/\/public-api.wordpress.com\/oembed\/", 1263 | "discovery": true 1264 | } 1265 | ] 1266 | }, 1267 | { 1268 | "provider_name": "YFrog", 1269 | "provider_url": "http:\/\/yfrog.com\/", 1270 | "endpoints": [ 1271 | { 1272 | "schemes": [ 1273 | "http:\/\/*.yfrog.com\/*", 1274 | "http:\/\/yfrog.us\/*" 1275 | ], 1276 | "url": "http:\/\/www.yfrog.com\/api\/oembed", 1277 | "formats": [ 1278 | "json" 1279 | ] 1280 | } 1281 | ] 1282 | }, 1283 | { 1284 | "provider_name": "YouTube", 1285 | "provider_url": "http:\/\/www.youtube.com\/", 1286 | "endpoints": [ 1287 | { 1288 | "url": "http:\/\/www.youtube.com\/oembed", 1289 | "discovery": true 1290 | } 1291 | ] 1292 | } 1293 | ] -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | const Code = require('code'); 6 | const Lab = require('lab'); 7 | const Metaphor = require('..'); 8 | const Wreck = require('wreck'); 9 | const Providers = require('../providers.json'); 10 | 11 | 12 | // Declare internals 13 | 14 | const internals = {}; 15 | 16 | 17 | // Test shortcuts 18 | 19 | const lab = exports.lab = Lab.script(); 20 | const describe = lab.describe; 21 | const it = lab.it; 22 | const expect = Code.expect; 23 | 24 | 25 | describe('Metaphor', () => { 26 | 27 | describe('Engine', () => { 28 | 29 | describe('describe()', () => { 30 | 31 | it('describes a NY Times article', (done) => { 32 | 33 | const engine = new Metaphor.Engine({ css: '/embed.css', script: '/script.js', providers: Providers, redirect: 'https://example.com/redirect=' }); 34 | const resource = 'http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html?rref=collection%252Fnewseventcollection%252FPresidential+Election+2016&contentId=&mediaId=&referrer=http%3A%2F%2Fwww.nytimes.com%2F%3Faction%3Dclick%26contentCollection%3DPolitics%26region%3DTopBar%26module%3DHomePage-Button%26pgtype%3Darticle%26WT.z_jog%3D1%26hF%3Dt%26vS%3Dundefined&priority=true&action=click&contentCollection=Politics&module=Collection®ion=Marginalia&src=me&version=newsevent&pgtype=article'; 35 | engine.describe(resource, (description) => { 36 | 37 | expect(description).to.equal({ 38 | url: 'http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html', 39 | type: 'article', 40 | title: 'Rise of Donald Trump Tracks Growing Debate Over Global Fascism', 41 | description: 'Mr. Trump\u2019s campaign has engendered impassioned discussion about the nature of his appeal and warnings from critics on the left and the right.', 42 | image: { 43 | url: 'https://static01.nyt.com/images/2016/05/29/world/JP-FASCISM1/JP-FASCISM1-facebookJumbo.jpg', 44 | size: 122475 45 | }, 46 | sources: ['ogp', 'oembed', 'resource', 'twitter'], 47 | site_name: 'The New York Times', 48 | author: 'Peter Baker', 49 | icon: { 50 | any: 'https://static01.nyt.com/favicon.ico', 51 | smallest: 'https://static01.nyt.com/favicon.ico' 52 | }, 53 | thumbnail: { 54 | url: 'https://static01.nyt.com/images/2016/05/29/world/JP-FASCISM1/JP-FASCISM1-mediumThreeByTwo440.jpg', 55 | width: 440, 56 | height: 293, 57 | size: 34445 58 | }, 59 | embed: { 60 | type: 'rich', 61 | height: 550, 62 | width: 300, 63 | url: 'http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html', 64 | html: '' 65 | }, 66 | app: { 67 | googleplay: { 68 | name: 'NYTimes', 69 | id: 'com.nytimes.android', 70 | url: 'nytimes://reader/id/100000004437909' 71 | } 72 | }, 73 | twitter: { site_username: '@nytimes', creator_username: 'peterbakernyt' }, 74 | preview: 'Rise of Donald Trump Tracks Growing Debate Over Global Fascism
Mr. Trump\u2019s campaign has engendered impassioned discussion about the nature of his appeal and warnings from critics on the left and the right.
' 75 | }); 76 | 77 | done(); 78 | }); 79 | }); 80 | 81 | it('uses the providers list', (done) => { 82 | 83 | const engine = new Metaphor.Engine({ preview: false }); 84 | const resource = 'http://www.deviantart.com/art/Who-are-you-612604046'; 85 | engine.describe(resource, (description) => { 86 | 87 | expect(description).to.equal({ 88 | title: 'Who are you?', 89 | image: { 90 | url: 'http://orig15.deviantart.net/7150/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg', 91 | width: 675, 92 | height: 450 93 | }, 94 | url: 'http://maaira.deviantart.com/art/Who-are-you-612604046', 95 | description: 'Nova meets cows.', 96 | type: 'website', 97 | sources: ['ogp', 'oembed', 'resource', 'twitter'], 98 | site_name: 'DeviantArt', 99 | icon: { 100 | '48': 'http://st.deviantart.net/minish/touch-icons/android-48.png', 101 | '96': 'http://st.deviantart.net/minish/touch-icons/android-96.png', 102 | '144': 'http://st.deviantart.net/minish/touch-icons/android-144.png', 103 | '192': 'http://st.deviantart.net/minish/touch-icons/android-192.png', 104 | any: 'http://i.deviantart.net/icons/da_favicon.ico', 105 | smallest: 'http://st.deviantart.net/minish/touch-icons/android-48.png' 106 | }, 107 | thumbnail: { 108 | url: 'http://t01.deviantart.net/GRpFefgpAK8ZU15icNW3ZcgOrGE=/fit-in/300x900/filters:no_upscale():origin()/pre14/566b/th/pre/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg', 109 | width: 300, 110 | height: 200 111 | }, 112 | embed: { 113 | type: 'photo', 114 | height: 450, 115 | width: 675, 116 | url: 'http://orig15.deviantart.net/7150/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg' 117 | }, 118 | twitter: { site_username: '@deviantart' } 119 | }); 120 | 121 | done(); 122 | }); 123 | }); 124 | 125 | it('skips using a providers list', (done) => { 126 | 127 | const engine = new Metaphor.Engine({ providers: false, preview: false }); 128 | const resource = 'http://www.deviantart.com/art/Who-are-you-612604046'; 129 | engine.describe(resource, (description) => { 130 | 131 | expect(description).to.equal({ 132 | title: 'Who are you?', 133 | image: { 134 | url: 'http://orig15.deviantart.net/7150/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg', 135 | width: 675, 136 | height: 450 137 | }, 138 | url: 'http://maaira.deviantart.com/art/Who-are-you-612604046', 139 | description: 'Nova meets cows.', 140 | type: 'website', 141 | site_name: 'Deviantart', 142 | sources: ['ogp', 'resource', 'twitter'], 143 | icon: { 144 | '48': 'http://st.deviantart.net/minish/touch-icons/android-48.png', 145 | '96': 'http://st.deviantart.net/minish/touch-icons/android-96.png', 146 | '144': 'http://st.deviantart.net/minish/touch-icons/android-144.png', 147 | '192': 'http://st.deviantart.net/minish/touch-icons/android-192.png', 148 | any: 'http://i.deviantart.net/icons/da_favicon.ico', 149 | smallest: 'http://st.deviantart.net/minish/touch-icons/android-48.png' 150 | }, 151 | twitter: { site_username: '@deviantart' } 152 | }); 153 | 154 | done(); 155 | }); 156 | }); 157 | 158 | it('describes a whitelisted resource', (done) => { 159 | 160 | const engine = new Metaphor.Engine({ whitelist: ['https://twitter.com/*'], preview: false }); 161 | engine.describe('https://twitter.com/sideway/status/626158822705401856', (description) => { 162 | 163 | expect(description).to.equal({ 164 | type: 'article', 165 | url: 'https://twitter.com/sideway/status/626158822705401856', 166 | title: 'Sideway on Twitter', 167 | image: { url: 'https://pbs.twimg.com/profile_images/733727309962838016/t8DzeKUZ_400x400.jpg' }, 168 | description: '\u201cFirst steps https://t.co/XvSn7XSI2G\u201d', 169 | site_name: 'Twitter', 170 | sources: ['ogp', 'resource', 'oembed'], 171 | icon: { 172 | any: 'https://abs.twimg.com/favicons/favicon.ico', 173 | smallest: 'https://abs.twimg.com/favicons/favicon.ico' 174 | }, 175 | embed: { 176 | type: 'rich', 177 | width: 550, 178 | url: 'https://twitter.com/sideway/status/626158822705401856', 179 | html: '

First steps https://t.co/XvSn7XSI2G

— Sideway (@sideway) July 28, 2015
\n' 180 | } 181 | }); 182 | 183 | done(); 184 | }); 185 | }); 186 | 187 | it('block when non whitelisted', (done) => { 188 | 189 | const engine = new Metaphor.Engine({ whitelist: ['https://example.com/*'] }); 190 | const resource = 'http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html'; 191 | engine.describe(resource, (description) => { 192 | 193 | expect(description).to.equal({ 194 | type: 'website', 195 | site_name: 'Nytimes', 196 | url: 'http://www.nytimes.com/2016/05/29/world/europe/rise-of-donald-trump-tracks-growing-debate-over-global-fascism.html', 197 | preview: '
' 198 | }); 199 | 200 | done(); 201 | }); 202 | }); 203 | 204 | it('describes a tweet', (done) => { 205 | 206 | const engine = new Metaphor.Engine(); 207 | engine.describe('https://twitter.com/sideway/status/626158822705401856', (description) => { 208 | 209 | expect(description).to.equal({ 210 | type: 'article', 211 | url: 'https://twitter.com/sideway/status/626158822705401856', 212 | title: 'Sideway on Twitter', 213 | image: { 214 | url: 'https://pbs.twimg.com/profile_images/733727309962838016/t8DzeKUZ_400x400.jpg', 215 | size: 14664 216 | }, 217 | description: '\u201cFirst steps https://t.co/XvSn7XSI2G\u201d', 218 | site_name: 'Twitter', 219 | sources: ['ogp', 'resource', 'oembed'], 220 | icon: { 221 | any: 'https://abs.twimg.com/favicons/favicon.ico', 222 | smallest: 'https://abs.twimg.com/favicons/favicon.ico' 223 | }, 224 | embed: { 225 | type: 'rich', 226 | width: 550, 227 | url: 'https://twitter.com/sideway/status/626158822705401856', 228 | html: '

First steps https://t.co/XvSn7XSI2G

— Sideway (@sideway) July 28, 2015
\n' 229 | }, 230 | preview: 'Sideway on Twitter
\u201cFirst steps https://t.co/XvSn7XSI2G\u201d
' 231 | }); 232 | 233 | done(); 234 | }); 235 | }); 236 | 237 | it('describes a private tweet', (done) => { 238 | 239 | const engine = new Metaphor.Engine(); 240 | engine.describe('https://twitter.com/halfbee/status/683408044386959360', (description) => { 241 | 242 | expect(description).to.equal({ 243 | type: 'website', 244 | sources: ['resource'], 245 | url: 'https://twitter.com/halfbee/status/683408044386959360', 246 | description: 'The latest Tweets and replies from Half Bee (@halfbee). The unpublishable brain farts of @eranhammer', 247 | icon: { 248 | any: 'https://abs.twimg.com/favicons/favicon.ico', 249 | smallest: 'https://abs.twimg.com/favicons/favicon.ico' 250 | }, 251 | site_name: 'Twitter', 252 | preview: '
The latest Tweets and replies from Half Bee (@halfbee). The unpublishable brain farts of @eranhammer
' 253 | }); 254 | 255 | done(); 256 | }); 257 | }); 258 | 259 | it('describes a flickr photo', (done) => { 260 | 261 | const engine = new Metaphor.Engine({ maxWidth: 400, maxHeight: 200 }); 262 | engine.describe('https://www.flickr.com/photos/kent-macdonald/19455364653/', (description) => { 263 | 264 | expect(description).to.equal({ 265 | site_name: 'Flickr', 266 | updated_time: description.updated_time, 267 | title: '300/365 "The Lonely Gold Rush"', 268 | description: '27.07.15 So this is it, day 300. The real count down begins now I guess. Also found a pickaxe at my house moment before I even shot this. I seem to have strange and worrisome objects at my house. The first one I was looking for was a spear. And I\'m still in need of another deadly prop for this series. A lot has been said with very few words. Don\'t worry I\'m not a murderer. HOnestly I was searching for the spear first as I had a stronger concept, well it has a stronger meaning to it for me, bur alas I couldn\'t find it in time. I have seince then loaceted it after I\'ve shot this. But time was of the essence. In other news I\'m planning a new photographic series and have been doing some research and sketching. On the downside I don\'t think I\'ll be shooting any of them until this project is over.', 269 | type: 'photo', 270 | custom_type: 'flickr_photos:photo', 271 | url: 'https://www.flickr.com/photos/kent-macdonald/19455364653/', 272 | image: { 273 | url: 'https://c1.staticflickr.com/1/259/19455364653_201bdfd31b_b.jpg', 274 | size: 278195, 275 | width: 1024, 276 | height: 576 277 | }, 278 | thumbnail: { 279 | url: 'https://farm1.staticflickr.com/259/19455364653_201bdfd31b_q.jpg', 280 | size: 15476, 281 | width: 150, 282 | height: 150 283 | }, 284 | embed: { 285 | type: 'photo', 286 | size: 21677, 287 | height: 180, 288 | width: 320, 289 | url: 'https://farm1.staticflickr.com/259/19455364653_201bdfd31b_n.jpg', 290 | html: '300/365 "The Lonely Gold Rush"' 291 | }, 292 | app: { 293 | iphone: { 294 | name: 'Flickr', 295 | id: '328407587', 296 | url: 'flickr://flickr.com/photos/kent-macdonald/19455364653/' 297 | } 298 | }, 299 | twitter: { site_username: '@flickr' }, 300 | icon: { 301 | any: 'https://s.yimg.com/pw/images/icon_black_white.svg', 302 | smallest: 'https://s.yimg.com/pw/images/icon_black_white.svg' 303 | }, 304 | preview: '300/365 "The Lonely Gold Rush"
27.07.15 So this is it, day 300. The real count down begins now I guess. Also found a pickaxe at my house moment before I even shot this. I seem to have strange and worrisome objects at my house. The first one I was looking for was a spear. And I\'m still in need of another deadly prop for this series. A lot has been said with very few words. Don\'t worry I\'m not a murderer. HOnestly I was searching for the spear first as I had a stronger concept, well it has a stronger meaning to it for me, bur alas I couldn\'t find it in time. I have seince then loaceted it after I\'ve shot this. But time was of the essence. In other news I\'m planning a new photographic series and have been doing some research and sketching. On the downside I don\'t think I\'ll be shooting any of them until this project is over.
', 305 | sources: ['ogp', 'resource', 'oembed', 'twitter'] 306 | }); 307 | 308 | done(); 309 | }); 310 | }); 311 | 312 | it('describes an image', (done) => { 313 | 314 | const engine = new Metaphor.Engine(); 315 | engine.describe('https://www.sideway.com/sideway.png', (description) => { 316 | 317 | expect(description).to.equal({ 318 | url: 'https://www.sideway.com/sideway.png', 319 | type: 'website', 320 | site_name: 'Image', 321 | embed: { 322 | url: 'https://www.sideway.com/sideway.png', 323 | type: 'photo', 324 | size: 17014 325 | }, 326 | preview: '
', 327 | sources: ['resource'] 328 | }); 329 | 330 | done(); 331 | }); 332 | }); 333 | 334 | it('describes an image (max size)', (done) => { 335 | 336 | const engine = new Metaphor.Engine({ maxSize: 1024 }); 337 | engine.describe('https://www.sideway.com/sideway.png', (description) => { 338 | 339 | expect(description).to.equal({ 340 | url: 'https://www.sideway.com/sideway.png', 341 | type: 'website', 342 | site_name: 'Image', 343 | embed: { 344 | url: 'https://www.sideway.com/sideway.png', 345 | type: 'photo', 346 | size: 17014 347 | }, 348 | preview: '
', 349 | sources: ['resource'] 350 | }); 351 | 352 | done(); 353 | }); 354 | }); 355 | 356 | it('describes an image (large max size)', (done) => { 357 | 358 | const engine = new Metaphor.Engine({ maxSize: 18000 }); 359 | engine.describe('https://www.sideway.com/sideway.png', (description) => { 360 | 361 | expect(description).to.equal({ 362 | url: 'https://www.sideway.com/sideway.png', 363 | type: 'website', 364 | site_name: 'Image', 365 | embed: { 366 | url: 'https://www.sideway.com/sideway.png', 367 | type: 'photo', 368 | size: 17014 369 | }, 370 | preview: '
', 371 | sources: ['resource'] 372 | }); 373 | 374 | done(); 375 | }); 376 | }); 377 | 378 | it('describes an article', (done) => { 379 | 380 | const engine = new Metaphor.Engine(); 381 | engine.describe('http://www.wired.com/2016/05/google-doesnt-owe-oracle-cent-using-java-android-jury-finds/', (description) => { 382 | 383 | expect(description).to.equal({ 384 | type: 'article', 385 | title: 'Google Doesn\u2019t Owe Oracle a Cent for Using Java in Android, Jury Finds', 386 | image: { 387 | url: 'http://www.wired.com/wp-content/uploads/2016/05/android-1200x630-e1464301027666.jpg', 388 | width: 1200, 389 | height: 630, 390 | size: 19189 391 | }, 392 | description: 'The verdict could have major implications for the future of software developments.', 393 | locale: { primary: 'en_US' }, 394 | url: 'http://www.wired.com/2016/05/google-doesnt-owe-oracle-cent-using-java-android-jury-finds/', 395 | site_name: 'WIRED', 396 | sources: ['ogp', 'resource', 'oembed', 'twitter'], 397 | icon: { 398 | any: 'http://www.wired.com/wp-content/themes/Phoenix/assets/images/favicon.ico', 399 | smallest: 'http://www.wired.com/wp-content/themes/Phoenix/assets/images/favicon.ico' 400 | }, 401 | thumbnail: { 402 | url: 'http://www.wired.com/wp-content/uploads/2016/05/android.jpg', 403 | width: 600, 404 | height: 450, 405 | size: 424398 406 | }, 407 | embed: { 408 | type: 'rich', 409 | height: 338, 410 | width: 600, 411 | html: '
Google Doesn’t Owe Oracle a Cent for Using Java in Android, Jury Finds
\n' 412 | }, 413 | twitter: { site_username: '@wired', creator_username: '@wired' }, 414 | preview: 'Google Doesn\u2019t Owe Oracle a Cent for Using Java in Android, Jury Finds
The verdict could have major implications for the future of software developments.
' 415 | }); 416 | 417 | done(); 418 | }); 419 | }); 420 | 421 | it('described a YouTube video', (done) => { 422 | 423 | const engine = new Metaphor.Engine(); 424 | engine.describe('https://www.youtube.com/watch?v=cWDdd5KKhts', (description) => { 425 | 426 | expect(description).to.equal({ 427 | site_name: 'YouTube', 428 | url: 'https://www.youtube.com/watch?v=cWDdd5KKhts', 429 | title: 'Cheese Shop Sketch - Monty Python\'s Flying Circus', 430 | image: { 431 | url: 'https://i.ytimg.com/vi/cWDdd5KKhts/maxresdefault.jpg', 432 | size: 106445 433 | }, 434 | description: 'Subscribe to the Official Monty Python Channel here - http://smarturl.it/SubscribeToPython Cleese plays an erudite customer attempting to purchase some chees...', 435 | type: 'video', 436 | video: [ 437 | { 438 | url: 'https://www.youtube.com/embed/cWDdd5KKhts', 439 | type: 'text/html', 440 | width: 480, 441 | height: 360 442 | }, 443 | { 444 | url: 'https://www.youtube.com/v/cWDdd5KKhts?version=3&autohide=1', 445 | type: 'application/x-shockwave-flash', 446 | width: 480, 447 | height: 360, 448 | tag: ['Monty Python', 'Python (Monty) Pictures Limited', 'Comedy', 'flying circus', 'monty pythons flying circus', 'john cleese', 'micael palin', 'eric idle', 'terry jones', 'graham chapman', 'terry gilliam', 'funny', 'comedy', 'animation', '60s animation', 'humor', 'humour', 'sketch show', 'british comedy', 'cheese shop', 'monty python cheese', 'cheese shop sketch', 'cleese cheese', 'cheese'] 449 | } 450 | ], 451 | sources: ['ogp', 'resource', 'oembed', 'twitter'], 452 | icon: { 453 | '32': 'https://s.ytimg.com/yts/img/favicon_32-vfl8NGn4k.png', 454 | '48': 'https://s.ytimg.com/yts/img/favicon_48-vfl1s0rGh.png', 455 | '96': 'https://s.ytimg.com/yts/img/favicon_96-vfldSA3ca.png', 456 | '144': 'https://s.ytimg.com/yts/img/favicon_144-vflWmzoXw.png', 457 | any: 'https://s.ytimg.com/yts/img/favicon-vflz7uhzw.ico', 458 | smallest: 'https://s.ytimg.com/yts/img/favicon_32-vfl8NGn4k.png' 459 | }, 460 | thumbnail: { 461 | url: 'https://i.ytimg.com/vi/cWDdd5KKhts/hqdefault.jpg', 462 | width: 480, 463 | height: 360, 464 | size: 30519 465 | }, 466 | embed: { 467 | type: 'video', 468 | height: 344, 469 | width: 459, 470 | html: '' 471 | }, 472 | app: { 473 | iphone: { 474 | name: 'YouTube', 475 | id: '544007664', 476 | url: 'vnd.youtube://www.youtube.com/watch?v=cWDdd5KKhts&feature=applinks' 477 | }, 478 | ipad: { 479 | name: 'YouTube', 480 | id: '544007664', 481 | url: 'vnd.youtube://www.youtube.com/watch?v=cWDdd5KKhts&feature=applinks' 482 | }, 483 | googleplay: { 484 | name: 'YouTube', 485 | id: 'com.google.android.youtube', 486 | url: 'https://www.youtube.com/watch?v=cWDdd5KKhts' 487 | } 488 | }, 489 | player: { 490 | url: 'https://www.youtube.com/embed/cWDdd5KKhts', 491 | width: 480, 492 | height: 360 493 | }, 494 | twitter: { site_username: '@youtube' }, 495 | preview: 'Cheese Shop Sketch - Monty Python\'s Flying Circus
Subscribe to the Official Monty Python Channel here - http://smarturl.it/SubscribeToPython Cleese plays an erudite customer attempting to purchase some chees...
' 496 | }); 497 | 498 | done(); 499 | }); 500 | }); 501 | 502 | it('describes a resource with redirection and no cookies', { parallel: false }, (done, onCleanup) => { 503 | 504 | const orig = Wreck.request; 505 | onCleanup((next) => { 506 | 507 | Wreck.request = orig; 508 | return next(); 509 | }); 510 | 511 | Wreck.request = (method, url, options, next) => { 512 | 513 | setImmediate(() => { 514 | 515 | options.beforeRedirect('GET', 301, 'http://example.com/something', {}, {}, () => next(null, { statusCode: 301, headers: {} })); 516 | }); 517 | 518 | return { abort: () => null }; 519 | }; 520 | 521 | const engine = new Metaphor.Engine({ preview: false }); 522 | engine.describe('http://example.com/something', (description) => { 523 | 524 | expect(description).to.equal({ type: 'website', url: 'http://example.com/something', site_name: 'Example' }); 525 | 526 | done(); 527 | }); 528 | }); 529 | 530 | it('uses non-discovery oembed when resource request fails', { parallel: false }, (done) => { 531 | 532 | const orig = Wreck.request; 533 | Wreck.request = (method, url, options, next) => { 534 | 535 | Wreck.request = orig; 536 | setImmediate(() => next(new Error('failed'))); 537 | return { abort: () => null }; 538 | }; 539 | 540 | const engine = new Metaphor.Engine({ preview: false }); 541 | const resource = 'http://www.deviantart.com/art/Who-are-you-612604046'; 542 | engine.describe(resource, (description) => { 543 | 544 | expect(description).to.equal({ 545 | type: 'website', 546 | url: 'http://www.deviantart.com/art/Who-are-you-612604046', 547 | site_name: 'DeviantArt', 548 | thumbnail: { 549 | url: 'http://t01.deviantart.net/GRpFefgpAK8ZU15icNW3ZcgOrGE=/fit-in/300x900/filters:no_upscale():origin()/pre14/566b/th/pre/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg', 550 | width: 300, 551 | height: 200 552 | }, 553 | embed: { 554 | type: 'photo', 555 | height: 450, 556 | width: 675, 557 | url: 'http://orig15.deviantart.net/7150/f/2016/153/4/0/img_2814_kopie_2_by_maaira-da4q8b2.jpg' 558 | }, 559 | sources: ['oembed'] 560 | }); 561 | 562 | done(); 563 | }); 564 | }); 565 | 566 | it('skips non-discovery oembed when resource request fails', { parallel: false }, (done) => { 567 | 568 | const orig = Wreck.request; 569 | Wreck.request = (method, url, options, next) => { 570 | 571 | Wreck.request = orig; 572 | setImmediate(() => next(new Error('failed'))); 573 | return { abort: () => null }; 574 | }; 575 | 576 | const engine = new Metaphor.Engine({ preview: false, providers: false }); 577 | const resource = 'http://www.deviantart.com/art/Who-are-you-612604046'; 578 | engine.describe(resource, (description) => { 579 | 580 | expect(description).to.equal({ 581 | type: 'website', 582 | url: 'http://www.deviantart.com/art/Who-are-you-612604046', 583 | site_name: 'Deviantart' 584 | }); 585 | 586 | done(); 587 | }); 588 | }); 589 | 590 | it('overrides preview function', (done) => { 591 | 592 | const engine = new Metaphor.Engine({ preview: (description, options, next) => next('yay!') }); 593 | engine.describe('https://twitter.com/sideway/status/1', (description) => { 594 | 595 | expect(description).to.equal({ 596 | type: 'website', 597 | url: 'https://twitter.com/sideway/status/1', 598 | preview: 'yay!', 599 | site_name: 'Twitter' 600 | }); 601 | 602 | done(); 603 | }); 604 | }); 605 | 606 | it('overrides preview function (empty preview)', (done) => { 607 | 608 | const engine = new Metaphor.Engine({ preview: (description, options, next) => next() }); 609 | engine.describe('https://twitter.com/sideway/status/1', (description) => { 610 | 611 | expect(description).to.equal({ 612 | type: 'website', 613 | url: 'https://twitter.com/sideway/status/1', 614 | site_name: 'Twitter' 615 | }); 616 | 617 | done(); 618 | }); 619 | }); 620 | 621 | it('handles missing document', (done) => { 622 | 623 | const engine = new Metaphor.Engine(); 624 | engine.describe('https://twitter.com/sideway/status/1', (description) => { 625 | 626 | expect(description).to.equal({ 627 | type: 'website', 628 | url: 'https://twitter.com/sideway/status/1', 629 | site_name: 'Twitter', 630 | preview: '
' 631 | }); 632 | 633 | done(); 634 | }); 635 | }); 636 | 637 | it('handles invalid domain', (done) => { 638 | 639 | const engine = new Metaphor.Engine({ preview: false }); 640 | engine.describe('http://no_such_domain/1', (description) => { 641 | 642 | expect(description).to.equal({ type: 'website', url: 'http://no_such_domain/1', site_name: 'no_such_domain' }); 643 | done(); 644 | }); 645 | }); 646 | 647 | it('handles unknown content type', { parallel: false }, (done) => { 648 | 649 | const orig = Wreck.request; 650 | Wreck.request = (method, url, options, next) => { 651 | 652 | Wreck.request = orig; 653 | setImmediate(() => next(null, { statusCode: 200, headers: { 'content-type': 'x/y' } })); 654 | return { abort: () => { } }; 655 | }; 656 | 657 | const engine = new Metaphor.Engine({ preview: false }); 658 | engine.describe('https://example.com/invalid', (description) => { 659 | 660 | expect(description).to.equal({ type: 'website', url: 'https://example.com/invalid', site_name: 'Example' }); 661 | done(); 662 | }); 663 | }); 664 | 665 | it('handles missing content-length', { parallel: false }, (done) => { 666 | 667 | const orig = Wreck.request; 668 | Wreck.request = (method, url, options, next) => { 669 | 670 | Wreck.request = orig; 671 | return Wreck.request(method, url, options, (err, res) => { 672 | 673 | delete res.headers['content-length']; 674 | return next(err, res); 675 | }); 676 | }; 677 | 678 | const engine = new Metaphor.Engine({ preview: false }); 679 | engine.describe('https://www.sideway.com/sideway.png', (description) => { 680 | 681 | expect(description).to.equal({ 682 | url: 'https://www.sideway.com/sideway.png', 683 | type: 'website', 684 | site_name: 'Image', 685 | embed: { 686 | url: 'https://www.sideway.com/sideway.png', 687 | type: 'photo' 688 | }, 689 | sources: ['resource'] 690 | }); 691 | 692 | done(); 693 | }); 694 | }); 695 | 696 | it('handles missing content-type', { parallel: false }, (done) => { 697 | 698 | const orig = Wreck.request; 699 | Wreck.request = (method, url, options, next) => { 700 | 701 | Wreck.request = orig; 702 | setImmediate(() => next(null, { statusCode: 200, headers: {} })); 703 | return { abort: () => { } }; 704 | }; 705 | 706 | const engine = new Metaphor.Engine({ preview: false }); 707 | engine.describe('https://example.com/invalid', (description) => { 708 | 709 | expect(description).to.equal({ type: 'website', url: 'https://example.com/invalid', site_name: 'Example' }); 710 | done(); 711 | }); 712 | }); 713 | 714 | it('handles on invalid content-type', { parallel: false }, (done) => { 715 | 716 | const orig = Wreck.request; 717 | Wreck.request = (method, url, options, next) => { 718 | 719 | Wreck.request = orig; 720 | setImmediate(() => next(null, { statusCode: 200, headers: { 'content-type': 'x' } })); 721 | return { abort: () => { } }; 722 | }; 723 | 724 | const engine = new Metaphor.Engine({ preview: false }); 725 | engine.describe('https://example.com/invalid', (description) => { 726 | 727 | expect(description).to.equal({ type: 'website', url: 'https://example.com/invalid', site_name: 'Example' }); 728 | done(); 729 | }); 730 | }); 731 | 732 | it('handles on invalid response object', { parallel: false }, (done) => { 733 | 734 | const origRequest = Wreck.request; 735 | Wreck.request = (method, url, options, next) => { 736 | 737 | Wreck.request = origRequest; 738 | setImmediate(() => next(null, { statusCode: 200, headers: { 'content-type': 'text/html' } })); 739 | return { abort: () => { } }; 740 | }; 741 | 742 | const origRead = Wreck.read; 743 | Wreck.read = (res, options, next) => { 744 | 745 | Wreck.read = origRead; 746 | return next(new Error('Invalid')); 747 | }; 748 | 749 | const engine = new Metaphor.Engine({ preview: false }); 750 | engine.describe('https://example.com/invalid', (description) => { 751 | 752 | expect(description).to.equal({ type: 'website', url: 'https://example.com/invalid', site_name: 'Example' }); 753 | done(); 754 | }); 755 | }); 756 | 757 | it('handles failed image size request', { parallel: false }, (done, onCleanup) => { 758 | 759 | const orig = Wreck.request; 760 | onCleanup((next) => { 761 | 762 | Wreck.request = orig; 763 | return next(); 764 | }); 765 | 766 | Wreck.request = (method, url, options, next) => { 767 | 768 | if (method === 'HEAD') { 769 | return next(new Error('failed')); 770 | } 771 | 772 | return orig.call(Wreck, method, url, options, next); 773 | }; 774 | 775 | const engine = new Metaphor.Engine({ maxSize: 1024 * 1024 }); 776 | engine.describe('https://twitter.com/sideway/status/626158822705401856', (description) => { 777 | 778 | expect(description).to.equal({ 779 | url: 'https://twitter.com/sideway/status/626158822705401856', 780 | type: 'article', 781 | title: 'Sideway on Twitter', 782 | image: { 783 | url: 'https://pbs.twimg.com/profile_images/733727309962838016/t8DzeKUZ_400x400.jpg' 784 | }, 785 | description: '\u201cFirst steps https://t.co/XvSn7XSI2G\u201d', 786 | site_name: 'Twitter', 787 | icon: { 788 | any: 'https://abs.twimg.com/favicons/favicon.ico', 789 | smallest: 'https://abs.twimg.com/favicons/favicon.ico' 790 | }, 791 | embed: { 792 | url: 'https://twitter.com/sideway/status/626158822705401856', 793 | html: '

First steps https://t.co/XvSn7XSI2G

— Sideway (@sideway) July 28, 2015
\n', 794 | width: 550, 795 | type: 'rich' 796 | }, 797 | sources: ['ogp', 'resource', 'oembed'], 798 | preview: 'Sideway on Twitter
\u201cFirst steps https://t.co/XvSn7XSI2G\u201d
' 799 | }); 800 | 801 | done(); 802 | }); 803 | }); 804 | 805 | it('handles failed image size request (missing length)', { parallel: false }, (done, onCleanup) => { 806 | 807 | const orig = Wreck.request; 808 | onCleanup((next) => { 809 | 810 | Wreck.request = orig; 811 | return next(); 812 | }); 813 | 814 | Wreck.request = (method, url, options, next) => { 815 | 816 | if (method === 'HEAD') { 817 | return orig.call(Wreck, method, url, options, (err, res) => { 818 | 819 | delete res.headers['content-length']; 820 | return next(err, res); 821 | }); 822 | } 823 | 824 | return orig.call(Wreck, method, url, options, next); 825 | }; 826 | 827 | const engine = new Metaphor.Engine({ maxSize: 1024 * 1024 }); 828 | engine.describe('https://twitter.com/sideway/status/626158822705401856', (description) => { 829 | 830 | expect(description).to.equal({ 831 | url: 'https://twitter.com/sideway/status/626158822705401856', 832 | type: 'article', 833 | title: 'Sideway on Twitter', 834 | image: { 835 | url: 'https://pbs.twimg.com/profile_images/733727309962838016/t8DzeKUZ_400x400.jpg' 836 | }, 837 | description: '\u201cFirst steps https://t.co/XvSn7XSI2G\u201d', 838 | site_name: 'Twitter', 839 | icon: { 840 | any: 'https://abs.twimg.com/favicons/favicon.ico', 841 | smallest: 'https://abs.twimg.com/favicons/favicon.ico' 842 | }, 843 | embed: { 844 | url: 'https://twitter.com/sideway/status/626158822705401856', 845 | html: '

First steps https://t.co/XvSn7XSI2G

— Sideway (@sideway) July 28, 2015
\n', 846 | width: 550, 847 | type: 'rich' 848 | }, 849 | sources: ['ogp', 'resource', 'oembed'], 850 | preview: 'Sideway on Twitter
\u201cFirst steps https://t.co/XvSn7XSI2G\u201d
' 851 | }); 852 | 853 | done(); 854 | }); 855 | }); 856 | }); 857 | }); 858 | 859 | describe('parse()', () => { 860 | 861 | it('uses oembed site_name if og is missing', (done) => { 862 | 863 | const html = ` 864 | 865 | 866 | 867 | `; 868 | 869 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 870 | 871 | expect(description).to.equal({ 872 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 873 | type: 'website', 874 | site_name: 'Twitter', 875 | embed: { 876 | type: 'rich', 877 | width: 550, 878 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 879 | html: '

Maybe agile doesn't scale and that's ok https://t.co/DwrWCnCU38

— Dion Almaer (@dalmaer) May 1, 2016
\n' 880 | }, 881 | sources: ['oembed'] 882 | }); 883 | 884 | done(); 885 | }); 886 | }); 887 | 888 | it('uses oembed link url if og is missing', { parallel: false }, (done) => { 889 | 890 | const html = ` 891 | 892 | 893 | 894 | `; 895 | 896 | const oembed = { 897 | type: 'link', 898 | version: '1.0', 899 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 900 | provider_name: 'Twitter' 901 | }; 902 | 903 | const orig = Wreck.get; 904 | Wreck.get = (url, options, next) => { 905 | 906 | Wreck.get = orig; 907 | next(null, { statusCode: 200 }, JSON.stringify(oembed)); 908 | }; 909 | 910 | Metaphor.parse(html, 'https://twitter.com/dalmaer/status/726624422237364226', {}, (description) => { 911 | 912 | expect(description).to.equal({ 913 | url: 'https://twitter.com/dalmaer/status/726624422237364226', 914 | type: 'website', 915 | site_name: 'Twitter', 916 | sources: ['oembed'] 917 | }); 918 | 919 | done(); 920 | }); 921 | }); 922 | }); 923 | }); 924 | --------------------------------------------------------------------------------