File size: 21,165 Bytes
25bc372
 
51b7902
 
 
1072676
25bc372
1072676
 
 
51b7902
 
1072676
25bc372
 
51b7902
 
25bc372
 
1072676
25bc372
51b7902
1072676
25bc372
 
 
 
 
6591c1c
25bc372
 
 
 
 
 
 
2629527
25bc372
 
 
2629527
 
 
25bc372
 
2629527
 
 
 
 
 
 
 
 
25bc372
 
 
 
 
 
51b7902
 
 
 
 
 
25bc372
 
 
 
 
 
 
 
 
 
5c80566
 
25bc372
 
 
 
 
 
 
 
 
 
 
 
 
1072676
 
 
 
 
 
 
 
 
 
 
 
 
 
25bc372
 
 
 
 
1072676
25bc372
 
 
 
 
 
 
 
 
1072676
25bc372
 
 
 
 
 
 
 
1072676
25bc372
 
 
 
 
1072676
25bc372
 
 
 
 
 
 
1072676
25bc372
 
 
 
 
 
1072676
25bc372
 
 
c8521a2
 
 
 
 
 
 
 
 
25bc372
 
 
1072676
25bc372
 
 
 
 
 
 
 
 
 
 
 
 
1072676
5c80566
 
 
 
 
1072676
5c80566
 
 
 
 
 
 
 
 
 
 
1072676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6591c1c
25bc372
1072676
 
25bc372
6591c1c
 
25bc372
 
 
 
 
 
 
 
 
 
 
1072676
 
25bc372
 
 
 
 
 
 
 
1072676
25bc372
 
1072676
25bc372
 
 
 
 
 
338f372
 
25bc372
 
 
 
 
 
 
 
1072676
25bc372
 
 
 
 
e1c1d38
 
 
 
 
 
 
 
 
 
 
25bc372
1072676
25bc372
 
 
 
 
 
51b7902
1072676
25bc372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072676
51b7902
 
 
 
1072676
 
 
 
 
 
 
51b7902
1072676
 
51b7902
 
1072676
 
 
 
 
 
 
 
51b7902
 
 
 
 
1072676
 
 
 
 
 
25bc372
 
 
1072676
25bc372
 
1072676
25bc372
 
 
1072676
25bc372
1072676
25bc372
1072676
25bc372
 
5c80566
1072676
25bc372
 
 
 
 
 
 
 
 
51b7902
 
 
 
 
25bc372
 
 
51b7902
 
 
25bc372
1072676
25bc372
 
 
 
 
 
1072676
25bc372
 
1072676
 
 
 
 
 
 
 
 
 
 
 
 
25bc372
 
 
1072676
 
25bc372
 
1072676
25bc372
 
1072676
25bc372
 
 
1072676
 
25bc372
1072676
 
25bc372
 
 
 
4234e9e
 
 
25bc372
51b7902
 
1072676
 
25bc372
 
 
 
 
1072676
 
4234e9e
c8521a2
 
4234e9e
 
 
 
c8521a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4234e9e
 
 
1072676
 
 
 
4234e9e
 
 
 
 
 
 
 
1072676
4234e9e
1072676
 
4234e9e
 
 
 
25bc372
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
"""
Kaggle Cloud Data Collector for ViuMini-MoE-242M.
Streams massive open datasets, cleans with stable blake2b hash dedup,
chunks into ~150MB Parquet shards (configurable via --shard_mb, default 150),
uploads to Hugging Face dataset repo `ViuAI/viu-mini-raw-pretrain`, and immediately deletes
local temporary files to keep Kaggle disk usage < 2GB.

Features:
  - Smart Auto-Resume: Automatically queries HF dataset repo, finds existing shards,
    starts numbering at (max_shard_id + 1), and fast-skips already processed rows.
  - ~150MB Shards: keeps HF viewer + DataLoader stable (old 1GB shards caused 1.6GB
    english files + viewer HTTP 500). Row cap 100k also enforced.
  - Zero Sub-Sampling: Ingests full corpora without artificial truncation.

Usage on Kaggle:
  python data/scripts/kaggle_collector.py --stream all --repo ViuAI/viu-mini-raw-pretrain --shard_mb 150
  python data/scripts/kaggle_collector.py --stream hindi --repo ViuAI/viu-mini-raw-pretrain --shard_mb 150
"""
import argparse
import gc
import gzip
import hashlib
import itertools
import json
import os
from pathlib import Path
import re
import sys
import tempfile
import time

