Repository: privatenumber/snap-tweet Branch: develop Commit: 3374517d3262 Files: 18 Total size: 22.6 KB Directory structure: gitextract_0e6brk6r/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── BUG_REPORT.md │ │ └── FEATURE_REQUEST.md │ ├── renovate.json │ └── workflows/ │ ├── package-size-report.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── bin/ │ └── snap-tweet.js ├── package.json ├── src/ │ ├── cdp-utils.ts │ ├── cli.ts │ ├── render-task-runner.tsx │ └── tweet-camera.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = tab end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .github/ISSUE_TEMPLATE/BUG_REPORT.md ================================================ --- name: Bug report about: Create a report to help us improve labels: 'bug: pending triage' --- ## Bug description ## Reproduction ## Environment - snap-tweet version: - Operating System: - Node version: - Package manager (npm/yarn/pnpm) and version: ================================================ FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md ================================================ --- name: Feature request about: Suggest an idea for this project labels: 'feature request' --- ## Is your feature request related to a problem? ## Describe the solution you'd like ## Describe alternatives you've considered ## Additional context ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>privatenumber/renovate-config" ] } ================================================ FILE: .github/workflows/package-size-report.yml ================================================ name: Package Size Report on: pull_request: branches: [ master, develop ] jobs: pkg-size-report: name: Package Size Report runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Package size report id: pkg-size-report uses: privatenumber/pkg-size-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: branches: master jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: node-version: 14.x - name: Install dependencies run: npx ci - name: Build run: npm run build - name: Lint run: npm run lint - name: Test run: npm run test --if-present - name: Release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release ================================================ FILE: .github/workflows/test.yml ================================================ name: Test on: push: branches: [develop] pull_request: branches: [master, develop] jobs: test: name: Test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Use Node.js uses: actions/setup-node@v2 with: node-version-file: '.nvmrc' - name: Install dependencies run: npx ci - name: Build run: npm run build - name: Lint run: npm run lint ================================================ FILE: .gitignore ================================================ # macOS .DS_Store # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Dependency directories node_modules/ # Output of 'npm pack' *.tgz # dotenv environment variables file .env .env.test # VSCode .vscode # Distribution dist ================================================ FILE: .nvmrc ================================================ v16.17.0 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Hiroki Osame Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # 📸 snap-tweet Command-line tool to capture clean and simple tweet snapshots.


Light mode


Dark mode

### Features - 🎛 Adjustable width - 💅 Rounded corners & transparent background - 🌚 Dark mode - 🌐 Customizable locale - 🙅‍♀️ No "Share" & "Info" buttons - 💖 No watermark - 🔥 Snap multiple tweets at once Support this project by ⭐️ starring and sharing it. [Follow me](https://github.com/privatenumber) to see what other cool projects I'm working on! ❤️ ## 🚀 Install The only requirement is to have [Google Chrome Browser](https://www.google.com/chrome/). ```sh npm i -g snap-tweet ``` ### npx Use [npx](https://nodejs.dev/learn/the-npx-nodejs-package-runner) to run without installation. ```sh npx snap-tweet ``` ## 🚦 Quick usage ### Basic usage By default, the tweet snap is opened in your default image viewer so you can decide whether to save or not. ```sh snap-tweet https://twitter.com/jack/status/20 ``` ### Save to directory Save the tweet snap to a specified directory using the `--output-dir` flag. ```sh snap-tweet https://twitter.com/jack/status/20 --output-dir ~/Desktop ``` ### Dark mode Snap a tweet in dark mode using the `--dark-mode` flag. ```sh snap-tweet https://twitter.com/jack/status/20 --dark-mode ``` ### Custom width Pass in a custom width for the tweet using the `--width` flag. ```sh snap-tweet https://twitter.com/github/status/1390807474748416006 --width 900 ```


Tweet with a 900px width

### Localization Pass in a [different locale](https://developer.twitter.com/en/docs/twitter-for-websites/supported-languages) using the `--locale` flag. ```sh snap-tweet https://twitter.com/TwitterJP/status/578707432 --locale ja ```


