├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── tsconfig.json ├── vercel.json ├── LICENSE ├── package.json ├── api ├── index.ts └── utils.ts └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: xiaoluoboding -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "semi": false, 6 | "trailingComma": "none", 7 | "printWidth": 80 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "CommonJS", 5 | "strict": true, 6 | "esModuleInterop": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": [{ 3 | "source": "/(.*)", 4 | "headers": [{ 5 | "key": "Cache-Control", 6 | "value": "public, s-maxage=86400, max-age=60, stale-while-revalidate" 7 | }, 8 | { 9 | "key": "Access-Control-Allow-Origin", 10 | "value": "*" 11 | } 12 | ] 13 | }] 14 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yunwei Xiao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "metafy-svg", 3 | "version": "0.1.2", 4 | "description": "Easily crawl a website's metadata and generate SVG as a service.", 5 | "scripts": { 6 | "dev": "esno api/index.ts" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/xiaoluoboding/metafy-svg.git" 11 | }, 12 | "keywords": [ 13 | "meta" 14 | ], 15 | "author": "xiaoluoboding ", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/xiaoluoboding/metafy-svg/issues" 19 | }, 20 | "homepage": "https://github.com/xiaoluoboding/metafy-svg#readme", 21 | "dependencies": { 22 | "got": "11.8.2", 23 | "html-escaper": "^3.0.3", 24 | "metascraper": "^5.31.1", 25 | "metascraper-author": "^5.31.1", 26 | "metascraper-clearbit": "^5.31.1", 27 | "metascraper-description": "^5.31.1", 28 | "metascraper-image": "^5.31.1", 29 | "metascraper-logo": "^5.31.1", 30 | "metascraper-logo-favicon": "^5.31.1", 31 | "metascraper-title": "^5.31.1" 32 | }, 33 | "devDependencies": { 34 | "@types/html-escaper": "^3.0.0", 35 | "@vercel/node": "^1.15.4", 36 | "esno": "^0.16.3", 37 | "typescript": "^4.8.3" 38 | } 39 | } -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import { VercelRequest, VercelResponse } from '@vercel/node' 2 | import got from 'got' 3 | 4 | import { transformSVG } from './utils' 5 | 6 | type AnyRecord = Record 7 | 8 | const metascraper = require('metascraper')([ 9 | require('metascraper-author')(), 10 | require('metascraper-clearbit')(), 11 | require('metascraper-description')(), 12 | require('metascraper-image')(), 13 | require('metascraper-logo')(), 14 | require('metascraper-logo-favicon')(), 15 | require('metascraper-title')() 16 | ]) 17 | 18 | const scrapeMetaData = async ( 19 | targetUrl: string = 'https://github.com/one-tab-group/vercel-metafy' 20 | ) => { 21 | const { body: html, url } = await got(targetUrl) 22 | const metadata = await metascraper({ html, url }) 23 | return metadata 24 | } 25 | 26 | export default async function handler(req: VercelRequest, res: VercelResponse) { 27 | const { url, mode, fromColor, viaColor, toColor, style } = 28 | req.query as AnyRecord 29 | 30 | try { 31 | require('url').parse(url) 32 | } catch (err) { 33 | res.status(400) 34 | res.json({ error: 'Invalid URL' }) 35 | return 36 | } 37 | 38 | const metadata = await scrapeMetaData(url) 39 | 40 | res.setHeader('Content-Type', 'image/svg+xml') 41 | res.setHeader('Cache-Control', 's-maxage=86400') 42 | 43 | const svgRaw = await transformSVG({ 44 | url, 45 | mode, 46 | metadata, 47 | fromColor, 48 | viaColor, 49 | toColor, 50 | style 51 | }) 52 | 53 | res.send(svgRaw) 54 | } 55 | 56 | // for dev 57 | // ;(async () => { 58 | // const metadata = { 59 | // author: 'one-tab-group', 60 | // logo: 'https://logo.clearbit.com/github.com', 61 | // publisher: 'GitHub', 62 | // description: 63 | // 'Easily scrape metadata from websites as a service using Vercel. & GitHub - one-tab-group/vercel-metafy: Easily scrape metadata from websites as a service using Vercel.', 64 | // image: 65 | // 'https://opengraph.githubassets.com/741b8799a320724ae859087c853045d6c25ce563ccaf8526eb3f5f83ca156e5c/one-tab-group/vercel-metafy', 66 | // title: 67 | // 'GitHub - one-tab-group/vercel-metafy: Easily scrape metadata from websites as a service using Vercel.' 68 | // } 69 | // // const res = await scrapeMetaData() 70 | // const res = await transformSVG({ 71 | // mode: 'light', 72 | // metadata, 73 | // fromColor: 'f4a', 74 | // toColor: '4fa', 75 | // style: 'horizontal' 76 | // }) 77 | // console.log(res) 78 | // })() 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Metafy SVG 2 | 3 | ![metafy-svg](https://vercelbadge.vercel.app/api/xiaoluoboding/metafy-svg) 4 | 5 | > Easily crawl a website's metadata and generate SVG as a service. 6 | 7 | ## Render In README.md 8 | 9 | You can render the bookmark.style card as a SVG in the GitHub README.md now. 10 | 11 | ### Code 12 | 13 | ```markdown 14 | [![onetab.group](https://svg.bookmark.style/api?url=https://onetab.group&mode=light)](https://onetab.group) 15 | ``` 16 | 17 | ### Preview 18 | 19 | | Card Style | Light Mode | Dark Mode | 20 | | :----------: | :------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------: | 21 | | Twitter Like | ![](https://svg.bookmark.style/api?url=https://bookmark.style&mode=light) | ![](https://svg.bookmark.style/api?url=https://bookmark.style&mode=dark) | 22 | | Notion Like | ![](https://svg.bookmark.style/api?url=https://tech-stack.tools&mode=light&style=horizontal) | ![](https://svg.bookmark.style/api?url=https://tech-stack.tools&mode=dark&style=horizontal) | 23 | 24 | ### Customize the gradient color 25 | 26 | The hex color in the URL can not contain the `#` character, so you can write like this `#000000` to `000000` 27 | 28 | #### Type 29 | 30 | ```ts 31 | type GradidentColor = { 32 | formColor: string 33 | viaColor?: string 34 | toColor: string 35 | } 36 | ``` 37 | 38 | #### URL 39 | 40 | ```bash 41 | curl https://svg.bookmark.style/api?url=$URL&formColor=$FROM_COLOR&viaColor=$VIA_COLOR&toColor=$TO_COLOR 42 | ``` 43 | 44 | #### Demo without `viaColor` 45 | 46 | ```bash 47 | https://svg.bookmark.style/api?url=https://onetab.group&mode=light&fromColor=fa4&toColor=a4f 48 | ``` 49 | 50 | ![](https://svg.bookmark.style/api?url=https://onetab.group&mode=light&fromColor=fa4&toColor=a4f&style=horizontal) 51 | 52 | #### Demo with `viaColor` 53 | 54 | ```bash 55 | https://svg.bookmark.style/api?url=https://onetab.group&mode=light&fromColor=fa4&viaColor=4af&toColor=a4f 56 | ``` 57 | 58 | ![](https://svg.bookmark.style/api?url=https://onetab.group&mode=light&fromColor=fa4&viaColor=4af&toColor=a4f&style=horizontal) 59 | 60 | ## Usage 61 | 62 | Enter a valid `$URL` as params 63 | 64 | ```bash 65 | curl https://svg.bookmark.style/api?url=$URL 66 | ``` 67 | 68 | ## Types 69 | 70 | ```ts 71 | type Params = { 72 | url: string 73 | mode?: 'light' | 'dark' 74 | style?: 'vertical' | 'horizontal' 75 | formColor?: string 76 | viaColor?: string 77 | toColor?: string 78 | } 79 | ``` 80 | 81 | ## Example 82 | 83 | ### Input 84 | 85 | ```bash 86 | curl https://svg.bookmark.style/api?url=https://onetab.group&mode=light 87 | ``` 88 | 89 | ### Output 90 | 91 | [![onetab.group](https://svg.bookmark.style/api?url=https://onetab.group&mode=light)](https://onetab.group) 92 | 93 | ## Deploy your own instance 94 | 95 | Deploy your `Metafy` on your own instance with one-click. 96 | 97 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fxiaoluoboding%2Fmetafy-svg) 98 | 99 | ## Tech Stack 100 | 101 | - [vercel](https://vercel.com/) - Develop. Preview. Ship. For the best frontend teams. 102 | - [metascraper](https://metascraper.js.org/) - metascraper, easily scrape metadata from an article on the web. 103 | - [typescript](https://www.typescriptlang.org/) - Typed JavaScript at Any Scale. 104 | - [got](https://github.com/sindresorhus/got) - 🌐 Human-friendly and powerful HTTP request library for Node.js 105 | - [html-escaper](https://github.com/WebReflection/html-escaper) - A simple module to escape/unescape common problematic entities. 106 | - [esno](https://github.com/antfu/esno) - TypeScript / ESNext node runtime powered by esbuild 107 | 108 | ## License 109 | 110 | MIT [xiaoluoboding](https://github.com/xiaoluoboding) 111 | -------------------------------------------------------------------------------- /api/utils.ts: -------------------------------------------------------------------------------- 1 | import got from 'got' 2 | import { escape } from 'html-escaper' 3 | 4 | const [defaultFrom, defaultVia, defaultTo] = ['#6EE7B7', '#3B82F6', '#7C3AED'] 5 | 6 | const stylishTwitterLikeCard = (payload: Record) => { 7 | const { 8 | url, 9 | className, 10 | escapedTitle, 11 | escapedDesc, 12 | escapedAuthor, 13 | escapedPublisher, 14 | linearGradientStop, 15 | logoBase64, 16 | imageBase64 17 | } = payload 18 | 19 | return ` 20 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |

83 | ${escapedTitle} 84 |

85 | 86 |
87 | 88 | 89 | 90 | 91 |

92 | ${escapedDesc} 93 |

94 | 95 |
96 | 97 | 98 | 99 | 100 |

101 | ${escapedAuthor || escapedPublisher || url} 102 |

103 | 104 |
105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | ${linearGradientStop} 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 |
156 | ` 157 | } 158 | 159 | const stylishNotionLikeCard = (payload: Record) => { 160 | const { 161 | url, 162 | className, 163 | escapedTitle, 164 | escapedDesc, 165 | escapedAuthor, 166 | escapedPublisher, 167 | linearGradientStop, 168 | logoBase64, 169 | imageBase64 170 | } = payload 171 | 172 | return ` 173 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |

236 | ${escapedTitle} 237 |

238 | 239 |
240 | 241 | 242 | 243 | 244 |

245 | ${escapedDesc} 246 |

247 | 248 |
249 | 250 | 251 | 252 | 253 |

254 | ${escapedAuthor || escapedPublisher || url} 255 |

256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | ${linearGradientStop} 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 |
301 | ` 302 | } 303 | 304 | export const transformSVG = async (options: Record) => { 305 | const { 306 | url, 307 | mode, 308 | style = 'vertical', 309 | metadata, 310 | fromColor, 311 | viaColor, 312 | toColor 313 | } = options 314 | 315 | const className = mode === 'dark' ? 'dark' : 'light' 316 | 317 | const logoReq = got(metadata.logo) 318 | const imageReq = got(metadata.image) 319 | 320 | const [logoRes, logoBuffer, imageRes, imageBuffer] = await Promise.all([ 321 | logoReq, 322 | logoReq.buffer(), 323 | imageReq, 324 | imageReq.buffer() 325 | ]) 326 | const logoBase64 = `data:${ 327 | logoRes.headers['content-type'] 328 | };base64,${logoBuffer.toString('base64')}` 329 | const imageBase64 = `data:${ 330 | imageRes.headers['content-type'] 331 | };base64,${imageBuffer.toString('base64')}` 332 | 333 | const escapedTitle = metadata.title ? escape(metadata.title) : '' 334 | const escapedDesc = metadata.description ? escape(metadata.description) : '' 335 | const escapedAuthor = metadata.author ? escape(metadata.author) : '' 336 | const escapedPublisher = metadata.publisher ? escape(metadata.publisher) : '' 337 | 338 | const gradientFrom = fromColor ? `#${fromColor}` : defaultFrom 339 | const gradientVia = viaColor ? `#${viaColor}` : defaultVia 340 | const gradientTo = toColor ? `#${toColor}` : defaultTo 341 | 342 | const linearGradientStop = viaColor 343 | ? ` 344 | 345 | ` 346 | : ` 347 | ` 348 | 349 | const payload = { 350 | url, 351 | className, 352 | escapedTitle, 353 | escapedDesc, 354 | escapedAuthor, 355 | escapedPublisher, 356 | linearGradientStop, 357 | logoBase64, 358 | imageBase64 359 | } 360 | 361 | return style === 'vertical' 362 | ? stylishTwitterLikeCard(payload) 363 | : stylishNotionLikeCard(payload) 364 | } 365 | --------------------------------------------------------------------------------