DEV = re.compile(r'[\u0900-\u097F]')
LAT = re.compile(r'[A-Za-z]')


def get_hf_token():
    """Retrieve HF token from Kaggle secrets, Colab secrets, env var, or user input."""
    # 1. Check Kaggle secrets
    try:
        from kaggle_secrets import UserSecretsClient
        tok = UserSecretsClient().get_secret("HF_TOKEN")
        if tok:
            return tok
    except Exception:
        pass
    # 2. Check Google Colab secrets
    try:
        from google.colab import userdata
        tok = userdata.get("HF_TOKEN")
        if tok:
            return tok
    except Exception:
        pass
    # 3. Check environment variable
    tok = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
    if tok:
        return tok
    return None


def stable_hash(s: str) -> int:
    """Stable 64-bit hash (PYTHONHASHSEED-independent). hash() is randomized per run,
    so dedup was unstable across Kaggle restarts -> duplicates. Use blake2b."""
    return int.from_bytes(hashlib.blake2b(s.encode("utf-8"), digest_size=8).digest(), "big")


def dev_ratio(s: str) -> float:
    dev = len(DEV.findall(s))
    lat = len(LAT.findall(s))
    tot = dev + lat
    return dev / max(tot, 1)


def is_valid_line(s: str, lang: str) -> bool:
    if len(s) < 10 or len(s.split()) < 3:
        return False
    if lang == "bilingual":
        return len(s) >= 15
    r = dev_ratio(s)
    if lang == "hinglish":
        # Must have Latin letters, Devanagari ratio <= 30%
        return bool(LAT.search(s)) and r <= 0.3
    elif lang == "hindi":
        # Must have Devanagari script
        return bool(DEV.search(s))
    elif lang == "english":
        # Must have Latin, zero Devanagari
        return bool(LAT.search(s)) and not bool(DEV.search(s))
    return True


def apply_skip(ds, skip_rows: int, source_name: str):
    """Applies fast skipping or falls back to itertools.islice."""
    if skip_rows <= 0:
        return ds
    try:
        ds = ds.skip(skip_rows)
        print(f"[resume] Fast-skipped {skip_rows:,} rows from {source_name}.")
        return ds
    except Exception as e:
        print(f"[resume] Fast-skip failed ({e}), using iterator slice for {source_name}...")
        return itertools.islice(ds, skip_rows, None)


def stream_hindi_data(skip_rows: int = 0):
    """Streams full IndicCorpV2 hin_Deva (152M rows) and Sangraha."""
    try:
        from datasets import load_dataset
        print("[source] Loading ai4bharat/IndicCorpV2 (indiccorp_v2, hin_Deva) streaming...")
        ds = load_dataset("ai4bharat/IndicCorpV2", "indiccorp_v2", split="hin_Deva", streaming=True)
        ds = apply_skip(ds, skip_rows, "IndicCorpV2")
        for r in ds:
            txt = (r.get("text") or "").strip()
            if txt:
                yield txt, "indiccorp_v2"
    except Exception as e:
        print(f"[warn] IndicCorpV2 stream error: {e}, falling back to sangraha...")
        try:
            from datasets import load_dataset
            ds = load_dataset("ai4bharat/sangraha", "verified", split="hin", streaming=True)
            ds = apply_skip(ds, skip_rows, "sangraha")
            for r in ds:
                txt = (r.get("text") or "").strip()
                if txt:
                    yield txt, "sangraha"
        except Exception as e2:
            print(f"[error] Sangraha stream error: {e2}")