Using the Japanese locale (ja)

### Show Thread Use the `--show-thread` flag to include the parent tweet in the screenshot. ```sh snap-tweet https://twitter.com/jack/status/1108487919969275904 --show-thread ```


Parent tweet inlcuded in the screenshot

### Multiple tweets Snap multiple tweets at once by passing in multiple tweet URLs. ```sh snap-tweet https://twitter.com/naval/status/1002103497725173760 https://twitter.com/naval/status/1002103559276478464 https://twitter.com/naval/status/1002103627387813888 ``` ### Manual ``` snap-tweet Usage: $ snap-tweet <...tweet urls> Options: -o, --output-dir Tweet screenshot output directory -w, --width Width of tweet (default: 550) -t, --show-thread Show tweet thread -d, --dark-mode Show tweet in dark mode -l, --locale Locale (default: en) -h, --help Display this message -v, --version Display version number ``` ## 🏋️‍♀️ Motivation It all started when I simply wanted to embed a couple tweets into a Google Doc... Quick googling showed that there's no way to embed an actual tweet because Google Docs doesn't support HTML iframes or JavaScript. And I wasn't going to install a plugin just for some tweets. I figured I could just take a screenshot of the tweet. But only to realize I would be spending way too much time cropping each tweet, and they still wouldn't be perfect because of the lack of transparency behind the rounded corners. And not to mention, the static screenshot would include buttons like "Copy link to Tweet" that looked actionable but actually weren't. I found services like [Screenshot Guru](https://screenshot.guru) (and their [Twitter Screenshots](https://chrome.google.com/webstore/detail/twitter-screenshots/imfhndkgmnbnogfjcecdpopaooachgco) Chrome extension), [Pikaso](https://pikaso.me/), etc. but none of them met my needs (low quality images, actionable buttons present, watermarks, etc.). All I wanted to do was to embed the tweet like how it looks in the [official embedder](https://publish.twitter.com/#) into a static environment. No sign up, no watermark, no BS... It shouldn't be this hard! 🤯 So of course, I spent a few hours developing a tool to save us all the headache 😇 _(I know, this is some pretty crazy [yak shaving](https://en.wiktionary.org/wiki/yak_shaving). Checkout [my other projects](https://github.com/privatenumber) to see how deep I've gone.)_ ## 🙋‍♀️ Need help? If you have a question about usage, [ask on Discussions](https://github.com/privatenumber/snap-tweet/discussions). If you'd like to make a feature request or file a bug report, [open an Issue](https://github.com/privatenumber/snap-tweet/issues). ================================================ FILE: bin/snap-tweet.js ================================================ #!/usr/bin/env node require('../dist/cli.js'); ================================================ FILE: package.json ================================================ { "name": "snap-tweet", "version": "0.0.0-semantic-release", "description": "Snap a screenshot of a tweet", "keywords": [ "twitter", "tweet", "snap", "snapshot", "screenshot" ], "license": "MIT", "repository": "privatenumber/snap-tweet", "funding": "https://github.com/privatenumber/snap-tweet?sponsor=1", "author": { "name": "Hiroki Osame", "email": "hiroki.osame@gmail.com" }, "files": [ "bin/snap-tweet.js", "dist" ], "main": "dist/tweet-camera.js", "bin": "bin/snap-tweet.js", "scripts": { "build": "rm -rf dist && tsup src --dts --minify --external '../package.json' --external 'yoga-layout-prebuilt'", "dev": "tsx src/cli.ts", "lint": "eslint ." }, "dependencies": { "yoga-layout-prebuilt": "1.10.0" }, "devDependencies": { "@pvtnbr/eslint-config": "^0.30.0", "@types/react": "^17.0.39", "chrome-launcher": "^0.15.0", "chrome-remote-interface": "^0.31.2", "cleye": "^1.1.0", "eslint": "^8.22.0", "exit-hook": "^3.0.0", "ink": "^3.2.0", "ink-task-list": "^1.1.0", "open": "^8.4.0", "p-retry": "^5.0.0", "react": "^17.0.2", "tempy": "^2.0.0", "tsup": "^5.11.13", "tsx": "^3.7.1", "typescript": "^4.5.5", "unused-filename": "^4.0.0" }, "eslintConfig": { "extends": "@pvtnbr" } } ================================================ FILE: src/cdp-utils.ts ================================================ import pRetry from 'p-retry'; export const waitForNetworkIdle = ( Network, waitFor: number, ): Promise => new Promise((resolve) => { const trackRequests = new Set(); let resolvingTimeout = setTimeout(resolve, waitFor); Network.requestWillBeSent(({ requestId }) => { trackRequests.add(requestId); clearTimeout(resolvingTimeout); }); Network.loadingFinished(({ requestId }) => { trackRequests.delete(requestId); if (trackRequests.size === 0) { resolvingTimeout = setTimeout(resolve, waitFor); } }); }); const sleep = (ms: number): Promise => new Promise((resolve) => { setTimeout(resolve, ms); }); export const querySelector = async ( DOM, contextNodeId: number, selector: string, ) => await pRetry( async () => { const { nodeId } = await DOM.querySelector({ nodeId: contextNodeId, selector, }); if (nodeId === 0) { throw new Error(`Selector "${selector}" not found`); } return nodeId as number; }, { retries: 3, onFailedAttempt: async () => await sleep(100), }, ); export const xpath = async ( DOM, query: string, ) => { const { searchId, resultCount } = await DOM.performSearch({ query }); const { nodeIds } = await DOM.getSearchResults({ searchId, fromIndex: 0, toIndex: resultCount, }); return nodeIds as number[]; }; export const hideNode = async ( DOM, nodeId: number, ) => { await DOM.setAttributeValue({ nodeId, name: 'style', value: 'visibility: hidden', }); }; export const screenshotNode = async ( Page, DOM, nodeId: number, ) => { try { const { model } = await DOM.getBoxModel({ nodeId }); const screenshot = await Page.captureScreenshot({ clip: { x: 0, y: 0, width: model.width, height: model.height, scale: 1, }, }); return Buffer.from(screenshot.data, 'base64'); } catch (error) { console.log(error); throw new Error('Failed to take a snapshot'); } }; ================================================ FILE: src/cli.ts ================================================ import fs from 'fs'; import path from 'path'; import { unusedFilename } from 'unused-filename'; import tempy from 'tempy'; import open from 'open'; import { cli } from 'cleye'; import renderTaskRunner from './render-task-runner'; import TweetCamera from './tweet-camera'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require('../package.json'); const argv = cli({ name: 'snap-tweet', version, parameters: [''], flags: { outputDir: { type: String, alias: 'o', description: 'Tweet screenshot output directory', placeholder: '', }, width: { type: Number, alias: 'w', description: 'Width of tweet', default: 550, placeholder: '', }, showThread: { type: Boolean, alias: 't', description: 'Show tweet thread', }, darkMode: { type: Boolean, alias: 'd', description: 'Show tweet in dark mode', }, locale: { type: String, description: 'Locale', default: 'en', placeholder: '', }, }, help: { examples: [ '# Snapshot a tweet', 'snap-tweet https://twitter.com/jack/status/20', '', '# Snapshot a tweet with Japanese locale', 'snap-tweet https://twitter.com/TwitterJP/status/578707432 --locale ja', '', '# Snapshot a tweet with dark mode and 900px width', 'snap-tweet https://twitter.com/Interior/status/463440424141459456 --width 900 --dark-mode', ], }, }); (async () => { const options = argv.flags; const tweets = argv._.tweetUrls .map( tweetUrl => ({ ...TweetCamera.parseTweetUrl(tweetUrl), tweetUrl, }), ) .filter( // Deduplicate (tweet, index, allTweets) => { const index2 = allTweets.findIndex(t => t.tweetId === tweet.tweetId); return index === index2; }, ); const tweetCamera = new TweetCamera(); const startTask = renderTaskRunner(); await Promise.all(tweets.map(async ({ tweetId, username, tweetUrl, }) => { const task = startTask(`📷 Snapping tweet #${tweetId} by @${username}`); try { const snapshot = await tweetCamera.snapTweet(tweetId, options); const recommendedFileName = TweetCamera.getRecommendedFileName( username, tweetId, options, ); const fileName = `snap-tweet-${recommendedFileName}`; if (options.outputDir) { const filePath = await unusedFilename(path.resolve(options.outputDir, fileName)); await fs.promises.writeFile(filePath, snapshot); task.success(`📸 Tweet #${tweetId} by @${username} saved to ${filePath}`); } else { const filePath = tempy.file({ name: fileName, }); await fs.promises.writeFile(filePath, snapshot); open(filePath); task.success(`📸 Snapped tweet #${tweetId} by @${username}`); } } catch (error) { task.error(`${error.message}: ${tweetUrl}`); } })); await tweetCamera.close(); })().catch((error) => { if (error.code === 'ERR_LAUNCHER_NOT_INSTALLED') { console.log( '[snap-tweet] Error: Chrome could not be automatically found! Manually pass in the Chrome binary path with the CHROME_PATH environment variable: CHROME_PATH=/path/to/chrome npx snap-tweet ...', ); } else { console.log('[snap-tweet] Error:', error.message); } process.exit(1); }); ================================================ FILE: src/render-task-runner.tsx ================================================ // eslint-disable-line unicorn/filename-case import React, { ComponentProps, useReducer, FC } from 'react'; import { render } from 'ink'; import { TaskList, Task } from 'ink-task-list'; interface Task { label: string; state: ComponentProps['state']; } const CliSnapTweet: FC<{ items: Task[]; }> = ({ items }) => ( { items.map((item, index) => ( )) } ); const reducer = (state: Task[], task: 'task-updated' | Task) => { if (task === 'task-updated') { return state.slice(); } return [...state, task]; }; function renderTaskRunner() { let items; let dispatchAction; render(React.createElement(() => { [items, dispatchAction] = useReducer(reducer, []); return React.createElement(CliSnapTweet, { items }); })); return function addTask(label: string) { const task = { label, state: 'loading', }; dispatchAction(task); return { success(message) { task.label = message; task.state = 'success'; dispatchAction('task-updated'); }, error(message) { task.label = message; task.state = 'error'; dispatchAction('task-updated'); }, }; }; } export default renderTaskRunner; ================================================ FILE: src/tweet-camera.ts ================================================ import assert from 'assert'; import { launch, LaunchedChrome } from 'chrome-launcher'; import CDP from 'chrome-remote-interface'; import exitHook from 'exit-hook'; import { querySelector, waitForNetworkIdle, hideNode, screenshotNode, } from './cdp-utils'; interface Options { width?: number; darkMode?: boolean; showThread?: boolean; locale?: string; } const getEmbeddableTweetUrl = (tweetId: string, options: Options) => { const embeddableTweetUrl = new URL('https://platform.twitter.com/embed/Tweet.html'); const searchParameters = { id: tweetId, theme: options.darkMode ? 'dark' : 'light', hideThread: options.showThread ? 'false' : 'true', lang: options.locale ?? 'en', // Not sure what these do but pass them in anyway (Reference: https://publish.twitter.com/) embedId: 'twitter-widget-0', features: 'eyJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2hvcml6b25fdHdlZXRfZW1iZWRfOTU1NSI6eyJidWNrZXQiOiJodGUiLCJ2ZXJzaW9uIjpudWxsfX0=', frame: 'false', hideCard: 'false', sessionId: '4ee57c34a8bc3f4118cee97a9904f889f35e29b4', widgetsVersion: '82e1070:1619632193066', }; // eslint-disable-next-line guard-for-in for (const key in searchParameters) { embeddableTweetUrl.searchParams.set(key, searchParameters[key]); } return embeddableTweetUrl.toString(); }; const waitForTweetLoad = Network => new Promise((resolve, reject) => { Network.responseReceived(({ type, response }) => { if ( type === 'XHR' && response.url.startsWith('https://cdn.syndication.twimg.com/tweet') ) { if (response.status === 200) { return resolve(); } if (response.status === 404) { return reject(new Error('Tweet not found')); } reject(new Error(`Failed to fetch tweet: ${response.status}`)); } }); }); class TweetCamera { chrome: LaunchedChrome; initializingChrome: Promise; constructor() { this.initializingChrome = this.initializeChrome(); } async initializeChrome() { const chrome = await launch({ chromeFlags: [ '--headless', '--disable-gpu', ], }); exitHook(() => { chrome.kill(); }); this.chrome = chrome; const browserClient = await CDP({ port: chrome.port, }); return browserClient; } static parseTweetUrl(tweetUrl: string) { assert(tweetUrl, 'Tweet URL must be passed in'); const [, username, tweetId] = tweetUrl.match(/(?:twitter|x)\.com\/(\w{1,15})\/status\/(\d+)/) ?? []; assert( username && tweetId, `Invalid Tweet URL: ${tweetUrl}`, ); return { username, tweetId, }; } static getRecommendedFileName( username: string, tweetId: string, options: Options = {}, ) { const nameComponents = [username, tweetId]; if (options.width !== 550) { nameComponents.push(`w${options.width}`); } if (options.showThread) { nameComponents.push('thread'); } if (options.darkMode) { nameComponents.push('dark'); } if (options.locale !== 'en') { nameComponents.push(options.locale); } return `${nameComponents.join('-')}.png`; } async snapTweet( tweetId: string, options: Options = {}, ) { const browserClient = await this.initializingChrome; const { targetId } = await browserClient.Target.createTarget({ url: getEmbeddableTweetUrl(tweetId, options), width: options.width ?? 550, height: 1000, }); const client = await CDP({ port: this.chrome.port, target: targetId, }); await client.Network.enable(); await waitForTweetLoad(client.Network); await waitForNetworkIdle(client.Network, 200); const { root } = await client.DOM.getDocument(); const tweetContainerNodeId = await querySelector(client.DOM, root.nodeId, '#app > div > div > div:last-child'); // "Copy link to Tweet" button const hideCopyLinkButtonNodeId = await querySelector(client.DOM, tweetContainerNodeId, '[role="button"]').catch(() => null); await Promise.all([ // "Copy link to Tweet" button (hideCopyLinkButtonNodeId && hideNode(client.DOM, hideCopyLinkButtonNodeId)), // Info button - can't use aria-label because of i18n hideNode( client.DOM, await querySelector( client.DOM, tweetContainerNodeId, 'a[href$="twitter-for-websites-ads-info-and-privacy"]', ), ), // Remove the "Read 10K replies" button client.DOM.removeNode({ nodeId: await querySelector( client.DOM, tweetContainerNodeId, '.css-1dbjc4n.r-kzbkwu.r-1h8ys4a', ), }), // Unset max-width to fill window width client.DOM.setAttributeValue({ nodeId: tweetContainerNodeId, name: 'style', value: 'max-width: unset', }), // Set transparent bg for screenshot client.Emulation.setDefaultBackgroundColorOverride({ color: { r: 0, g: 0, b: 0, a: 0, }, }), ]); // If the width is larger than default, a larger image might get requested if (options.width > 550) { await waitForNetworkIdle(client.Network, 200); } // Screenshot only the tweet const snapshot = await screenshotNode(client.Page, client.DOM, tweetContainerNodeId); client.Target.closeTarget({ targetId, }); return snapshot; } async close() { const browserClient = await this.initializingChrome; await browserClient.close(); await this.chrome.kill(); } } export default TweetCamera; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "moduleResolution": "node", "isolatedModules": true, "esModuleInterop": true, "outDir": "dist", "jsx": "react", // Node 12 "module": "commonjs", "target": "ES2019" }, "include": [ "src" ] }