[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/BUG_REPORT.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\nlabels: 'bug: pending triage'\n---\n\n## Bug description\n<!--\n  What did you do? (Provide code in next section)\n\n  What did you expect to happen?\n\n  What happened instead?\n\n  Do you have an error stack-trace?\n-->\n\n## Reproduction\n<!--\n  Provide one of the following:\n  1. A code-snippet that reproduces the issue\n  2. A reproduction repo that reproduces the issue\n  3. A PR with a failing test-case\n\n  Remove irrelevant code to make it easier for others to read and debug.\n\n  -- Why?\n  The goal is to maximize communication efficiency.\n\n  When an issue is immediately reproducible, others can start debugging instead of following-up with questions.\n-->\n\n## Environment\n\n- snap-tweet version:\n- Operating System:\n- Node version:\n- Package manager (npm/yarn/pnpm) and version:\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\nlabels: 'feature request'\n---\n\n## Is your feature request related to a problem?\n<!--\n  What's the motivation behind this issue?\n\n  Eg. I'm frustrated when...\n-->\n\n## Describe the solution you'd like\n<!--\n  What kind of solution would you like to see?\n\n  What makes it a good solution?\n-->\n\n## Describe alternatives you've considered\n<!--\n  What else did you try?\n\n  Do you have a work around?\n-->\n\n## Additional context\n<!--\n  Anything else to share?\n\n  Screenshots? Links?\n-->\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n\t\"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n\t\"extends\": [\n\t\t\"github>privatenumber/renovate-config\"\n\t]\n}\n"
  },
  {
    "path": ".github/workflows/package-size-report.yml",
    "content": "name: Package Size Report\n\non:\n  pull_request:\n    branches: [ master, develop ]\n\njobs:\n  pkg-size-report:\n    name: Package Size Report\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v2\n\n      - name: Package size report\n        id: pkg-size-report\n        uses: privatenumber/pkg-size-action@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    branches: master\n\njobs:\n  release:\n    name: Release\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v2\n    - name: Use Node.js ${{ matrix.node-version }}\n      uses: actions/setup-node@v1\n      with:\n        node-version: 14.x\n    - name: Install dependencies\n      run: npx ci\n    - name: Build\n      run: npm run build\n    - name: Lint\n      run: npm run lint\n    - name: Test\n      run: npm run test --if-present\n    - name: Release\n      env:\n        GH_TOKEN: ${{ secrets.GH_TOKEN }}\n        NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n      run: npx semantic-release\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  push:\n    branches: [develop]\n  pull_request:\n    branches: [master, develop]\n\njobs:\n  test:\n    name: Test\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v2\n\n    - name: Use Node.js\n      uses: actions/setup-node@v2\n      with:\n        node-version-file: '.nvmrc'\n\n    - name: Install dependencies\n      run: npx ci\n\n    - name: Build\n      run: npm run build\n\n    - name: Lint\n      run: npm run lint\n"
  },
  {
    "path": ".gitignore",
    "content": "# macOS\n.DS_Store\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Dependency directories\nnode_modules/\n\n# Output of 'npm pack'\n*.tgz\n\n# dotenv environment variables file\n.env\n.env.test\n\n# VSCode\n.vscode\n\n# Distribution\ndist\n"
  },
  {
    "path": ".nvmrc",
    "content": "v16.17.0\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) Hiroki Osame <hiroki.osame@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# 📸 snap-tweet <a href=\"https://npm.im/snap-tweet\"><img src=\"https://badgen.net/npm/v/snap-tweet\"></a> <a href=\"https://packagephobia.now.sh/result?p=snap-tweet\"><img src=\"https://packagephobia.now.sh/badge?p=snap-tweet\"></a>\n\nCommand-line tool to capture clean and simple tweet snapshots.\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/jack/status/20\">\n    <img src=\".github/example.png\" width=\"60%\">\n  </a>\n  <br>\n  <em>Light mode</em>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/jack/status/20\">\n    <img src=\".github/example-dark.png\" width=\"60%\">\n  </a>\n  <br>\n  <em>Dark mode</em>\n</p>\n\n### Features\n- 🎛 Adjustable width\n- 💅 Rounded corners & transparent background\n- 🌚 Dark mode\n- 🌐 Customizable locale\n- 🙅‍♀️ No \"Share\" & \"Info\" buttons\n- 💖 No watermark\n- 🔥 Snap multiple tweets at once\n\n<sub>Support this project by ⭐️ starring and sharing it. [Follow me](https://github.com/privatenumber) to see what other cool projects I'm working on! ❤️</sub>\n\n## 🚀 Install\nThe only requirement is to have [Google Chrome Browser](https://www.google.com/chrome/).\n\n\n```sh\nnpm i -g snap-tweet\n```\n\n### npx\nUse [npx](https://nodejs.dev/learn/the-npx-nodejs-package-runner) to run without installation.\n```sh\nnpx snap-tweet\n```\n\n## 🚦 Quick usage\n### Basic usage\nBy default, the tweet snap is opened in your default image viewer so you can decide whether to save or not.\n```sh\nsnap-tweet https://twitter.com/jack/status/20\n```\n\n### Save to directory\nSave the tweet snap to a specified directory using the `--output-dir` flag.\n```sh\nsnap-tweet https://twitter.com/jack/status/20 --output-dir ~/Desktop\n```\n\n### Dark mode\nSnap a tweet in dark mode using the `--dark-mode` flag.\n```sh\nsnap-tweet https://twitter.com/jack/status/20 --dark-mode\n```\n\n### Custom width\nPass in a custom width for the tweet using the `--width` flag.\n```sh\nsnap-tweet https://twitter.com/github/status/1390807474748416006 --width 900\n```\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/github/status/1390807474748416006\">\n    <img src=\".github/example-width-900.png\" width=\"50%\">\n  </a>\n  <br>\n  <em>Tweet with a 900px width</em>\n</p>\n\n### Localization\nPass in a [different locale](https://developer.twitter.com/en/docs/twitter-for-websites/supported-languages) using the `--locale` flag.\n```sh\nsnap-tweet https://twitter.com/TwitterJP/status/578707432 --locale ja\n```\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/TwitterJP/status/578707432\">\n    <img src=\".github/example-locale-ja.png\" width=\"50%\">\n  </a>\n  <br>\n  <em>Using the Japanese locale (ja)</em>\n</p>\n\n### Show Thread\nUse the `--show-thread` flag to include the parent tweet in the screenshot.\n\n```sh\nsnap-tweet https://twitter.com/jack/status/1108487919969275904 --show-thread\n```\n\n<p align=\"center\">\n  <a href=\"https://twitter.com/jack/status/1108487919969275904\">\n    <img src=\".github/example-thread.png\" width=\"50%\">\n  </a>\n  <br>\n  <em>Parent tweet inlcuded in the screenshot</em>\n</p>\n\n### Multiple tweets\nSnap multiple tweets at once by passing in multiple tweet URLs.\n```sh\nsnap-tweet https://twitter.com/naval/status/1002103497725173760 https://twitter.com/naval/status/1002103559276478464 https://twitter.com/naval/status/1002103627387813888\n```\n\n### Manual\n```\nsnap-tweet\n\nUsage:\n  $ snap-tweet <...tweet urls>\n\nOptions:\n  -o, --output-dir <path>  Tweet screenshot output directory\n  -w, --width <width>      Width of tweet (default: 550)\n  -t, --show-thread        Show tweet thread\n  -d, --dark-mode          Show tweet in dark mode\n  -l, --locale <locale>    Locale (default: en)\n  -h, --help               Display this message\n  -v, --version            Display version number\n```\n\n## 🏋️‍♀️ Motivation\nIt all started when I simply wanted to embed a couple tweets into a Google Doc...\n\nQuick 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.\n\n\nI 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.\n\nI 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.).\n\nAll 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! 🤯\n\nSo of course, I spent a few hours developing a tool to save us all the headache 😇\n\n_(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.)_\n\n## 🙋‍♀️ Need help?\nIf you have a question about usage, [ask on Discussions](https://github.com/privatenumber/snap-tweet/discussions).\n\nIf you'd like to make a feature request or file a bug report, [open an Issue](https://github.com/privatenumber/snap-tweet/issues).\n"
  },
  {
    "path": "bin/snap-tweet.js",
    "content": "#!/usr/bin/env node\nrequire('../dist/cli.js');\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"snap-tweet\",\n\t\"version\": \"0.0.0-semantic-release\",\n\t\"description\": \"Snap a screenshot of a tweet\",\n\t\"keywords\": [\n\t\t\"twitter\",\n\t\t\"tweet\",\n\t\t\"snap\",\n\t\t\"snapshot\",\n\t\t\"screenshot\"\n\t],\n\t\"license\": \"MIT\",\n\t\"repository\": \"privatenumber/snap-tweet\",\n\t\"funding\": \"https://github.com/privatenumber/snap-tweet?sponsor=1\",\n\t\"author\": {\n\t\t\"name\": \"Hiroki Osame\",\n\t\t\"email\": \"hiroki.osame@gmail.com\"\n\t},\n\t\"files\": [\n\t\t\"bin/snap-tweet.js\",\n\t\t\"dist\"\n\t],\n\t\"main\": \"dist/tweet-camera.js\",\n\t\"bin\": \"bin/snap-tweet.js\",\n\t\"scripts\": {\n\t\t\"build\": \"rm -rf dist && tsup src --dts --minify --external '../package.json' --external 'yoga-layout-prebuilt'\",\n\t\t\"dev\": \"tsx src/cli.ts\",\n\t\t\"lint\": \"eslint .\"\n\t},\n\t\"dependencies\": {\n\t\t\"yoga-layout-prebuilt\": \"1.10.0\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@pvtnbr/eslint-config\": \"^0.30.0\",\n\t\t\"@types/react\": \"^17.0.39\",\n\t\t\"chrome-launcher\": \"^0.15.0\",\n\t\t\"chrome-remote-interface\": \"^0.31.2\",\n\t\t\"cleye\": \"^1.1.0\",\n\t\t\"eslint\": \"^8.22.0\",\n\t\t\"exit-hook\": \"^3.0.0\",\n\t\t\"ink\": \"^3.2.0\",\n\t\t\"ink-task-list\": \"^1.1.0\",\n\t\t\"open\": \"^8.4.0\",\n\t\t\"p-retry\": \"^5.0.0\",\n\t\t\"react\": \"^17.0.2\",\n\t\t\"tempy\": \"^2.0.0\",\n\t\t\"tsup\": \"^5.11.13\",\n\t\t\"tsx\": \"^3.7.1\",\n\t\t\"typescript\": \"^4.5.5\",\n\t\t\"unused-filename\": \"^4.0.0\"\n\t},\n\t\"eslintConfig\": {\n\t\t\"extends\": \"@pvtnbr\"\n\t}\n}\n"
  },
  {
    "path": "src/cdp-utils.ts",
    "content": "import pRetry from 'p-retry';\n\nexport const waitForNetworkIdle = (\n\tNetwork,\n\twaitFor: number,\n): Promise<void> => new Promise((resolve) => {\n\tconst trackRequests = new Set();\n\tlet resolvingTimeout = setTimeout(resolve, waitFor);\n\n\tNetwork.requestWillBeSent(({ requestId }) => {\n\t\ttrackRequests.add(requestId);\n\t\tclearTimeout(resolvingTimeout);\n\t});\n\n\tNetwork.loadingFinished(({ requestId }) => {\n\t\ttrackRequests.delete(requestId);\n\t\tif (trackRequests.size === 0) {\n\t\t\tresolvingTimeout = setTimeout(resolve, waitFor);\n\t\t}\n\t});\n});\n\nconst sleep = (ms: number): Promise<void> => new Promise((resolve) => {\n\tsetTimeout(resolve, ms);\n});\n\nexport const querySelector = async (\n\tDOM,\n\tcontextNodeId: number,\n\tselector: string,\n) => await pRetry(\n\tasync () => {\n\t\tconst { nodeId } = await DOM.querySelector({\n\t\t\tnodeId: contextNodeId,\n\t\t\tselector,\n\t\t});\n\n\t\tif (nodeId === 0) {\n\t\t\tthrow new Error(`Selector \"${selector}\" not found`);\n\t\t}\n\n\t\treturn nodeId as number;\n\t},\n\t{\n\t\tretries: 3,\n\t\tonFailedAttempt: async () => await sleep(100),\n\t},\n);\n\nexport const xpath = async (\n\tDOM,\n\tquery: string,\n) => {\n\tconst { searchId, resultCount } = await DOM.performSearch({ query });\n\tconst { nodeIds } = await DOM.getSearchResults({\n\t\tsearchId,\n\t\tfromIndex: 0,\n\t\ttoIndex: resultCount,\n\t});\n\n\treturn nodeIds as number[];\n};\n\nexport const hideNode = async (\n\tDOM,\n\tnodeId: number,\n) => {\n\tawait DOM.setAttributeValue({\n\t\tnodeId,\n\t\tname: 'style',\n\t\tvalue: 'visibility: hidden',\n\t});\n};\n\nexport const screenshotNode = async (\n\tPage,\n\tDOM,\n\tnodeId: number,\n) => {\n\ttry {\n\t\tconst { model } = await DOM.getBoxModel({ nodeId });\n\t\tconst screenshot = await Page.captureScreenshot({\n\t\t\tclip: {\n\t\t\t\tx: 0,\n\t\t\t\ty: 0,\n\t\t\t\twidth: model.width,\n\t\t\t\theight: model.height,\n\t\t\t\tscale: 1,\n\t\t\t},\n\t\t});\n\n\t\treturn Buffer.from(screenshot.data, 'base64');\n\t} catch (error) {\n\t\tconsole.log(error);\n\t\tthrow new Error('Failed to take a snapshot');\n\t}\n};\n"
  },
  {
    "path": "src/cli.ts",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport { unusedFilename } from 'unused-filename';\nimport tempy from 'tempy';\nimport open from 'open';\nimport { cli } from 'cleye';\nimport renderTaskRunner from './render-task-runner';\nimport TweetCamera from './tweet-camera';\n\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst { version } = require('../package.json');\n\nconst argv = cli({\n\tname: 'snap-tweet',\n\n\tversion,\n\n\tparameters: ['<tweet urls...>'],\n\n\tflags: {\n\t\toutputDir: {\n\t\t\ttype: String,\n\t\t\talias: 'o',\n\t\t\tdescription: 'Tweet screenshot output directory',\n\t\t\tplaceholder: '<path>',\n\t\t},\n\t\twidth: {\n\t\t\ttype: Number,\n\t\t\talias: 'w',\n\t\t\tdescription: 'Width of tweet',\n\t\t\tdefault: 550,\n\t\t\tplaceholder: '<width>',\n\t\t},\n\t\tshowThread: {\n\t\t\ttype: Boolean,\n\t\t\talias: 't',\n\t\t\tdescription: 'Show tweet thread',\n\t\t},\n\t\tdarkMode: {\n\t\t\ttype: Boolean,\n\t\t\talias: 'd',\n\t\t\tdescription: 'Show tweet in dark mode',\n\t\t},\n\t\tlocale: {\n\t\t\ttype: String,\n\t\t\tdescription: 'Locale',\n\t\t\tdefault: 'en',\n\t\t\tplaceholder: '<locale>',\n\t\t},\n\t},\n\n\thelp: {\n\t\texamples: [\n\t\t\t'# Snapshot a tweet',\n\t\t\t'snap-tweet https://twitter.com/jack/status/20',\n\t\t\t'',\n\t\t\t'# Snapshot a tweet with Japanese locale',\n\t\t\t'snap-tweet https://twitter.com/TwitterJP/status/578707432 --locale ja',\n\t\t\t'',\n\t\t\t'# Snapshot a tweet with dark mode and 900px width',\n\t\t\t'snap-tweet https://twitter.com/Interior/status/463440424141459456 --width 900 --dark-mode',\n\t\t],\n\t},\n});\n\n(async () => {\n\tconst options = argv.flags;\n\tconst tweets = argv._.tweetUrls\n\t\t.map(\n\t\t\ttweetUrl => ({\n\t\t\t\t...TweetCamera.parseTweetUrl(tweetUrl),\n\t\t\t\ttweetUrl,\n\t\t\t}),\n\t\t)\n\t\t.filter(\n\t\t\t// Deduplicate\n\t\t\t(tweet, index, allTweets) => {\n\t\t\t\tconst index2 = allTweets.findIndex(t => t.tweetId === tweet.tweetId);\n\t\t\t\treturn index === index2;\n\t\t\t},\n\t\t);\n\n\tconst tweetCamera = new TweetCamera();\n\tconst startTask = renderTaskRunner();\n\n\tawait Promise.all(tweets.map(async ({\n\t\ttweetId,\n\t\tusername,\n\t\ttweetUrl,\n\t}) => {\n\t\tconst task = startTask(`📷 Snapping tweet #${tweetId} by @${username}`);\n\n\t\ttry {\n\t\t\tconst snapshot = await tweetCamera.snapTweet(tweetId, options);\n\t\t\tconst recommendedFileName = TweetCamera.getRecommendedFileName(\n\t\t\t\tusername,\n\t\t\t\ttweetId,\n\t\t\t\toptions,\n\t\t\t);\n\t\t\tconst fileName = `snap-tweet-${recommendedFileName}`;\n\n\t\t\tif (options.outputDir) {\n\t\t\t\tconst filePath = await unusedFilename(path.resolve(options.outputDir, fileName));\n\t\t\t\tawait fs.promises.writeFile(filePath, snapshot);\n\n\t\t\t\ttask.success(`📸 Tweet #${tweetId} by @${username} saved to ${filePath}`);\n\t\t\t} else {\n\t\t\t\tconst filePath = tempy.file({\n\t\t\t\t\tname: fileName,\n\t\t\t\t});\n\t\t\t\tawait fs.promises.writeFile(filePath, snapshot);\n\t\t\t\topen(filePath);\n\n\t\t\t\ttask.success(`📸 Snapped tweet #${tweetId} by @${username}`);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\ttask.error(`${error.message}: ${tweetUrl}`);\n\t\t}\n\t}));\n\n\tawait tweetCamera.close();\n})().catch((error) => {\n\tif (error.code === 'ERR_LAUNCHER_NOT_INSTALLED') {\n\t\tconsole.log(\n\t\t\t'[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 ...',\n\t\t);\n\t} else {\n\t\tconsole.log('[snap-tweet] Error:', error.message);\n\t}\n\tprocess.exit(1);\n});\n"
  },
  {
    "path": "src/render-task-runner.tsx",
    "content": "// eslint-disable-line unicorn/filename-case\nimport React, { ComponentProps, useReducer, FC } from 'react';\nimport { render } from 'ink';\nimport { TaskList, Task } from 'ink-task-list';\n\ninterface Task {\n\tlabel: string;\n\tstate: ComponentProps<typeof Task>['state'];\n}\n\nconst CliSnapTweet: FC<{\n\titems: Task[];\n}> = ({ items }) => (\n\t<TaskList>\n\t\t{\n\t\t\titems.map((item, index) => (\n\t\t\t\t<Task\n\t\t\t\t\tkey={index}\n\t\t\t\t\tlabel={item.label}\n\t\t\t\t\tstate={item.state}\n\t\t\t\t/>\n\t\t\t))\n\t\t}\n\t</TaskList>\n);\n\nconst reducer = (state: Task[], task: 'task-updated' | Task) => {\n\tif (task === 'task-updated') {\n\t\treturn state.slice();\n\t}\n\treturn [...state, task];\n};\n\nfunction renderTaskRunner() {\n\tlet items;\n\tlet dispatchAction;\n\trender(React.createElement(() => {\n\t\t[items, dispatchAction] = useReducer(reducer, []);\n\t\treturn React.createElement(CliSnapTweet, { items });\n\t}));\n\n\treturn function addTask(label: string) {\n\t\tconst task = {\n\t\t\tlabel,\n\t\t\tstate: 'loading',\n\t\t};\n\t\tdispatchAction(task);\n\n\t\treturn {\n\t\t\tsuccess(message) {\n\t\t\t\ttask.label = message;\n\t\t\t\ttask.state = 'success';\n\n\t\t\t\tdispatchAction('task-updated');\n\t\t\t},\n\t\t\terror(message) {\n\t\t\t\ttask.label = message;\n\t\t\t\ttask.state = 'error';\n\n\t\t\t\tdispatchAction('task-updated');\n\t\t\t},\n\t\t};\n\t};\n}\n\nexport default renderTaskRunner;\n"
  },
  {
    "path": "src/tweet-camera.ts",
    "content": "import assert from 'assert';\nimport { launch, LaunchedChrome } from 'chrome-launcher';\nimport CDP from 'chrome-remote-interface';\nimport exitHook from 'exit-hook';\nimport {\n\tquerySelector,\n\twaitForNetworkIdle,\n\thideNode,\n\tscreenshotNode,\n} from './cdp-utils';\n\ninterface Options {\n\twidth?: number;\n\tdarkMode?: boolean;\n\tshowThread?: boolean;\n\tlocale?: string;\n}\n\nconst getEmbeddableTweetUrl = (tweetId: string, options: Options) => {\n\tconst embeddableTweetUrl = new URL('https://platform.twitter.com/embed/Tweet.html');\n\tconst searchParameters = {\n\t\tid: tweetId,\n\t\ttheme: options.darkMode ? 'dark' : 'light',\n\t\thideThread: options.showThread ? 'false' : 'true',\n\t\tlang: options.locale ?? 'en',\n\n\t\t// Not sure what these do but pass them in anyway (Reference: https://publish.twitter.com/)\n\t\tembedId: 'twitter-widget-0',\n\t\tfeatures: 'eyJ0ZndfZXhwZXJpbWVudHNfY29va2llX2V4cGlyYXRpb24iOnsiYnVja2V0IjoxMjA5NjAwLCJ2ZXJzaW9uIjpudWxsfSwidGZ3X2hvcml6b25fdHdlZXRfZW1iZWRfOTU1NSI6eyJidWNrZXQiOiJodGUiLCJ2ZXJzaW9uIjpudWxsfX0=',\n\t\tframe: 'false',\n\t\thideCard: 'false',\n\t\tsessionId: '4ee57c34a8bc3f4118cee97a9904f889f35e29b4',\n\t\twidgetsVersion: '82e1070:1619632193066',\n\t};\n\n\t// eslint-disable-next-line guard-for-in\n\tfor (const key in searchParameters) {\n\t\tembeddableTweetUrl.searchParams.set(key, searchParameters[key]);\n\t}\n\n\treturn embeddableTweetUrl.toString();\n};\n\nconst waitForTweetLoad = Network => new Promise<void>((resolve, reject) => {\n\tNetwork.responseReceived(({ type, response }) => {\n\t\tif (\n\t\t\ttype === 'XHR'\n\t\t\t&& response.url.startsWith('https://cdn.syndication.twimg.com/tweet')\n\t\t) {\n\t\t\tif (response.status === 200) {\n\t\t\t\treturn resolve();\n\t\t\t}\n\n\t\t\tif (response.status === 404) {\n\t\t\t\treturn reject(new Error('Tweet not found'));\n\t\t\t}\n\n\t\t\treject(new Error(`Failed to fetch tweet: ${response.status}`));\n\t\t}\n\t});\n});\n\nclass TweetCamera {\n\tchrome: LaunchedChrome;\n\n\tinitializingChrome: Promise<any>;\n\n\tconstructor() {\n\t\tthis.initializingChrome = this.initializeChrome();\n\t}\n\n\tasync initializeChrome() {\n\t\tconst chrome = await launch({\n\t\t\tchromeFlags: [\n\t\t\t\t'--headless',\n\t\t\t\t'--disable-gpu',\n\t\t\t],\n\t\t});\n\n\t\texitHook(() => {\n\t\t\tchrome.kill();\n\t\t});\n\n\t\tthis.chrome = chrome;\n\n\t\tconst browserClient = await CDP({\n\t\t\tport: chrome.port,\n\t\t});\n\n\t\treturn browserClient;\n\t}\n\n\tstatic parseTweetUrl(tweetUrl: string) {\n\t\tassert(tweetUrl, 'Tweet URL must be passed in');\n\t\tconst [, username, tweetId] = tweetUrl.match(/(?:twitter|x)\\.com\\/(\\w{1,15})\\/status\\/(\\d+)/) ?? [];\n\n\t\tassert(\n\t\t\tusername && tweetId,\n\t\t\t`Invalid Tweet URL: ${tweetUrl}`,\n\t\t);\n\n\t\treturn {\n\t\t\tusername,\n\t\t\ttweetId,\n\t\t};\n\t}\n\n\tstatic getRecommendedFileName(\n\t\tusername: string,\n\t\ttweetId: string,\n\t\toptions: Options = {},\n\t) {\n\t\tconst nameComponents = [username, tweetId];\n\n\t\tif (options.width !== 550) {\n\t\t\tnameComponents.push(`w${options.width}`);\n\t\t}\n\n\t\tif (options.showThread) {\n\t\t\tnameComponents.push('thread');\n\t\t}\n\n\t\tif (options.darkMode) {\n\t\t\tnameComponents.push('dark');\n\t\t}\n\n\t\tif (options.locale !== 'en') {\n\t\t\tnameComponents.push(options.locale);\n\t\t}\n\n\t\treturn `${nameComponents.join('-')}.png`;\n\t}\n\n\tasync snapTweet(\n\t\ttweetId: string,\n\t\toptions: Options = {},\n\t) {\n\t\tconst browserClient = await this.initializingChrome;\n\t\tconst { targetId } = await browserClient.Target.createTarget({\n\t\t\turl: getEmbeddableTweetUrl(tweetId, options),\n\t\t\twidth: options.width ?? 550,\n\t\t\theight: 1000,\n\t\t});\n\n\t\tconst client = await CDP({\n\t\t\tport: this.chrome.port,\n\t\t\ttarget: targetId,\n\t\t});\n\n\t\tawait client.Network.enable();\n\n\t\tawait waitForTweetLoad(client.Network);\n\n\t\tawait waitForNetworkIdle(client.Network, 200);\n\n\t\tconst { root } = await client.DOM.getDocument();\n\t\tconst tweetContainerNodeId = await querySelector(client.DOM, root.nodeId, '#app > div > div > div:last-child');\n\n\t\t// \"Copy link to Tweet\" button\n\t\tconst hideCopyLinkButtonNodeId = await querySelector(client.DOM, tweetContainerNodeId, '[role=\"button\"]').catch(() => null);\n\n\t\tawait Promise.all([\n\t\t\t// \"Copy link to Tweet\" button\n\t\t\t(hideCopyLinkButtonNodeId && hideNode(client.DOM, hideCopyLinkButtonNodeId)),\n\n\t\t\t// Info button - can't use aria-label because of i18n\n\t\t\thideNode(\n\t\t\t\tclient.DOM,\n\t\t\t\tawait querySelector(\n\t\t\t\t\tclient.DOM,\n\t\t\t\t\ttweetContainerNodeId,\n\t\t\t\t\t'a[href$=\"twitter-for-websites-ads-info-and-privacy\"]',\n\t\t\t\t),\n\t\t\t),\n\n\t\t\t// Remove the \"Read 10K replies\" button\n\t\t\tclient.DOM.removeNode({\n\t\t\t\tnodeId: await querySelector(\n\t\t\t\t\tclient.DOM,\n\t\t\t\t\ttweetContainerNodeId,\n\t\t\t\t\t'.css-1dbjc4n.r-kzbkwu.r-1h8ys4a',\n\t\t\t\t),\n\t\t\t}),\n\n\t\t\t// Unset max-width to fill window width\n\t\t\tclient.DOM.setAttributeValue({\n\t\t\t\tnodeId: tweetContainerNodeId,\n\t\t\t\tname: 'style',\n\t\t\t\tvalue: 'max-width: unset',\n\t\t\t}),\n\n\t\t\t// Set transparent bg for screenshot\n\t\t\tclient.Emulation.setDefaultBackgroundColorOverride({\n\t\t\t\tcolor: {\n\t\t\t\t\tr: 0, g: 0, b: 0, a: 0,\n\t\t\t\t},\n\t\t\t}),\n\t\t]);\n\n\t\t// If the width is larger than default, a larger image might get requested\n\t\tif (options.width > 550) {\n\t\t\tawait waitForNetworkIdle(client.Network, 200);\n\t\t}\n\n\t\t// Screenshot only the tweet\n\t\tconst snapshot = await screenshotNode(client.Page, client.DOM, tweetContainerNodeId);\n\n\t\tclient.Target.closeTarget({\n\t\t\ttargetId,\n\t\t});\n\n\t\treturn snapshot;\n\t}\n\n\tasync close() {\n\t\tconst browserClient = await this.initializingChrome;\n\t\tawait browserClient.close();\n\t\tawait this.chrome.kill();\n\t}\n}\n\nexport default TweetCamera;\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"moduleResolution\": \"node\",\n\t\t\"isolatedModules\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"outDir\": \"dist\",\n\t\t\"jsx\": \"react\",\n\n\t\t// Node 12\n\t\t\"module\": \"commonjs\",\n\t\t\"target\": \"ES2019\"\n\t},\n\t\"include\": [\n\t\t\"src\"\n\t]\n}\n"
  }
]