def stream_english_data(skip_rows: int = 0):
    """Streams Fineweb-edu / TinyStories educational slices."""
    from datasets import load_dataset
    print("[source] Loading HuggingFaceFW/fineweb-edu (sample-10BT or train) streaming...")
    try:
        ds = load_dataset("HuggingFaceFW/fineweb-edu", split="train", streaming=True)
        ds = apply_skip(ds, skip_rows, "fineweb-edu")
        for r in ds:
            txt = (r.get("text") or "").strip()
            if txt:
                yield txt, "fineweb-edu"
    except Exception as e:
        print(f"[warn] fineweb-edu fallback to TinyStories: {e}")
        ds = load_dataset("roneneldan/TinyStories", split="train", streaming=True)
        ds = apply_skip(ds, skip_rows, "tinystories")
        for r in ds:
            txt = (r.get("text") or "").strip()
            if txt:
                yield txt, "tinystories"


def stream_hinglish_data(skip_rows: int = 0):
    """Streams sk-community/romanized_hindi and conversational corpora."""
    from datasets import load_dataset
    print("[source] Loading sk-community/romanized_hindi streaming...")
    try:
        ds = load_dataset("sk-community/romanized_hindi", split="train", streaming=True)
        ds = apply_skip(ds, skip_rows, "romanized_hindi")
        for r in ds:
            txt = (r.get("Transliterated Hindi") or r.get("hi_rom") or r.get("text") or "").strip()
            if txt:
                yield txt, "romanized_hindi"
    except Exception as e:
        print(f"[warn] sk-community/romanized_hindi stream error: {e}")


def stream_domain_data():
    """Streams curated 26-pillar knowledge from collect_domains."""
    try:
        from data.scripts.collect_domains import generate_domain_samples
        for rec in generate_domain_samples():
            yield rec["text"], rec["source"], rec["lang"], rec["domain"]
    except ImportError:
        try:
            from collect_domains import generate_domain_samples
            for rec in generate_domain_samples():
                yield rec["text"], rec["source"], rec["lang"], rec["domain"]
        except Exception as e:
            print(f"[warn] domain generator import failed: {e}")


def stream_translation_data(skip_rows: int = 0):
    """Streams parallel translation data from cfilt/iitb-english-hindi & Romanized Hindi."""
    from datasets import load_dataset
    print("[source] Loading cfilt/iitb-english-hindi parallel translation streaming...")
    try:
        ds = load_dataset("cfilt/iitb-english-hindi", split="train", streaming=True)
        ds = apply_skip(ds, skip_rows, "iitb-english-hindi")
        for r in ds:
            trans = r.get("translation") or {}
            en = (trans.get("en") or "").strip()
            hi = (trans.get("hi") or "").strip()
            if en and hi and len(en) >= 10 and len(hi) >= 10:
                text = f"English: {en}\nHindi: {hi}"
                yield text, "iitb-english-hindi", "bilingual", "translation"
    except Exception as e:
        print(f"[warn] iitb translation stream fallback: {e}")


def get_existing_shards_info(api, repo_id: str, stream_name: str):
    """
    Checks HF repo for existing shards in {stream_name}/.
    Returns (next_shard_id, max_shard_id, existing_count).
    """
    if not api:
        return 0, -1, 0
    try:
        files = api.list_repo_files(repo_id=repo_id, repo_type="dataset")
        prefix = f"{stream_name}/train-"
        shard_ids = []
        for f in files:
            if f.startswith(prefix) and (f.endswith(".parquet") or f.endswith(".jsonl.gz")):
                base = f[len(prefix):]
                num_str = base.split(".")[0]
                if num_str.isdigit():
                    shard_ids.append(int(num_str))
        if shard_ids:
            max_id = max(shard_ids)
            return max_id + 1, max_id, len(shard_ids)
    except Exception as e:
        print(f"[warn] Could not inspect existing shards for {stream_name}: {e}")
    return 0, -1, 0


