commentscraper 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.
@@ -0,0 +1,119 @@
1
+ const STEAM_REVIEWS_API = 'https://store.steampowered.com/appreviews';
2
+
3
+ /**
4
+ * Fetch Steam game reviews via the free public API.
5
+ * @param {string} url - Steam store URL
6
+ * @param {{ progress: { send: function } }} opts
7
+ */
8
+ export async function fetchSteamReviews(url, { progress }) {
9
+ try {
10
+ const match = url.match(/\/app\/(\d+)/);
11
+ if (!match) return { success: false, error: 'Could not detect Steam app ID from URL.' };
12
+
13
+ const appId = match[1];
14
+ progress.send('Fetching Steam reviews...', 10);
15
+
16
+ const [detailsRes, firstReviewRes] = await Promise.all([
17
+ fetch(`https://store.steampowered.com/api/appdetails?appids=${appId}`),
18
+ fetch(`${STEAM_REVIEWS_API}/${appId}?json=1&filter=recent&language=all&num_per_page=100&cursor=*&review_type=all&purchase_type=all`),
19
+ ]);
20
+
21
+ let gameTitle = `Steam App ${appId}`;
22
+ try {
23
+ const detailsData = await detailsRes.json();
24
+ if (detailsData[appId]?.success) {
25
+ gameTitle = detailsData[appId].data.name || gameTitle;
26
+ }
27
+ } catch { /* use default title */ }
28
+
29
+ const firstData = await firstReviewRes.json();
30
+ if (firstData.success !== 1) throw new Error('Steam API returned error');
31
+
32
+ const qs = firstData.query_summary || {};
33
+ const post = {
34
+ title: gameTitle,
35
+ body: [
36
+ qs.review_score_desc || '',
37
+ qs.total_reviews ? `${qs.total_reviews.toLocaleString()} reviews` : '',
38
+ ].filter(Boolean).join(' \u00b7 '),
39
+ url: `https://store.steampowered.com/app/${appId}/`,
40
+ subreddit: 'Steam',
41
+ };
42
+
43
+ const reviews = parseSteamReviews(firstData.reviews || [], appId);
44
+ let cursor = firstData.cursor;
45
+ let page = 1;
46
+ const MAX_PAGES = 20;
47
+
48
+ progress.send(`Found ${reviews.length} reviews (page 1)`, 20);
49
+
50
+ while (page < MAX_PAGES) {
51
+ if (!cursor || cursor === '*') break;
52
+
53
+ await new Promise(r => setTimeout(r, 500));
54
+
55
+ const params = new URLSearchParams({
56
+ json: '1',
57
+ filter: 'recent',
58
+ language: 'all',
59
+ num_per_page: '100',
60
+ cursor,
61
+ review_type: 'all',
62
+ purchase_type: 'all',
63
+ });
64
+
65
+ const res = await fetch(`${STEAM_REVIEWS_API}/${appId}?${params}`);
66
+ const data = await res.json();
67
+
68
+ if (data.success !== 1) break;
69
+ const pageReviews = parseSteamReviews(data.reviews || [], appId);
70
+ if (pageReviews.length === 0) break;
71
+
72
+ reviews.push(...pageReviews);
73
+ page++;
74
+
75
+ if (data.cursor === cursor) break;
76
+ cursor = data.cursor;
77
+
78
+ const pct = 20 + Math.round((page / MAX_PAGES) * 70);
79
+ progress.send(`Loading Steam reviews... ${reviews.length} found`, Math.min(pct, 90));
80
+ }
81
+
82
+ progress.send(`Loaded ${reviews.length} reviews`, 95);
83
+
84
+ return { success: true, comments: reviews, post, method: 'json' };
85
+ } catch (error) {
86
+ return { success: false, error: error.message };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Parse Steam API review objects into the Comment model.
92
+ */
93
+ export function parseSteamReviews(reviews, appId) {
94
+ return reviews.map(r => {
95
+ const recommended = r.voted_up ? 'Recommended' : 'Not Recommended';
96
+ const hours = r.author?.playtime_forever
97
+ ? Math.round(r.author.playtime_forever / 60)
98
+ : 0;
99
+ const playtime = hours ? `${hours}h on record` : '';
100
+
101
+ let text = `[${recommended}]`;
102
+ if (playtime) text += ` (${playtime})`;
103
+ text += `\n\n${r.review || ''}`;
104
+
105
+ return {
106
+ text,
107
+ author: r.author?.steamid || '',
108
+ timestamp: r.timestamp_created
109
+ ? new Date(r.timestamp_created * 1000).toISOString()
110
+ : '',
111
+ permalink: r.author?.steamid
112
+ ? `https://steamcommunity.com/profiles/${r.author.steamid}/recommended/${appId}/`
113
+ : '',
114
+ links: [],
115
+ score: r.votes_up || 0,
116
+ depth: 0,
117
+ };
118
+ });
119
+ }
package/core/utils.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Extract URLs from markdown text.
3
+ * @param {string} markdown
4
+ * @returns {string[]}
5
+ */
6
+ export function extractLinksFromMarkdown(markdown) {
7
+ const links = [];
8
+
9
+ const markdownLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
10
+ const rawUrlRegex = /https?:\/\/[^\s\])<>]+/g;
11
+
12
+ let match;
13
+
14
+ while ((match = markdownLinkRegex.exec(markdown)) !== null) {
15
+ if (match[2] && !links.includes(match[2])) {
16
+ links.push(match[2]);
17
+ }
18
+ }
19
+
20
+ while ((match = rawUrlRegex.exec(markdown)) !== null) {
21
+ if (!links.includes(match[0])) {
22
+ links.push(match[0]);
23
+ }
24
+ }
25
+
26
+ return links;
27
+ }
28
+
29
+ /**
30
+ * Convert HN comment HTML to plain text.
31
+ * @param {string} html
32
+ * @returns {string}
33
+ */
34
+ export function hnHtmlToText(html) {
35
+ return (html || '')
36
+ .replace(/<p>/gi, '\n\n')
37
+ .replace(/<br\s*\/?>/gi, '\n')
38
+ .replace(/<[^>]+>/g, '')
39
+ .replace(/&amp;/g, '&')
40
+ .replace(/&lt;/g, '<')
41
+ .replace(/&gt;/g, '>')
42
+ .replace(/&quot;/g, '"')
43
+ .replace(/&#x27;/g, "'")
44
+ .replace(/&#x2F;/g, '/')
45
+ .replace(/&nbsp;/g, ' ')
46
+ .trim();
47
+ }
48
+
49
+ /**
50
+ * Convert Notion rich text array to plain text.
51
+ * Notion stores text as arrays like [["Hello", [["b"]]], [" world"]]
52
+ * @param {Array} richText
53
+ * @returns {string}
54
+ */
55
+ export function notionRichTextToPlain(richText) {
56
+ if (!richText || !Array.isArray(richText)) return '';
57
+ return richText.map(segment => {
58
+ if (typeof segment === 'string') return segment;
59
+ if (Array.isArray(segment)) return segment[0] || '';
60
+ return '';
61
+ }).join('').trim();
62
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,72 @@
1
+ import { SUPABASE_URL, SUPABASE_ANON_KEY } from '../core/config.js';
2
+ import { getToken } from './config.js';
3
+
4
+ /**
5
+ * Verify CLI token with Supabase edge function.
6
+ * Returns { valid, email, plan, plan_type } or { valid: false, error }.
7
+ */
8
+ export async function verifyToken() {
9
+ const token = getToken();
10
+ if (!token) {
11
+ return { valid: false, error: 'Not logged in. Run: commentscraper login' };
12
+ }
13
+
14
+ try {
15
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/cli-auth`, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ 'apikey': SUPABASE_ANON_KEY,
20
+ },
21
+ body: JSON.stringify({ action: 'verify', cli_token: token }),
22
+ });
23
+
24
+ if (!res.ok) {
25
+ return { valid: false, error: `Auth server error (${res.status})` };
26
+ }
27
+
28
+ return await res.json();
29
+ } catch (error) {
30
+ return { valid: false, error: `Could not reach auth server: ${error.message}` };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Create a device code for the login flow.
36
+ */
37
+ export async function createDeviceCode() {
38
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/cli-auth`, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ 'apikey': SUPABASE_ANON_KEY,
43
+ },
44
+ body: JSON.stringify({ action: 'create' }),
45
+ });
46
+
47
+ if (!res.ok) {
48
+ throw new Error(`Auth server error (${res.status})`);
49
+ }
50
+
51
+ return await res.json();
52
+ }
53
+
54
+ /**
55
+ * Poll for device code approval.
56
+ */
57
+ export async function pollDeviceCode(deviceCode) {
58
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/cli-auth`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'apikey': SUPABASE_ANON_KEY,
63
+ },
64
+ body: JSON.stringify({ action: 'poll', device_code: deviceCode }),
65
+ });
66
+
67
+ if (!res.ok) {
68
+ throw new Error(`Auth server error (${res.status})`);
69
+ }
70
+
71
+ return await res.json();
72
+ }
@@ -0,0 +1,11 @@
1
+ import { createProgressReporter } from '../core/progress.js';
2
+
3
+ export function createConsoleProgress(quiet = false) {
4
+ if (quiet) {
5
+ return { send: () => {} };
6
+ }
7
+ return createProgressReporter((message, percent) => {
8
+ process.stderr.write(`\r\x1b[K${message} [${percent}%]`);
9
+ if (percent >= 95) process.stderr.write('\n');
10
+ });
11
+ }
package/lib/config.js ADDED
@@ -0,0 +1,33 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.commentscraper');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ export function readConfig() {
9
+ try {
10
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function writeConfig(data) {
17
+ mkdirSync(CONFIG_DIR, { recursive: true });
18
+ writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2) + '\n', 'utf8');
19
+ }
20
+
21
+ export function deleteConfig() {
22
+ try {
23
+ unlinkSync(CONFIG_FILE);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ export function getToken() {
31
+ const config = readConfig();
32
+ return config?.cli_token || null;
33
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "commentscraper",
3
+ "version": "1.0.0",
4
+ "description": "Scrape comments and reviews from Reddit, Hacker News, Steam, Product Hunt, Notion, and Reddit profiles. Built for AI agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "commentscraper": "./bin/commentscraper.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "commands/",
12
+ "lib/",
13
+ "core/",
14
+ "SKILL.md",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "keywords": [
21
+ "scraper",
22
+ "reddit",
23
+ "hackernews",
24
+ "steam",
25
+ "producthunt",
26
+ "notion",
27
+ "comments",
28
+ "reviews",
29
+ "cli",
30
+ "agent-skills",
31
+ "claude-code",
32
+ "openclaw",
33
+ "openclaw-skills",
34
+ "agentic-ai",
35
+ "mcp"
36
+ ],
37
+ "scripts": {
38
+ "prepublish": "node prepublish.js"
39
+ },
40
+ "author": "DDTechSolution",
41
+ "license": "proprietary",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/daniel-ddtech/commentscraper-cli.git"
45
+ }
46
+ }