xapi-to 0.1.18 → 0.1.19
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 +21 -0
- package/README.md +51 -10
- package/dist/index.js +384 -46
- package/package.json +9 -2
- package/skills/xapi/SKILL.md +485 -0
- package/skills/xapi/guides/ai.md +200 -0
- package/skills/xapi/guides/ai_gateway.md +263 -0
- package/skills/xapi/guides/crypto.md +197 -0
- package/skills/xapi/guides/douyin.md +297 -0
- package/skills/xapi/guides/google_search.md +194 -0
- package/skills/xapi/guides/linkedin.md +198 -0
- package/skills/xapi/guides/reddit.md +312 -0
- package/skills/xapi/guides/sms.md +186 -0
- package/skills/xapi/guides/tiktok.md +322 -0
- package/skills/xapi/guides/twitter.md +276 -0
- package/skills/xapi/guides/weibo.md +301 -0
- package/skills/xapi/guides/ws_gateway.md +206 -0
- package/skills/xapi/guides/xiaohongshu.md +315 -0
- package/skills/xapi/scripts/download_tweet_videos.sh +125 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# 小红书 (Xiaohongshu / RedNote) Guide
|
|
2
|
+
|
|
3
|
+
Complete guide for Xiaohongshu (小红书) operations via xAPI — user profiles, notes, comments, search, topics, products, and creator inspiration.
|
|
4
|
+
|
|
5
|
+
> **Dynamic catalog:** These are database-registered third-party APIs under the `xiaohongshu` service. Exact action IDs, HTTP methods, parameters, and response fields can change. Run `search` and `get` before calling; the current schema wins. Examples below reflect one known GET-based version and keep `"method":"GET"` in the input for compatibility.
|
|
6
|
+
|
|
7
|
+
**Tip:** Many endpoints accept both a direct ID (e.g. `user_id`, `note_id`) and a `share_text` (share link). You can use either one.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [User data](#user-data)
|
|
12
|
+
- [Note data](#note-data)
|
|
13
|
+
- [Search](#search)
|
|
14
|
+
- [Topics](#topics)
|
|
15
|
+
- [Products](#products-商品)
|
|
16
|
+
- [Creator inspiration](#creator-inspiration-创作灵感)
|
|
17
|
+
- [Common workflows](#common-workflows)
|
|
18
|
+
- [Pagination](#pagination-patterns)
|
|
19
|
+
- [API reference](#api-reference)
|
|
20
|
+
- [Error handling](#error-handling)
|
|
21
|
+
|
|
22
|
+
## User Data
|
|
23
|
+
|
|
24
|
+
### Get user info
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__user__info \
|
|
28
|
+
--input '{"method":"GET","params":{"user_id":"<user_id>"}}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Can also pass `share_text` (share link) instead of `user_id`.
|
|
32
|
+
|
|
33
|
+
### Get user's posted notes
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__user__posted__notes \
|
|
37
|
+
--input '{"method":"GET","params":{"user_id":"<user_id>"}}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Optional parameters:
|
|
41
|
+
- `cursor` — pagination cursor, leave empty for first request; pass last `note_id` from previous response
|
|
42
|
+
- `share_text` — can use share link instead of `user_id`
|
|
43
|
+
|
|
44
|
+
### Get user's faved notes
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__user__faved__notes \
|
|
48
|
+
--input '{"method":"GET","params":{"user_id":"<user_id>"}}'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Optional parameters:
|
|
52
|
+
- `cursor` — pagination cursor, pass last `note_id` from previous response
|
|
53
|
+
- `share_text` — share link alternative
|
|
54
|
+
|
|
55
|
+
## Note Data
|
|
56
|
+
|
|
57
|
+
### Get image note detail
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__image__note__detail \
|
|
61
|
+
--input '{"method":"GET","params":{"note_id":"<note_id>"}}'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use this for image-type notes. Can also pass `share_text` instead of `note_id`.
|
|
65
|
+
|
|
66
|
+
### Get video note detail
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__video__note__detail \
|
|
70
|
+
--input '{"method":"GET","params":{"note_id":"<note_id>"}}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use this for video-type notes. Can also pass `share_text` instead of `note_id`.
|
|
74
|
+
|
|
75
|
+
### Get mixed note detail
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__mixed__note__detail \
|
|
79
|
+
--input '{"method":"GET","params":{"note_id":"<note_id>"}}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Auto-detects note type (image or video) from feed. Can also pass `share_text` instead of `note_id`.
|
|
83
|
+
|
|
84
|
+
**Tip:** If you don't know whether a note is image or video, use `get__mixed__note__detail` — it handles both types.
|
|
85
|
+
|
|
86
|
+
### Get note comments
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__note__comments \
|
|
90
|
+
--input '{"method":"GET","params":{"note_id":"<note_id>","index":0}}'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Optional parameters:
|
|
94
|
+
- `index` — comment index, pass `0` for first request
|
|
95
|
+
- `cursor` — pagination cursor, leave empty for first request
|
|
96
|
+
- `sort_strategy` — `default`, `latest_v2` (最新), `like_count` (最热)
|
|
97
|
+
- `share_text` — share link alternative
|
|
98
|
+
|
|
99
|
+
## Search
|
|
100
|
+
|
|
101
|
+
### Search notes
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_search__notes \
|
|
105
|
+
--input '{"method":"GET","params":{"keyword":"咖啡推荐","page":1}}'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Optional parameters:
|
|
109
|
+
- `page` — page number, start from 1
|
|
110
|
+
- `note_type` — `不限` (all), `视频笔记` (video), `普通笔记` (image), `直播笔记` (live)
|
|
111
|
+
- `sort_type` — sort type
|
|
112
|
+
- `time_filter` — `不限` (all), `一天内` (1 day), `一周内` (1 week), `半年内` (6 months)
|
|
113
|
+
- `ai_mode` — `0` = off, `1` = on (AI-enhanced search)
|
|
114
|
+
- `search_id` — for pagination consistency
|
|
115
|
+
- `search_session_id` — for pagination consistency
|
|
116
|
+
|
|
117
|
+
### Search users
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_search__users \
|
|
121
|
+
--input '{"method":"GET","params":{"keyword":"美食博主","page":1}}'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Optional: `page`, `search_id`.
|
|
125
|
+
|
|
126
|
+
### Search images
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_search__images \
|
|
130
|
+
--input '{"method":"GET","params":{"keyword":"壁纸","page":1}}'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Optional: `page`, `search_id`, `word_request_id`, `search_session_id`.
|
|
134
|
+
|
|
135
|
+
### Search groups
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_search__groups \
|
|
139
|
+
--input '{"method":"GET","params":{"keyword":"摄影","page_no":0}}'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Optional parameters:
|
|
143
|
+
- `page_no` — page number, start from **0** (not 1)
|
|
144
|
+
- `is_recommend` — `0` = no, `1` = yes
|
|
145
|
+
- `search_id` — for pagination
|
|
146
|
+
|
|
147
|
+
### Search products
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_search__products \
|
|
151
|
+
--input '{"method":"GET","params":{"keyword":"面霜","page":1}}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Optional: `page`, `search_id`.
|
|
155
|
+
|
|
156
|
+
## Topics
|
|
157
|
+
|
|
158
|
+
### Get topic info
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__topic__info \
|
|
162
|
+
--input '{"method":"GET","params":{"page_id":"<topic_page_id>"}}'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Optional: `note_id` — pass when jumping from a specific note to its topic.
|
|
166
|
+
|
|
167
|
+
### Get topic feed
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__topic__feed \
|
|
171
|
+
--input '{"method":"GET","params":{"page_id":"<topic_page_id>","sort":"trend"}}'
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Optional parameters:
|
|
175
|
+
- `sort` — `trend` (最热), `time` (最新)
|
|
176
|
+
- `cursor_score` — pagination cursor score for next page
|
|
177
|
+
- `last_note_id` — last note ID from previous page
|
|
178
|
+
- `last_note_ct` — last note create time from previous page
|
|
179
|
+
- `session_id` — keep consistent across pagination
|
|
180
|
+
- `first_load_time` — keep consistent across pagination
|
|
181
|
+
|
|
182
|
+
## Products (商品)
|
|
183
|
+
|
|
184
|
+
### Get product detail
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__product__detail \
|
|
188
|
+
--input '{"method":"GET","params":{"sku_id":"<sku_id>"}}'
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Get product reviews
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__product__reviews \
|
|
195
|
+
--input '{"method":"GET","params":{"sku_id":"<sku_id>","page":0}}'
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Optional parameters:
|
|
199
|
+
- `page` — page number, start from **0**
|
|
200
|
+
- `sort_strategy_type` — `0` = general, `1` = latest
|
|
201
|
+
- `share_pics_only` — `0` = all reviews, `1` = only reviews with images
|
|
202
|
+
|
|
203
|
+
### Get product review overview
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__product__review__overview \
|
|
207
|
+
--input '{"method":"GET","params":{"sku_id":"<sku_id>"}}'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Optional: `tab`.
|
|
211
|
+
|
|
212
|
+
### Get product recommendations
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__product__recommendations \
|
|
216
|
+
--input '{"method":"GET","params":{"sku_id":"<sku_id>"}}'
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Optional: `cursor_score` for pagination, `region`.
|
|
220
|
+
|
|
221
|
+
## Creator Inspiration (创作灵感)
|
|
222
|
+
|
|
223
|
+
### Get creator inspiration feed
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__creator__inspiration__feed \
|
|
227
|
+
--input '{"method":"GET","params":{}}'
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Optional parameters:
|
|
231
|
+
- `tab` — tab type
|
|
232
|
+
- `cursor` — pagination cursor
|
|
233
|
+
|
|
234
|
+
### Get creator hot inspiration feed
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
npx xapi-to call xiaohongshu.api_v1_xiaohongshu_app__v2_get__creator__hot__inspiration__feed \
|
|
238
|
+
--input '{"method":"GET","params":{}}'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Optional: `cursor` for pagination.
|
|
242
|
+
|
|
243
|
+
## Common Workflows
|
|
244
|
+
|
|
245
|
+
### Research a Xiaohongshu user
|
|
246
|
+
|
|
247
|
+
1. Get user info: `xiaohongshu...get__user__info` with `user_id` → profile, follower stats
|
|
248
|
+
2. Get posted notes: `xiaohongshu...get__user__posted__notes` → browse content
|
|
249
|
+
3. Get faved notes: `xiaohongshu...get__user__faved__notes` → what they like
|
|
250
|
+
|
|
251
|
+
### Analyze a note
|
|
252
|
+
|
|
253
|
+
1. Get note detail: `xiaohongshu...get__mixed__note__detail` with `note_id` → full note data
|
|
254
|
+
2. Get comments: `xiaohongshu...get__note__comments` with `sort_strategy=like_count` → top comments
|
|
255
|
+
|
|
256
|
+
### Discover content by topic
|
|
257
|
+
|
|
258
|
+
1. Get topic info: `xiaohongshu...get__topic__info` with `page_id` → topic metadata
|
|
259
|
+
2. Get topic feed: `xiaohongshu...get__topic__feed` with `sort=trend` → trending notes in topic
|
|
260
|
+
|
|
261
|
+
### Research a product
|
|
262
|
+
|
|
263
|
+
1. Get product detail: `xiaohongshu...get__product__detail` with `sku_id` → product info
|
|
264
|
+
2. Get review overview: `xiaohongshu...get__product__review__overview` → rating summary
|
|
265
|
+
3. Get reviews: `xiaohongshu...get__product__reviews` → detailed reviews
|
|
266
|
+
4. Get recommendations: `xiaohongshu...get__product__recommendations` → similar products
|
|
267
|
+
|
|
268
|
+
### Search content
|
|
269
|
+
|
|
270
|
+
1. Search notes: `xiaohongshu...search__notes` with `keyword` → find relevant notes
|
|
271
|
+
2. Search users: `xiaohongshu...search__users` → find creators
|
|
272
|
+
3. Search products: `xiaohongshu...search__products` → find products
|
|
273
|
+
|
|
274
|
+
## Pagination Patterns
|
|
275
|
+
|
|
276
|
+
| Pattern | Endpoints | How to use |
|
|
277
|
+
|---------|-----------|------------|
|
|
278
|
+
| `cursor` (note_id) | user posted/faved notes | Pass last `note_id` from previous response |
|
|
279
|
+
| `cursor` | comments, creator feed, product recommendations | Pass `cursor` from previous response |
|
|
280
|
+
| `page` (from 1) | search notes/users/images/products | Increment `page` by 1 |
|
|
281
|
+
| `page_no` (from 0) | search groups | Increment `page_no` by 1 |
|
|
282
|
+
| `page` (from 0) | product reviews | Increment `page` by 1 |
|
|
283
|
+
| `cursor_score` + `last_note_id` | topic feed | Pass both from previous response |
|
|
284
|
+
|
|
285
|
+
## API Reference
|
|
286
|
+
|
|
287
|
+
| API (prefix: `xiaohongshu.api_v1_xiaohongshu_app__v2_`) | Description | Key Params |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| `get__user__info` | User profile | `user_id` or `share_text` |
|
|
290
|
+
| `get__user__posted__notes` | User's notes | `user_id`, `cursor` |
|
|
291
|
+
| `get__user__faved__notes` | User's favorites | `user_id`, `cursor` |
|
|
292
|
+
| `get__image__note__detail` | Image note detail | `note_id` or `share_text` |
|
|
293
|
+
| `get__video__note__detail` | Video note detail | `note_id` or `share_text` |
|
|
294
|
+
| `get__mixed__note__detail` | Mixed note detail (auto-detect) | `note_id` or `share_text` |
|
|
295
|
+
| `get__note__comments` | Note comments | `note_id`, `index`, `cursor`, `sort_strategy` |
|
|
296
|
+
| `search__notes` | Search notes | `keyword`*, `page`, `note_type`, `time_filter` |
|
|
297
|
+
| `search__users` | Search users | `keyword`*, `page` |
|
|
298
|
+
| `search__images` | Search images | `keyword`*, `page` |
|
|
299
|
+
| `search__groups` | Search groups | `keyword`*, `page_no` |
|
|
300
|
+
| `search__products` | Search products | `keyword`*, `page` |
|
|
301
|
+
| `get__topic__info` | Topic info | `page_id`* |
|
|
302
|
+
| `get__topic__feed` | Topic feed | `page_id`*, `sort`, `cursor_score` |
|
|
303
|
+
| `get__product__detail` | Product detail | `sku_id`* |
|
|
304
|
+
| `get__product__reviews` | Product reviews | `sku_id`*, `page`, `sort_strategy_type` |
|
|
305
|
+
| `get__product__review__overview` | Product review overview | `sku_id`* |
|
|
306
|
+
| `get__product__recommendations` | Product recommendations | `sku_id`*, `cursor_score` |
|
|
307
|
+
| `get__creator__inspiration__feed` | Creator inspiration feed | `tab`, `cursor` |
|
|
308
|
+
| `get__creator__hot__inspiration__feed` | Creator hot inspiration | `cursor` |
|
|
309
|
+
|
|
310
|
+
## Error Handling
|
|
311
|
+
|
|
312
|
+
- **Missing note_id** → Use `search__notes` to find notes, or parse `note_id` from share links
|
|
313
|
+
- **Image vs Video note** → If unsure, use `get__mixed__note__detail` which auto-detects
|
|
314
|
+
- **Pagination: page starts from 0 or 1** → Check endpoint docs: search notes/users/images start from `1`, search groups and product reviews start from `0`
|
|
315
|
+
- **Empty results** → Verify the `user_id`, `note_id`, or `sku_id` is valid
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
echo "Usage: $0 <tweet-url-or-id> [output-directory]" >&2
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
die() {
|
|
9
|
+
echo "Error: $*" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
for required_command in npx jq curl file mktemp; do
|
|
14
|
+
command -v "$required_command" >/dev/null 2>&1 ||
|
|
15
|
+
die "required command not found: $required_command"
|
|
16
|
+
done
|
|
17
|
+
|
|
18
|
+
TWEET_INPUT=${1:-}
|
|
19
|
+
OUTPUT_DIR=${2:-.}
|
|
20
|
+
[ -n "$TWEET_INPUT" ] || {
|
|
21
|
+
usage
|
|
22
|
+
exit 2
|
|
23
|
+
}
|
|
24
|
+
[ -d "$OUTPUT_DIR" ] || die "output directory does not exist: $OUTPUT_DIR"
|
|
25
|
+
OUTPUT_DIR=$(cd "$OUTPUT_DIR" && pwd -P)
|
|
26
|
+
|
|
27
|
+
if [[ "$TWEET_INPUT" =~ ^https://((www|mobile)\.)?(x\.com|twitter\.com)/[^/]+/status/([0-9]+) ]]; then
|
|
28
|
+
TWEET_ID=${BASH_REMATCH[4]}
|
|
29
|
+
elif [[ "$TWEET_INPUT" =~ ^[0-9]+$ ]]; then
|
|
30
|
+
TWEET_ID=$TWEET_INPUT
|
|
31
|
+
else
|
|
32
|
+
die "expected a numeric tweet ID or an x.com/twitter.com status URL"
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
SCHEMA=$(npx xapi-to get twitter.tweet_detail --format json)
|
|
36
|
+
printf '%s' "$SCHEMA" | jq -e '
|
|
37
|
+
.output.properties.data.properties.tweet.properties.media
|
|
38
|
+
.items.properties.video_url.type == "string"
|
|
39
|
+
' >/dev/null || die "twitter.tweet_detail video_url is not deployed on this xAPI backend"
|
|
40
|
+
|
|
41
|
+
INPUT=$(jq -nc --arg tweet_id "$TWEET_ID" '{tweet_id:$tweet_id}')
|
|
42
|
+
RESPONSE=$(npx xapi-to call twitter.tweet_detail --input "$INPUT" --format json)
|
|
43
|
+
|
|
44
|
+
DOWNLOADS=$(printf '%s' "$RESPONSE" | jq -cer '
|
|
45
|
+
def tweet_tree:
|
|
46
|
+
.,
|
|
47
|
+
(
|
|
48
|
+
(.quoted_tweet?, .retweeted_tweet?)
|
|
49
|
+
| select(type == "object")
|
|
50
|
+
| tweet_tree
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if .success != true then
|
|
54
|
+
error("xAPI call failed")
|
|
55
|
+
elif (.data.tweet | type) != "object" then
|
|
56
|
+
error("tweet detail returned no main tweet")
|
|
57
|
+
else
|
|
58
|
+
[
|
|
59
|
+
.data.tweet
|
|
60
|
+
| tweet_tree
|
|
61
|
+
| .media[]?
|
|
62
|
+
| select(.type == "video" or .type == "animated_gif")
|
|
63
|
+
| select((.video_url? | type) == "string" and (.video_url | length) > 0)
|
|
64
|
+
| {url: .video_url}
|
|
65
|
+
]
|
|
66
|
+
| unique_by(.url)
|
|
67
|
+
| if length == 0 then error("tweet has no downloadable MP4") else . end
|
|
68
|
+
end
|
|
69
|
+
')
|
|
70
|
+
|
|
71
|
+
COUNT=$(printf '%s' "$DOWNLOADS" | jq -r 'length')
|
|
72
|
+
URLS=()
|
|
73
|
+
OUTPUTS=()
|
|
74
|
+
TEMP_FILES=()
|
|
75
|
+
|
|
76
|
+
for ((INDEX = 0; INDEX < COUNT; INDEX++)); do
|
|
77
|
+
VIDEO_URL=$(printf '%s' "$DOWNLOADS" | jq -r --argjson index "$INDEX" '.[$index].url')
|
|
78
|
+
[[ "$VIDEO_URL" =~ ^https://video\.twimg\.com/ ]] ||
|
|
79
|
+
die "refusing unexpected media URL outside https://video.twimg.com/"
|
|
80
|
+
URLS+=("$VIDEO_URL")
|
|
81
|
+
if ((COUNT == 1)); then
|
|
82
|
+
OUTPUT="$OUTPUT_DIR/tweet-$TWEET_ID.mp4"
|
|
83
|
+
else
|
|
84
|
+
OUTPUT="$OUTPUT_DIR/tweet-$TWEET_ID-$((INDEX + 1)).mp4"
|
|
85
|
+
fi
|
|
86
|
+
[ ! -e "$OUTPUT" ] || die "output already exists: $OUTPUT"
|
|
87
|
+
OUTPUTS+=("$OUTPUT")
|
|
88
|
+
done
|
|
89
|
+
|
|
90
|
+
cleanup() {
|
|
91
|
+
if ((${#TEMP_FILES[@]})); then
|
|
92
|
+
for temporary_file in "${TEMP_FILES[@]}"; do
|
|
93
|
+
[ -z "$temporary_file" ] || rm -f -- "$temporary_file"
|
|
94
|
+
done
|
|
95
|
+
fi
|
|
96
|
+
}
|
|
97
|
+
trap cleanup EXIT
|
|
98
|
+
|
|
99
|
+
for ((INDEX = 0; INDEX < COUNT; INDEX++)); do
|
|
100
|
+
OUTPUT=${OUTPUTS[$INDEX]}
|
|
101
|
+
TEMP_FILE=$(mktemp "$OUTPUT.part.XXXXXX")
|
|
102
|
+
TEMP_FILES+=("$TEMP_FILE")
|
|
103
|
+
|
|
104
|
+
curl --proto '=https' --proto-redir '=https' \
|
|
105
|
+
--fail --location --silent --show-error \
|
|
106
|
+
--output "$TEMP_FILE" "${URLS[$INDEX]}"
|
|
107
|
+
|
|
108
|
+
MIME_TYPE=$(file --brief --mime-type "$TEMP_FILE")
|
|
109
|
+
[ "$MIME_TYPE" = 'video/mp4' ] ||
|
|
110
|
+
die "downloaded content is not video/mp4 (got $MIME_TYPE)"
|
|
111
|
+
|
|
112
|
+
if command -v ffprobe >/dev/null 2>&1; then
|
|
113
|
+
ffprobe -v error "$TEMP_FILE" >/dev/null ||
|
|
114
|
+
die "ffprobe rejected the downloaded MP4"
|
|
115
|
+
fi
|
|
116
|
+
done
|
|
117
|
+
|
|
118
|
+
# Publish files only after every download has passed validation.
|
|
119
|
+
for ((INDEX = 0; INDEX < COUNT; INDEX++)); do
|
|
120
|
+
mv -- "${TEMP_FILES[$INDEX]}" "${OUTPUTS[$INDEX]}"
|
|
121
|
+
TEMP_FILES[$INDEX]=''
|
|
122
|
+
echo "Downloaded: ${OUTPUTS[$INDEX]}"
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
trap - EXIT
|