def write_and_upload_shard(records, shard_id, stream_name, repo_id, api, dry_run=False, tmp_dir=None):
    """
    Writes records to a compressed parquet shard, uploads to HF,
    and immediately removes the local file and frees memory.
    """
    if tmp_dir is None:
        tmp_dir = Path(tempfile.gettempdir()) / "viu_shards"
    tmp_path = Path(tmp_dir) / stream_name
    tmp_path.mkdir(parents=True, exist_ok=True)

    file_name = f"train-{shard_id:05d}.parquet"
    out_file = tmp_path / file_name
    remote_path = f"{stream_name}/{file_name}"

    try:
        import pandas as pd
        df = pd.DataFrame(records)
        df.to_parquet(out_file, index=False, compression="snappy")
        del df
        gc.collect()
    except Exception as e:
        # Fallback to jsonl.gz if parquet/pandas is unavailable
        file_name = f"train-{shard_id:05d}.jsonl.gz"
        out_file = tmp_path / file_name
        remote_path = f"{stream_name}/{file_name}"
        with gzip.open(out_file, "wt", encoding="utf-8") as f:
            for r in records:
                f.write(json.dumps(r, ensure_ascii=False) + "\n")
        gc.collect()

    size_mb = out_file.stat().st_size / (1024 * 1024)
    print(f"[shard] Serialized {file_name} with {len(records):,} rows ({size_mb:.2f} MB)")

    if dry_run:
        print(f"[dry-run] Skipped upload of {out_file} to {repo_id}/{remote_path}")
        out_file.unlink(missing_ok=True)
        return True

    # Upload to HF with retry and exponential backoff + 429 rate limit handler (Overnight resilient)
    retries = 999
    success = False
    for attempt in range(1, retries + 1):
        try:
            api.upload_file(
                path_or_fileobj=str(out_file),
                path_in_repo=remote_path,
                repo_id=repo_id,
                repo_type="dataset",
                commit_message=f"Upload {remote_path} ({len(records):,} rows, {size_mb:.1f} MB)"
            )
            print(f"[hf] Uploaded successfully: {remote_path} ({size_mb:.2f} MB)")
            success = True
            break
        except Exception as err:
            err_str = str(err)
            if "429" in err_str or "rate limit" in err_str.lower():
                wait_time = 120
                m = re.search(r"Retry after (\d+) seconds", err_str)
                if m:
                    wait_time = int(m.group(1)) + 5
                print(f"[rate-limit] HF 128 commits/hr limit hit. Waiting {wait_time}s before retry ({attempt}/{retries})...")
                time.sleep(wait_time)
            else:
                print(f"[warn] Upload attempt {attempt}/{retries} failed: {err}")
                time.sleep(4 * attempt)

    # Immediately delete local temporary shard to keep Kaggle disk clean
    out_file.unlink(missing_ok=True)
    if not success:
        print(f"[error] Failed to upload {remote_path} after {retries} retries!")
    return success


