@xbrowser/steam 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.
Files changed (2) hide show
  1. package/index.ts +188 -0
  2. package/package.json +21 -0
package/index.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { z } from 'zod';
2
+ import type { XCLIAPI } from '@dyyz1993/xcli-core';
3
+ import { ok, fail } from '@dyyz1993/xcli-core';
4
+
5
+ export default function(xcli: XCLIAPI): void {
6
+ const steam = xcli.createSite({
7
+ name: 'steam',
8
+ url: 'https://store.steampowered.com',
9
+ description: 'Steam 游戏评论抓取 — 通过 Review API 批量获取所有语言评论',
10
+ requiresLogin: false,
11
+ });
12
+
13
+ steam.command('reviews', {
14
+ description: '抓取 Steam 游戏的全部评论(cursor 分页,100/页)',
15
+ scope: 'browser',
16
+ result: z.any(),
17
+ parameters: z.object({
18
+ appId: z.string().describe('Steam app ID,如 3730100'),
19
+ language: z.string().optional().default('all').describe('评论语言过滤,如 all/schinese/english'),
20
+ filter: z.enum(['all', 'recent', 'updated']).optional().default('recent').describe('排序过滤方式'),
21
+ reviewType: z.enum(['all', 'positive', 'negative']).optional().default('all').describe('好评/差评过滤'),
22
+ purchaseType: z.enum(['all', 'steam', 'non_steam_purchase']).optional().default('all').describe('购买渠道过滤'),
23
+ maxReviews: z.number().optional().default(0).describe('最大抓取数量,0=全部'),
24
+ delay: z.number().optional().default(1500).describe('每页间隔(ms),避免触发限流'),
25
+ }),
26
+ handler: async (params) => {
27
+ const BASE_URL = `https://store.steampowered.com/appreviews/${params.appId}`;
28
+
29
+ const { language = 'all', filter = 'recent', reviewType = 'all', purchaseType = 'all', maxReviews = 0, delay = 1500 } = params;
30
+
31
+ const buildQuery = (cursor: string) => new URLSearchParams({
32
+ json: '1',
33
+ cursor,
34
+ num_per_page: '100',
35
+ language,
36
+ purchase_type: purchaseType,
37
+ review_type: reviewType,
38
+ filter,
39
+ filter_offtopic_activity: '0',
40
+ });
41
+
42
+ const allReviews: Record<string, unknown>[] = [];
43
+ let cursor = '*';
44
+ let page = 0;
45
+ let totalFromApi = 0;
46
+
47
+ while (true) {
48
+ page++;
49
+ const url = `${BASE_URL}?${buildQuery(cursor).toString()}`;
50
+
51
+ let retries = 3;
52
+ let data: SteamApiResponse | undefined;
53
+
54
+ while (retries > 0) {
55
+ try {
56
+ const res = await fetch(url, {
57
+ headers: {
58
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
59
+ 'Cookie': 'wants_mature_content=1; birthtime=864000000; lastagecheckage=1-0-1997',
60
+ },
61
+ });
62
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
63
+ data = await res.json() as SteamApiResponse;
64
+ if (data.success !== 1) throw new Error(`API success=${data.success}`);
65
+ break;
66
+ } catch (err) {
67
+ retries--;
68
+ if (retries === 0) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ return { success: false as const, data: null, message: `Page ${page} failed: ${msg}` };
71
+ }
72
+ await sleep(3000);
73
+ }
74
+ }
75
+
76
+ if (!data) break;
77
+
78
+ const reviews = data.reviews ?? [];
79
+ if (page === 1) totalFromApi = data.query_summary?.total_reviews ?? 0;
80
+
81
+ for (const r of reviews) {
82
+ allReviews.push({
83
+ recommendationid: r.recommendationid,
84
+ steamid: r.author?.steamid,
85
+ playtime_forever: r.author?.playtime_forever,
86
+ playtime_at_review: r.author?.playtime_at_review,
87
+ review: r.review,
88
+ language: r.language,
89
+ timestamp_created: r.timestamp_created,
90
+ timestamp_updated: r.timestamp_updated,
91
+ voted_up: r.voted_up,
92
+ votes_up: r.votes_up,
93
+ votes_funny: r.votes_funny,
94
+ weighted_vote_score: r.weighted_vote_score,
95
+ steam_purchase: r.steam_purchase,
96
+ received_for_free: r.received_for_free,
97
+ written_during_early_access: r.written_during_early_access,
98
+ comment_count: r.comment_count,
99
+ });
100
+ }
101
+
102
+ const nextCursor = data.cursor ?? '';
103
+ const reached = maxReviews > 0 && allReviews.length >= maxReviews;
104
+ if (reviews.length === 0 || !nextCursor || nextCursor === cursor || reached) break;
105
+
106
+ cursor = nextCursor;
107
+ await sleep(delay);
108
+ }
109
+
110
+ // Trim if maxReviews set
111
+ const final = maxReviews > 0 ? allReviews.slice(0, maxReviews) : allReviews;
112
+ const positive = final.filter(r => r.voted_up === true).length;
113
+ const negative = final.length - positive;
114
+
115
+ // Language breakdown
116
+ const langs: Record<string, number> = {};
117
+ for (const r of final) {
118
+ const lang = (r.language as string) ?? 'unknown';
119
+ langs[lang] = (langs[lang] ?? 0) + 1;
120
+ }
121
+
122
+ return ok({
123
+ app_id: params.appId,
124
+ scraped_at: new Date().toISOString(),
125
+ api_total: totalFromApi,
126
+ fetched: final.length,
127
+ positive,
128
+ negative,
129
+ positive_ratio: final.length > 0 ? ((positive / final.length) * 100).toFixed(1) + '%' : 'N/A',
130
+ language_breakdown: Object.entries(langs).sort((a, b) => b[1] - a[1]),
131
+ reviews: final,
132
+ }, [
133
+ `Steam ${params.appId}: 抓取 ${final.length}/${totalFromApi} 条评论`,
134
+ `👍${positive} 👎${negative} (${((positive / final.length) * 100).toFixed(1)}%)`,
135
+ `语言分布: ${Object.entries(langs).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}(${v})`).join(', ')}`,
136
+ ]);
137
+ },
138
+
139
+ // ─── Helpers ────────────────────────────────────────────────────────────────
140
+
141
+ function sleep(ms: number): Promise<void> {
142
+ return new Promise(r => setTimeout(r, ms));
143
+ }
144
+
145
+ // ─── Types ──────────────────────────────────────────────────────────────────
146
+
147
+ interface SteamApiAuthor {
148
+ steamid: string;
149
+ num_games_owned: number;
150
+ num_reviews: number;
151
+ playtime_forever: number;
152
+ playtime_last_two_weeks: number;
153
+ playtime_at_review: number;
154
+ last_played: number;
155
+ }
156
+
157
+ interface SteamApiReview {
158
+ recommendationid: string;
159
+ author: SteamApiAuthor;
160
+ language: string;
161
+ review: string;
162
+ timestamp_created: number;
163
+ timestamp_updated: number;
164
+ voted_up: boolean;
165
+ votes_up: number;
166
+ votes_funny: number;
167
+ weighted_vote_score: string;
168
+ steam_purchase: boolean;
169
+ received_for_free: boolean;
170
+ written_during_early_access: boolean;
171
+ comment_count: number;
172
+ }
173
+
174
+ interface SteamApiQuerySummary {
175
+ num_reviews: number;
176
+ review_score: number;
177
+ review_score_desc: string;
178
+ total_positive: number;
179
+ total_negative: number;
180
+ total_reviews: number;
181
+ }
182
+
183
+ interface SteamApiResponse {
184
+ success: number;
185
+ query_summary: SteamApiQuerySummary;
186
+ reviews: SteamApiReview[];
187
+ cursor: string;
188
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@xbrowser/steam",
3
+ "version": "1.0.0",
4
+ "description": "Steam 游戏评论抓取 — 通过 Steam Review API 批量获取所有语言评论",
5
+ "main": "index.ts",
6
+ "keywords": [
7
+ "xbrowser",
8
+ "xbrowser-plugin",
9
+ "steam",
10
+ "reviews"
11
+ ],
12
+ "author": "dyyz1993",
13
+ "license": "MIT",
14
+ "xbrowser": {
15
+ "site": "https://store.steampowered.com",
16
+ "requiresLogin": false,
17
+ "commands": [
18
+ "reviews"
19
+ ]
20
+ }
21
+ }