File size: 8,766 Bytes
d04142e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Weekly refresh of the Etimad tender-titles dataset.

  python scripts/weekly_update.py [--dry-run] [--max-pages 40] [--no-upload]

1. known refs  <- data/tenders_all.csv
2. crawl the unfiltered newest-first visitor listing exactly like scripts/crawl.py (same endpoint,
   headers, >= 8.6 s between requests, sequential) until two consecutive pages add no new ref, or --max-pages
3. normalise like build_dataset.py / make_splits.py (parent activity, spelling fold, frozen v1 label set)
4. append to data/tenders_all.csv, write data/published/updates/<date>.jsonl + .csv (train/test never touched)
5. refresh the "## Updates" table in DATASET-CARD.md
6. upload updates/ + card (as README.md) to the dataset repo and tag v<YYYY.MM.DD>   (skipped with --dry-run)
Idempotent: refs already in tenders_all.csv are never added twice."""
import argparse, csv, json, os, re, sys, time, datetime, logging
from collections import Counter
HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.abspath(os.path.join(HERE, ".."))
sys.path.insert(0, HERE)
from common import norm_text, sector_of, kind_of, okey

REPO = "chaliasif/etimad-tender-titles"
BASE = "https://tenders.etimad.sa/Tender/AllSupplierTendersForVisitorAsync"
H = {"X-Requested-With": "XMLHttpRequest", "Referer": "https://tenders.etimad.sa/Tender/AllTendersForVisitor"}
MIN_GAP = max(8.6, float(os.environ.get("MIN_GAP", "8.6")))
ALL_FIELDS = ["ref", "title", "agency", "branch", "activity", "ttype", "publish_date", "source"]
PUB_FIELDS = ["ref", "title", "agency", "branch", "activity", "activity_detail", "activity_label", "ttype", "sector", "kind", "publish_date", "source"]
ALL_CSV = os.path.join(ROOT, "data", "tenders_all.csv")
UPD_DIR = os.path.join(ROOT, "data", "published", "updates")
CARD = os.path.join(ROOT, "DATASET-CARD.md")
log = logging.getLogger("weekly")

def setup_log():
    os.makedirs(os.path.join(ROOT, "logs"), exist_ok=True)
    fmt = logging.Formatter("%(asctime)s %(message)s")
    log.setLevel(logging.INFO)
    for h in (logging.FileHandler(os.path.join(ROOT, "logs", "weekly_update.log"), encoding="utf-8"), logging.StreamHandler()):
        h.setFormatter(fmt); log.addHandler(h)

def frozen_labels():
    """v1 class list + surface forms, read from the published splits (never recomputed from counts)."""
    import pandas as pd
    df = pd.concat([pd.read_csv(os.path.join(ROOT, "data", "published", f"{n}.csv"), dtype=str).fillna("") for n in ("train", "test")])
    classes = set(df.activity_label) - {"other"}
    surf = {}
    for a, _ in Counter(df.activity).most_common(): surf.setdefault(okey(a), a)
    for a in classes: surf.setdefault(okey(a), a)
    return classes, surf

def normalise(t, classes, surf, src):
    g = lambda k: norm_text(t.get(k))
    full = g("tenderActivityName")
    parent = re.split(r"\s*-\s*", full, maxsplit=1)[0].strip()
    parent = surf.get(okey(parent), parent)
    row = {"ref": g("referenceNumber"), "title": g("tenderName"), "agency": g("agencyName"), "branch": g("branchName"),
           "activity": parent, "activity_detail": full, "activity_label": parent if parent in classes else "other",
           "ttype": g("tenderTypeName"), "sector": sector_of(parent), "publish_date": (t.get("submitionDate") or "")[:10], "source": src}
    row["kind"] = kind_of(parent, row["ttype"], row["title"])
    return row

def fetch(pg, state):
    from curl_cffi import requests
    qs = ("MultipleSearch=&TenderCategory=&ReferenceNumber=&TenderNumber=&AgencyCode=&PublishDateId=&TenderTypeId="
          "&TenderActivityId=&SortDirection=DESC&Sort=SubmitionDate&PageSize=24&PageNumber=%d&IsSearch=false" % pg)
    for attempt in range(3):
        wait = MIN_GAP - (time.time() - state["last"])
        if wait > 0: time.sleep(wait)
        state["last"] = time.time()
        try:
            j = requests.get(BASE + "?" + qs, impersonate="safari", timeout=45, headers=H).json()
            return j.get("data") or []
        except Exception as e:
            log.info(f"page {pg} attempt {attempt+1} failed: {str(e)[:80]}"); time.sleep(15 * (attempt + 1))
    raise RuntimeError(f"BLOCKED at page {pg}: 3 failures, stopping without retrying around it")

def update_card(date, new_total, total, tag):
    txt = open(CARD, encoding="utf-8").read()
    hdr = "| Date | New rows | Total rows | Tag |\n|---|---|---|---|\n"
    m = re.search(r"## Updates\n(.*?)(?=\n## )", txt, re.S)
    rows = re.findall(r"^\| (\d{4}-\d\d-\d\d) \| ([\d,]+) \| ([\d,]+) \| (\S+) \|$", m.group(1), re.M) if m else []
    if not rows: rows = [("2026-10-13", "0", f"{total - new_total:,}", "v1.0")]
    rows = [r for r in rows if r[0] != date] + [(date, f"{new_total:,}", f"{total:,}", tag)]
    block = ("## Updates\nWeekly refresh adds newly listed tenders under `updates/` (one file per date); `train` and `test` never change. "
             "Each tag freezes the repository at that date.\n\n" + hdr + "".join(f"| {a} | {b} | {c} | {d} |\n" for a, b, c, d in rows) + "\n")
    if m: txt = txt[:m.start()] + block.rstrip("\n") + "\n" + txt[m.end():]
    else: txt = txt.replace("## ملخص بالعربية", block + "## ملخص بالعربية", 1)
    open(CARD, "w", encoding="utf-8").write(txt)

def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--max-pages", type=int, default=40)
    ap.add_argument("--no-upload", action="store_true")
    a = ap.parse_args(); setup_log()
    today = datetime.date.today().isoformat(); src = f"weekly-{today}"
    log.info(f"start {today} dry_run={a.dry_run} max_pages={a.max_pages}")
    with open(ALL_CSV, encoding="utf-8", newline="") as f: known = {r["ref"] for r in csv.DictReader(f)}
    classes, surf = frozen_labels()
    log.info(f"known refs {len(known)}, frozen classes {len(classes)} (+other)")
    new, seen, state, empty_streak, pages = [], set(), {"last": 0.0}, 0, 0
    try:
        for pg in range(1, a.max_pages + 1):
            data = fetch(pg, state); pages += 1
            if not data: log.info(f"page {pg}: empty, end of listing"); break
            added = 0
            for t in data:
                r = normalise(t, classes, surf, src)
                if not (r["ref"] and r["title"] and r["activity"] and r["ttype"]) or r["ref"] in known or r["ref"] in seen: continue
                seen.add(r["ref"]); new.append(r); added += 1
            log.info(f"page {pg}: {len(data)} rows, {added} new (total new {len(new)})")
            empty_streak = 0 if added else empty_streak + 1
            if empty_streak >= 2: log.info("two consecutive pages with no new refs, stopping"); break
    except RuntimeError as e:
        log.info(str(e)); return 2
    log.info(f"crawled {pages} pages, {len(new)} new rows")
    if a.dry_run:
        log.info("dry run: nothing written"); [log.info("  " + json.dumps(r, ensure_ascii=False)) for r in new[:3]]; return 0
    if new:
        with open(ALL_CSV, "a", encoding="utf-8", newline="") as f:
            w = csv.DictWriter(f, ALL_FIELDS, extrasaction="ignore"); w.writerows(new)
        os.makedirs(UPD_DIR, exist_ok=True)
        jp, cp = os.path.join(UPD_DIR, today + ".jsonl"), os.path.join(UPD_DIR, today + ".csv")
        with open(jp, "a", encoding="utf-8") as f:
            for r in new: f.write(json.dumps({k: r[k] for k in PUB_FIELDS}, ensure_ascii=False) + "\n")
        rows = [json.loads(l) for l in open(jp, encoding="utf-8")]
        with open(cp, "w", encoding="utf-8", newline="") as f:
            w = csv.DictWriter(f, PUB_FIELDS); w.writeheader(); w.writerows(rows)
    jp = os.path.join(UPD_DIR, today + ".jsonl")
    day_new = sum(1 for _ in open(jp, encoding="utf-8")) if os.path.exists(jp) else 0
    with open(ALL_CSV, encoding="utf-8", newline="") as f: total = sum(1 for _ in csv.DictReader(f))
    tag = "v" + today.replace("-", ".")
    update_card(today, day_new, total, tag)
    log.info(f"day new rows {day_new}, total {total}, card updated, tag {tag}")
    if a.no_upload: return 0
    from huggingface_hub import HfApi
    api = HfApi()
    api.upload_file(path_or_fileobj=CARD, path_in_repo="README.md", repo_id=REPO, repo_type="dataset", commit_message=f"Card: update table {today}")
    if os.path.isdir(UPD_DIR) and os.listdir(UPD_DIR):
        api.upload_folder(repo_id=REPO, repo_type="dataset", folder_path=UPD_DIR, path_in_repo="updates", commit_message=f"Weekly update {today}: {day_new} new rows")
    api.create_tag(REPO, tag=tag, repo_type="dataset", tag_message=f"Weekly refresh {today}: +{day_new}, total {total}", exist_ok=True)
    log.info(f"uploaded and tagged {tag}")
    return 0
if __name__ == "__main__": sys.exit(main())