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
<!--
What did you do? (Provide code in next section)
What did you expect to happen?
What happened instead?
Do you have an error stack-trace?
-->
## Reproduction
<!--
Provide one of the following:
1. A code-snippet that reproduces the issue
2. A reproduction repo that reproduces the issue
3. A PR with a failing test-case
Remove irrelevant code to make it easier for others to read and debug.
-- Why?
The goal is to maximize communication efficiency.
When an issue is immediately reproducible, others can start debugging instead of following-up with questions.
-->
## 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?
<!--
What's the motivation behind this issue?
Eg. I'm frustrated when...
-->
## Describe the solution you'd like
<!--
What kind of solution would you like to see?
What makes it a good solution?
-->
## Describe alternatives you've considered
<!--
What else did you try?
Do you have a work around?
-->
## Additional context
<!--
Anything else to share?
Screenshots? Links?
-->
================================================
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 <hiroki.osame@gmail.com>
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 <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>
Command-line tool to capture clean and simple tweet snapshots.
<p align="center">
<a href="https://twitter.com/jack/status/20">
<img src=".github/example.png" width="60%">
</a>
<br>
<em>Light mode</em>
</p>
<p align="center">
<a href="https://twitter.com/jack/status/20">
<img src=".github/example-dark.png" width="60%">
</a>
<br>
<em>Dark mode</em>
</p>
### Features
- 🎛 Adjustable width
- 💅 Rounded corners & transparent background
- 🌚 Dark mode
- 🌐 Customizable locale
- 🙅♀️ No "Share" & "Info" buttons
- 💖 No watermark
- 🔥 Snap multiple tweets at once
<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>
## 🚀 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
```
<p align="center">
<a href="https://twitter.com/github/status/1390807474748416006">
<img src=".github/example-width-900.png" width="50%">
</a>
<br>
<em>Tweet with a 900px width</em>
</p>
### 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
```
<p align="center">
<a href="https://twitter.com/TwitterJP/status/578707432">
<img src=".github/example-locale-ja.png" width="50%">
</a>
<br>
<em>Using the Japanese locale (ja)</em>
</p>
### 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
```
<p align="center">
<a href="https://twitter.com/jack/status/1108487919969275904">
<img src=".github/example-thread.png" width="50%">
</a>
<br>
<em>Parent tweet inlcuded in the screenshot</em>
</p>
### 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 <path> Tweet screenshot output directory
-w, --width <width> Width of tweet (default: 550)
-t, --show-thread Show tweet thread
-d, --dark-mode Show tweet in dark mode
-l, --locale <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<void> => 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<void> => 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: ['<tweet urls...>'],
flags: {
outputDir: {
type: String,
alias: 'o',
description: 'Tweet screenshot output directory',
placeholder: '<path>',
},
width: {
type: Number,
alias: 'w',
description: 'Width of tweet',
default: 550,
placeholder: '<width>',
},
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: '<locale>',
},
},
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<typeof Task>['state'];
}
const CliSnapTweet: FC<{
items: Task[];
}> = ({ items }) => (
<TaskList>
{
items.map((item, index) => (
<Task
key={index}
label={item.label}
state={item.state}
/>
))
}
</TaskList>
);
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<void>((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<any>;
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"
]
}
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
SYMBOL INDEX (10 symbols across 2 files)
FILE: src/render-task-runner.tsx
type Task (line 6) | interface Task {
function renderTaskRunner (line 34) | function renderTaskRunner() {
FILE: src/tweet-camera.ts
type Options (line 12) | interface Options {
class TweetCamera (line 63) | class TweetCamera {
method constructor (line 68) | constructor() {
method initializeChrome (line 72) | async initializeChrome() {
method parseTweetUrl (line 93) | static parseTweetUrl(tweetUrl: string) {
method getRecommendedFileName (line 108) | static getRecommendedFileName(
method snapTweet (line 134) | async snapTweet(
method close (line 215) | async close() {
Condensed preview — 18 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (26K chars).
[
{
"path": ".editorconfig",
"chars": 129,
"preview": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newlin"
},
{
"path": ".github/ISSUE_TEMPLATE/BUG_REPORT.md",
"chars": 835,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\nlabels: 'bug: pending triage'\n---\n\n## Bug description\n<!-"
},
{
"path": ".github/ISSUE_TEMPLATE/FEATURE_REQUEST.md",
"chars": 544,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\nlabels: 'feature request'\n---\n\n## Is your feature requ"
},
{
"path": ".github/renovate.json",
"chars": 127,
"preview": "{\n\t\"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n\t\"extends\": [\n\t\t\"github>privatenumber/renovate-config"
},
{
"path": ".github/workflows/package-size-report.yml",
"chars": 408,
"preview": "name: Package Size Report\n\non:\n pull_request:\n branches: [ master, develop ]\n\njobs:\n pkg-size-report:\n name: Pac"
},
{
"path": ".github/workflows/release.yml",
"chars": 638,
"preview": "name: Release\n\non:\n push:\n branches: master\n\njobs:\n release:\n name: Release\n runs-on: ubuntu-latest\n\n step"
},
{
"path": ".github/workflows/test.yml",
"chars": 463,
"preview": "name: Test\n\non:\n push:\n branches: [develop]\n pull_request:\n branches: [master, develop]\n\njobs:\n test:\n name:"
},
{
"path": ".gitignore",
"chars": 262,
"preview": "# 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 direc"
},
{
"path": ".nvmrc",
"chars": 9,
"preview": "v16.17.0\n"
},
{
"path": "LICENSE",
"chars": 1089,
"preview": "MIT License\n\nCopyright (c) Hiroki Osame <hiroki.osame@gmail.com>\n\nPermission is hereby granted, free of charge, to any p"
},
{
"path": "README.md",
"chars": 5410,
"preview": "# 📸 snap-tweet <a href=\"https://npm.im/snap-tweet\"><img src=\"https://badgen.net/npm/v/snap-tweet\"></a> <a href=\"https://"
},
{
"path": "bin/snap-tweet.js",
"chars": 47,
"preview": "#!/usr/bin/env node\nrequire('../dist/cli.js');\n"
},
{
"path": "package.json",
"chars": 1266,
"preview": "{\n\t\"name\": \"snap-tweet\",\n\t\"version\": \"0.0.0-semantic-release\",\n\t\"description\": \"Snap a screenshot of a tweet\",\n\t\"keyword"
},
{
"path": "src/cdp-utils.ts",
"chars": 1907,
"preview": "import pRetry from 'p-retry';\n\nexport const waitForNetworkIdle = (\n\tNetwork,\n\twaitFor: number,\n): Promise<void> => new P"
},
{
"path": "src/cli.ts",
"chars": 3225,
"preview": "import fs from 'fs';\nimport path from 'path';\nimport { unusedFilename } from 'unused-filename';\nimport tempy from 'tempy"
},
{
"path": "src/render-task-runner.tsx",
"chars": 1268,
"preview": "// eslint-disable-line unicorn/filename-case\nimport React, { ComponentProps, useReducer, FC } from 'react';\nimport { ren"
},
{
"path": "src/tweet-camera.ts",
"chars": 5319,
"preview": "import assert from 'assert';\nimport { launch, LaunchedChrome } from 'chrome-launcher';\nimport CDP from 'chrome-remote-in"
},
{
"path": "tsconfig.json",
"chars": 236,
"preview": "{\n\t\"compilerOptions\": {\n\t\t\"moduleResolution\": \"node\",\n\t\t\"isolatedModules\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"outDir\": "
}
]
About this extraction
This page contains the full source code of the privatenumber/snap-tweet GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 18 files (22.6 KB), approximately 6.9k tokens, and a symbol index with 10 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.