def run_collector(stream_name, repo_id, shard_mb=150, shard_rows=100_000,
                  resume=True, skip_rows=None, limit=None, dry_run=False, token=None):
    from huggingface_hub import HfApi

    api = None
    if not dry_run:
        if not token:
            token = get_hf_token()
        if not token:
            print("[error] HF token missing! Set HF_TOKEN environment variable or Kaggle secret.")
            sys.exit(1)
        api = HfApi(token=token)
        try:
            api.create_repo(repo_id=repo_id, repo_type="dataset", exist_ok=True)
            print(f"[hf] Verified dataset repo: {repo_id}")
        except Exception as e:
            print(f"[warn] create_repo warning: {e}")

    # Determine starting shard_id and rows to skip if resuming
    # FIX: old code assumed 100k rows/shard even for 1.6GB english shards (~3-4M rows),
    # causing massive duplication on resume. Now shard_rows is enforced, so
    # skip = (max_id+1) * shard_rows is correct. For legacy 1GB shards, require
    # explicit --skip_rows or manual backfill (see docs/HF_REPAIR_RUNBOOK.md).
    shard_id = 0
    actual_skip = 0
    if resume and not dry_run:
        next_id, max_id, count = get_existing_shards_info(api, repo_id, stream_name)
        if count > 0:
            shard_id = next_id
            if skip_rows is None:
                actual_skip = (max_id + 1) * shard_rows
                print(f"[resume] Found {count} existing shards in '{stream_name}' (up to train-{max_id:05d}.parquet).")
                print(f"[resume] Resuming at shard_id={shard_id} (train-{shard_id:05d}.parquet), auto-skipping ~{actual_skip:,} rows.")
                print(f"[resume] NOTE: legacy 1GB shards (english/hindi-big) used variable rows. "
                      f"If resuming those, pass explicit --skip_rows. See docs/HF_REPAIR_RUNBOOK.md")
            else:
                actual_skip = skip_rows
                print(f"[resume] Resuming at shard_id={shard_id}, skipping specified {actual_skip:,} rows.")
        else:
            actual_skip = skip_rows or 0
    else:
        actual_skip = skip_rows or 0

    # Target byte calculation: Snappy parquet ~2.7x for hindi text, ~1.6x for english.
    # FIX: old default 1000MB caused 1.6GB english shards -> HF viewer 500 + OOM.
    # New default 150MB + hard row cap 100k keeps shards 30-150MB, viewer-stable.
    # Conservative ratio 2.0 to avoid overshoot on english.
    target_raw_bytes = int(shard_mb * 1024 * 1024 * 2.0)
    if stream_name == "domains":
        # Domain samples are curated; use smaller chunk threshold to flush promptly
        target_raw_bytes = int(min(shard_mb, 150) * 1024 * 1024 * 2.7)

    print(f"\n=== Starting Collector: stream={stream_name}, repo={repo_id} ===")
    print(f"    Target shard size: ~{shard_mb} MB compressed | Start shard: train-{shard_id:05d}.parquet | Skip: {actual_skip:,} rows")

    seen_hashes = set()
    current_records = []
    current_raw_bytes = 0
    total_kept = 0
    start_time = time.time()
    last_log_time = start_time

    # Determine generator
    if stream_name == "hindi":
        gen = ((txt, src, "hindi", "general_hindi") for txt, src in stream_hindi_data(skip_rows=actual_skip))
    elif stream_name == "english":
        gen = ((txt, src, "english", "general_english") for txt, src in stream_english_data(skip_rows=actual_skip))
    elif stream_name == "hinglish":
        gen = ((txt, src, "hinglish", "general_hinglish") for txt, src in stream_hinglish_data(skip_rows=actual_skip))
    elif stream_name == "domains":
        gen = stream_domain_data()
    elif stream_name == "translation":
        gen = stream_translation_data(skip_rows=actual_skip)
    else:
        raise ValueError(f"Unknown stream: {stream_name}")

    for item in gen:
        txt, src, lang, dom = item[0], item[1], item[2], item[3]
        s = txt.strip()
        if not is_valid_line(s, lang):
            continue

        # In-flight stable-hash deduplication (FIX: hash() was seed-randomized)
        # NOTE: seen_hashes grows with total rows. For >10M-row runs on Kaggle,
        # memory can blow up. We cap at 5M entries then clear oldest via sampling
        # reset to keep RAM <2GB (small duplicate leak acceptable vs OOM).
        h = stable_hash(s)
        if h in seen_hashes:
            continue
        seen_hashes.add(h)
        if len(seen_hashes) > 5_000_000:
            print("[dedup] seen_hashes >5M, clearing to bound RAM (minor dup leak accepted)")
            seen_hashes.clear()

        row_bytes = len(s.encode('utf-8'))
        current_records.append({
            "text": s,
            "lang": lang,
            "source": src,
            "domain": dom
        })
        current_raw_bytes += row_bytes
        total_kept += 1

        # Periodic status log every 100,000 rows or every 30 seconds
        now = time.time()
        if total_kept % 100_000 == 0 or (now - last_log_time) >= 30.0:
            elapsed = max(now - start_time, 1)
            rate = total_kept / elapsed
            est_parquet_mb = (current_raw_bytes / 2.7) / (1024 * 1024)
            print(f"[progress] {total_kept:,} rows | Shard buffer: {len(current_records):,} rows (~{est_parquet_mb:.1f} MB est. parquet) | {rate:.1f} rows/s")
            last_log_time = now

        # Flush condition: Target MB reached OR explicit row cap reached
        hit_bytes = current_raw_bytes >= target_raw_bytes
        hit_rows = (shard_rows is not None and len(current_records) >= shard_rows)
        if hit_bytes or hit_rows:
            write_and_upload_shard(current_records, shard_id, stream_name, repo_id, api, dry_run=dry_run)
            shard_id += 1
            current_records = []
            current_raw_bytes = 0
            gc.collect()

        if limit and total_kept >= limit:
            print(f"[limit] Reached limit of {limit:,} rows.")
            break

    # Flush remaining records
    if current_records:
        write_and_upload_shard(current_records, shard_id, stream_name, repo_id, api, dry_run=dry_run)
        shard_id += 1
        current_records = []
        gc.collect()

    elapsed = max(time.time() - start_time, 1)
    print(f"=== Collector Finished: {total_kept:,} total rows in this session ({elapsed:.1f}s, {total_kept/elapsed:.1f} rows/s) ===")


