@saeedalone/x-shot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 x-shot contributors
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.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # xshot
2
+
3
+ Capture X/Twitter posts as Instagram-ready portrait screenshots from the command line.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @saeedalone/x-shot
9
+ ```
10
+
11
+ Or run without installing globally:
12
+
13
+ ```bash
14
+ npx @saeedalone/x-shot "https://x.com/user/status/123456789"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ xshot "https://x.com/user/status/123456789"
21
+ xshot -u "https://x.com/user/status/123" -f post -o my-tweet.png
22
+ xshot "https://x.com/user/status/123" --stats
23
+ ```
24
+
25
+ ### Options
26
+
27
+ | Flag | Description |
28
+ |------|-------------|
29
+ | `-u, --url <url>` | Tweet URL (required if not passed as first argument) |
30
+ | `-f, --format story\|post` | Output size: `story` (1080×1920) or `post` (1080×1350). Default: `story` |
31
+ | `-o, --output <path>` | Output PNG path. Default: `tweet-<format>.png` in current directory |
32
+ | `--no-stats` | Hide likes, replies, and action buttons (**default**) |
33
+ | `--stats` | Show engagement stats and action buttons |
34
+ | `--html <path>` | Custom HTML template |
35
+ | `-h, --help` | Show help |
36
+ | `-V, --version` | Show version |
37
+
38
+ ## Formats
39
+
40
+ - **story** — 1080×1920 (9:16) for Instagram Stories / Reels
41
+ - **post** — 1080×1350 (4:5) for Instagram portrait feed posts
42
+
43
+ ## Requirements
44
+
45
+ - Node.js 18+
46
+ - Internet connection (loads the tweet embed from X)
47
+
48
+ ## Publish
49
+
50
+ ```bash
51
+ npm login
52
+ npm publish --access public
53
+ ```
54
+
55
+ Scoped packages (`@saeedalone/x-shot`) must be published with `--access public` to be installable by anyone.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,51 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Embedded Tweet</title>
6
+ <style>
7
+ * {
8
+ margin: 0;
9
+ padding: 0;
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ html, body {
14
+ width: 100%;
15
+ height: 100%;
16
+ overflow: hidden;
17
+ }
18
+
19
+ body {
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ background: linear-gradient(135deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
24
+ }
25
+
26
+ .tweet-wrapper {
27
+ width: 100%;
28
+ max-width: 550px;
29
+ padding: 24px;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+
35
+ <div class="tweet-wrapper">
36
+ <blockquote class="twitter-tweet" data-theme="dark">
37
+ <a id="tweet-link" href="https://x.com/zubilubit/status/2074081634094121318?s=20"></a>
38
+ </blockquote>
39
+ </div>
40
+
41
+ <script>
42
+ (function () {
43
+ var params = new URLSearchParams(window.location.search);
44
+ var url = params.get("url");
45
+ if (url) document.getElementById("tweet-link").href = url;
46
+ })();
47
+ </script>
48
+ <script async src="https://platform.x.com/widgets.js"></script>
49
+
50
+ </body>
51
+ </html>
package/bin/xshot.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from "../lib/cli.js";
4
+
5
+ run();
package/lib/capture.js ADDED
@@ -0,0 +1,139 @@
1
+ import puppeteer from "puppeteer";
2
+ import { createServer } from "http";
3
+ import { readFileSync } from "fs";
4
+
5
+ export const FORMATS = {
6
+ story: { width: 1080, height: 1920, label: "Instagram Story (9:16)" },
7
+ post: { width: 1080, height: 1350, label: "Instagram Post (4:5)" },
8
+ };
9
+
10
+ function startServer(htmlPath) {
11
+ const html = readFileSync(htmlPath, "utf-8");
12
+
13
+ return new Promise((resolvePromise, reject) => {
14
+ const server = createServer((_req, res) => {
15
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
16
+ res.end(html);
17
+ });
18
+
19
+ server.listen(0, "127.0.0.1", () => {
20
+ const { port } = server.address();
21
+ resolvePromise({
22
+ url: `http://127.0.0.1:${port}/`,
23
+ close: () =>
24
+ new Promise((closeResolve, closeReject) => {
25
+ server.close((err) => (err ? closeReject(err) : closeResolve()));
26
+ }),
27
+ });
28
+ });
29
+
30
+ server.on("error", reject);
31
+ });
32
+ }
33
+
34
+ async function waitForTweet(page) {
35
+ await page.waitForFunction(
36
+ () => {
37
+ const iframe = document.querySelector('iframe[id^="twitter-widget"]');
38
+ if (!iframe) return false;
39
+ const rect = iframe.getBoundingClientRect();
40
+ return rect.height > 80 && rect.width > 200;
41
+ },
42
+ { timeout: 60000 }
43
+ );
44
+
45
+ await new Promise((r) => setTimeout(r, 2000));
46
+ }
47
+
48
+ async function hideTweetStats(page) {
49
+ const tweetFrame = page
50
+ .frames()
51
+ .find((f) => f.url().includes("embed/Tweet.html"));
52
+ if (!tweetFrame) return;
53
+
54
+ const cardHeight = await tweetFrame.evaluate(() => {
55
+ const style = document.createElement("style");
56
+ style.textContent = `
57
+ a[href*="intent/like"],
58
+ a[href*="intent/tweet"],
59
+ a[href*="intent/retweet"],
60
+ a[href*="/explore"] {
61
+ display: none !important;
62
+ }
63
+ `;
64
+ document.head.appendChild(style);
65
+
66
+ const hideRow = (anchor) => {
67
+ if (!anchor) return;
68
+ let row = anchor.parentElement;
69
+ for (let i = 0; i < 6 && row; i++) {
70
+ if (row.querySelector('[data-testid="tweetText"]')) break;
71
+ const actions = row.querySelectorAll(
72
+ 'a[href*="intent/like"], a[href*="intent/tweet"], a[href*="intent/retweet"], a[href*="/explore"]'
73
+ );
74
+ if (actions.length > 0) {
75
+ row.style.setProperty("display", "none", "important");
76
+ row.style.setProperty("height", "0", "important");
77
+ row.style.setProperty("margin", "0", "important");
78
+ row.style.setProperty("padding", "0", "important");
79
+ break;
80
+ }
81
+ row = row.parentElement;
82
+ }
83
+ };
84
+
85
+ hideRow(document.querySelector('a[href*="intent/like"]'));
86
+ hideRow(document.querySelector('a[href*="/explore"]'));
87
+
88
+ const card =
89
+ document.querySelector('[data-testid="tweetText"]')?.closest(".r-126aqm3") ??
90
+ document.body.querySelector("div[class*='r-126aqm3']");
91
+
92
+ return Math.ceil(
93
+ card?.getBoundingClientRect().height ?? document.body.scrollHeight
94
+ );
95
+ });
96
+
97
+ await page.evaluate((height) => {
98
+ const iframe = document.querySelector('iframe[id^="twitter-widget"]');
99
+ if (iframe) iframe.style.height = `${height}px`;
100
+ }, cardHeight + 4);
101
+
102
+ await new Promise((r) => setTimeout(r, 300));
103
+ }
104
+
105
+ export async function captureScreenshot(opts) {
106
+ const server = await startServer(opts.html);
107
+ const query = new URLSearchParams();
108
+ if (opts.tweetUrl) query.set("url", opts.tweetUrl);
109
+ if (opts.hideStats) query.set("no-stats", "1");
110
+ const qs = query.toString();
111
+ const pageUrl = qs ? `${server.url}?${qs}` : server.url;
112
+
113
+ const browser = await puppeteer.launch({
114
+ headless: true,
115
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
116
+ });
117
+
118
+ try {
119
+ const page = await browser.newPage();
120
+ await page.setViewport({
121
+ width: opts.width,
122
+ height: opts.height,
123
+ deviceScaleFactor: 1,
124
+ });
125
+
126
+ await page.goto(pageUrl, { waitUntil: "networkidle2", timeout: 60000 });
127
+ await waitForTweet(page);
128
+ if (opts.hideStats) await hideTweetStats(page);
129
+
130
+ await page.screenshot({
131
+ path: opts.output,
132
+ type: "png",
133
+ clip: { x: 0, y: 0, width: opts.width, height: opts.height },
134
+ });
135
+ } finally {
136
+ await browser.close();
137
+ await server.close();
138
+ }
139
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { captureScreenshot, FORMATS } from "./capture.js";
4
+ import { defaultPreviewHtml, packageRoot } from "./paths.js";
5
+
6
+ const VERSION = JSON.parse(
7
+ readFileSync(join(packageRoot, "package.json"), "utf-8")
8
+ ).version;
9
+
10
+ export function normalizeTweetUrl(url) {
11
+ let parsed;
12
+ try {
13
+ parsed = new URL(url);
14
+ } catch {
15
+ throw new Error(`Invalid tweet URL: ${url}`);
16
+ }
17
+
18
+ const host = parsed.hostname.replace(/^www\./, "");
19
+ if (!["x.com", "twitter.com"].includes(host)) {
20
+ throw new Error("Tweet URL must be on x.com or twitter.com.");
21
+ }
22
+
23
+ if (!parsed.pathname.includes("/status/")) {
24
+ throw new Error("Tweet URL must be a status link (contain /status/).");
25
+ }
26
+
27
+ return parsed.toString();
28
+ }
29
+
30
+ export function printHelp() {
31
+ console.log(`xshot — capture X posts as Instagram-ready portrait screenshots
32
+
33
+ Usage:
34
+ xshot <tweet-url> [options]
35
+ xshot --url <tweet-url> [options]
36
+
37
+ Options:
38
+ -u, --url <url> Tweet URL (x.com or twitter.com status link)
39
+ -f, --format <type> story | post (default: story)
40
+ -o, --output <path> Output PNG path (default: tweet-<format>.png)
41
+ --html <path> Custom HTML template (default: bundled preview.html)
42
+ --no-stats Hide likes, replies, and action buttons (default)
43
+ --stats Show engagement stats and action buttons
44
+ -h, --help Show help
45
+ -V, --version Show version
46
+
47
+ Formats:
48
+ story 1080×1920 Instagram Stories / Reels
49
+ post 1080×1350 Instagram portrait feed post
50
+
51
+ Examples:
52
+ xshot "https://x.com/user/status/123456789"
53
+ xshot -u "https://x.com/user/status/123" -f post -o my-tweet.png
54
+ xshot "https://x.com/user/status/123" --stats
55
+ `);
56
+ }
57
+
58
+ export function parseArgs(argv) {
59
+ const args = argv.slice(2);
60
+ let format = "story";
61
+ let output = null;
62
+ let html = defaultPreviewHtml;
63
+ let tweetUrl = null;
64
+ let hideStats = true;
65
+
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i];
68
+
69
+ if (arg === "--format" || arg === "-f") {
70
+ format = args[++i];
71
+ } else if (arg === "--output" || arg === "-o") {
72
+ output = args[++i];
73
+ } else if (arg === "--html") {
74
+ html = resolve(args[++i]);
75
+ } else if (arg === "--url" || arg === "-u") {
76
+ tweetUrl = args[++i];
77
+ } else if (arg === "--no-stats") {
78
+ hideStats = true;
79
+ } else if (arg === "--stats") {
80
+ hideStats = false;
81
+ } else if (arg === "--help" || arg === "-h") {
82
+ return { help: true };
83
+ } else if (arg === "--version" || arg === "-V") {
84
+ return { version: true };
85
+ } else if (!arg.startsWith("-") && !tweetUrl) {
86
+ tweetUrl = arg;
87
+ } else {
88
+ throw new Error(`Unknown option: ${arg}`);
89
+ }
90
+ }
91
+
92
+ if (!FORMATS[format]) {
93
+ throw new Error(`Unknown format "${format}". Use story or post.`);
94
+ }
95
+
96
+ return {
97
+ format,
98
+ output: output ?? join(process.cwd(), `tweet-${format}.png`),
99
+ html,
100
+ tweetUrl: tweetUrl ? normalizeTweetUrl(tweetUrl) : null,
101
+ hideStats,
102
+ ...FORMATS[format],
103
+ };
104
+ }
105
+
106
+ export async function run(argv = process.argv) {
107
+ let opts;
108
+ try {
109
+ opts = parseArgs(argv);
110
+ } catch (err) {
111
+ console.error(err.message);
112
+ process.exit(1);
113
+ }
114
+
115
+ if (opts.help) {
116
+ printHelp();
117
+ return;
118
+ }
119
+
120
+ if (opts.version) {
121
+ console.log(VERSION);
122
+ return;
123
+ }
124
+
125
+ if (!opts.tweetUrl) {
126
+ console.error("Error: Tweet URL is required.\n");
127
+ printHelp();
128
+ process.exit(1);
129
+ }
130
+
131
+ console.log(`Format: ${opts.label} (${opts.width}×${opts.height})`);
132
+ console.log(`Tweet: ${opts.tweetUrl}`);
133
+ console.log(`Stats: ${opts.hideStats ? "hidden" : "shown"}`);
134
+ console.log(`Output: ${opts.output}`);
135
+
136
+ try {
137
+ await captureScreenshot(opts);
138
+ console.log(`Saved ${opts.output}`);
139
+ } catch (err) {
140
+ console.error(`Screenshot failed: ${err.message}`);
141
+ process.exit(1);
142
+ }
143
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname, join } from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+
6
+ export const packageRoot = join(__dirname, "..");
7
+ export const defaultPreviewHtml = join(packageRoot, "assets", "preview.html");
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@saeedalone/x-shot",
3
+ "version": "1.0.0",
4
+ "description": "CLI to capture X/Twitter posts as Instagram-ready portrait screenshots",
5
+ "author": "SaeedPoureshghi (https://github.com/SaeedPoureshghi)",
6
+ "type": "module",
7
+ "bin": {
8
+ "xshot": "./bin/xshot.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "assets"
14
+ ],
15
+ "scripts": {
16
+ "start": "node bin/xshot.js",
17
+ "test": "node bin/xshot.js --help"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "x",
24
+ "twitter",
25
+ "tweet",
26
+ "screenshot",
27
+ "instagram",
28
+ "cli",
29
+ "puppeteer",
30
+ "social-media"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/SaeedPoureshghi/x-shot.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/SaeedPoureshghi/x-shot/issues"
38
+ },
39
+ "homepage": "https://github.com/SaeedPoureshghi/x-shot#readme",
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "puppeteer": "^24.2.0"
43
+ }
44
+ }