def main():
    parser = argparse.ArgumentParser(description="Kaggle High-Throughput Cloud Stream Collector")
    parser.add_argument("--stream", type=str, required=True,
                        choices=["hindi", "english", "hinglish", "domains", "translation", "all"],
                        help="Data stream to collect (or 'all' for sequential run)")
    parser.add_argument("--repo", type=str, default="ViuAI/viu-mini-raw-pretrain", help="HF dataset repo ID")
    parser.add_argument("--shard_mb", type=int, default=150, help="Target compressed Parquet shard size in MB (default 150 MB, viewer-stable; old 1000 MB caused 1.6GB shards + viewer 500)")
    parser.add_argument("--shard_rows", type=int, default=100000, help="Row cap per shard (default 100k; whichever hits first: bytes or rows)")
    parser.add_argument("--no-resume", action="store_true", help="Do not auto-resume; start from shard 0 and row 0")
    parser.add_argument("--skip_rows", type=int, default=None, help="Explicitly skip N rows before streaming")
    parser.add_argument("--limit", type=int, default=None, help="Optional max rows to collect")
    parser.add_argument("--dry-run", action="store_true", help="Run without uploading to HF")
    parser.add_argument("--token", type=str, default=None, help="HF API token")
    args = parser.parse_args()

    resume = not args.no_resume

    if args.stream == "all":
        # Prioritize Hinglish and English so they accumulate immediately alongside Hindi
        streams = ["domains", "translation", "hinglish", "english", "hindi"]
        for s in streams:
            print(f"\n==========================================")
            print(f"      STARTING STREAM: {s.upper()}")
            print(f"==========================================")
            # Smart check: if domains or translation are already fully uploaded, proceed immediately
            if resume and not args.dry_run:
                try:
                    from huggingface_hub import HfApi
                    check_api = HfApi(token=args.token or get_hf_token())
                    _, _, existing_cnt = get_existing_shards_info(check_api, args.repo, s)
                    if s == "domains" and existing_cnt >= 2:
                        print(f"[all] Stream 'domains' already fully uploaded ({existing_cnt} shards). Skipping to next...")
                        continue
                    if s == "translation" and existing_cnt >= 13:
                        print(f"[all] Stream 'translation' already fully uploaded ({existing_cnt} shards). Skipping to next...")
                        continue
                except Exception as e_chk:
                    print(f"[warn] Existing check error for {s}: {e_chk}")

            run_collector(
                stream_name=s,
                repo_id=args.repo,
                shard_mb=args.shard_mb,
                shard_rows=args.shard_rows,
                resume=resume,
                skip_rows=args.skip_rows,
                limit=args.limit,
                dry_run=args.dry_run,
                token=args.token
            )
    else:
        run_collector(
            stream_name=args.stream,
            repo_id=args.repo,
            shard_mb=args.shard_mb,
            shard_rows=args.shard_rows,
            resume=resume,
            skip_rows=args.skip_rows,
            limit=args.limit,
            dry_run=args.dry_run,
            token=args.token
        )


if __name__ == "__main__":
    main()