fis_wse

PAP Biznes ESPI Scraper

Apr 26th, 2026 (edited)
87
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 24.06 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. ESPI Scraper - biznes.pap.pl
  4. Pobiera komunikaty ESPI dla wybranej spolki i wypluwa w formacie do wklejenia
  5. do wskaznika ESPI Front-Runner w Pine Script.
  6.  
  7. Uzycie:
  8.    python3 espi_scraper.py 521 2026              # Polimex, rok 2026
  9.    python3 espi_scraper.py 521 2026 2025         # Polimex, 2026 i 2025
  10.    python3 espi_scraper.py 521 2026 --csv out.csv
  11.    python3 espi_scraper.py --list-companies "polimex"   # szukaj ID spolki
  12.    python3 espi_scraper.py --link <URL>          # pojedynczy artykul -> linia paste
  13.  
  14. Format URL na biznes.pap.pl:
  15.    https://biznes.pap.pl/espi/espi/{ROK}?company={ID}&selectCompany={ID}&limit=100&page={N}
  16.    https://biznes.pap.pl/wiadomosci/firmy/{slug}      <- single article (--link)
  17.  
  18. Timezone:
  19.    Strona podaje czas w Europe/Warsaw. Unix timestamp = UTC.
  20.    Pine Script wskaznika ESPI Front-Runner oczekuje Unix sekund UTC.
  21. """
  22.  
  23. from __future__ import annotations
  24. import argparse
  25. import csv
  26. import re
  27. import sys
  28. import time
  29. from dataclasses import dataclass, asdict
  30. from datetime import datetime
  31. from pathlib import Path
  32. from urllib.parse import urlencode
  33. from zoneinfo import ZoneInfo
  34.  
  35. import requests
  36. from bs4 import BeautifulSoup
  37.  
  38. WARSAW = ZoneInfo("Europe/Warsaw")
  39. UTC = ZoneInfo("UTC")
  40. BASE = "https://biznes.pap.pl"
  41. UA = "Mozilla/5.0 (compatible; ESPI-Scraper/1.0)"
  42.  
  43.  
  44. @dataclass
  45. class EspiEvent:
  46.     date: str            # YYYY-MM-DD (Europe/Warsaw)
  47.     time: str            # HH:MM (Europe/Warsaw)
  48.     unix_utc: int        # Unix seconds UTC
  49.     number: str          # "12/2026"
  50.     company: str
  51.     title: str
  52.     url: str
  53.     sentiment: int = 0   # -1 negative, 0 neutral, +1 positive
  54.  
  55.  
  56. # ============== SENTIMENT KEYWORD RULES ==============
  57. # Prosta heurystyka po tytule. Priorytet: negatywne > pozytywne > neutralne.
  58.  
  59. POSITIVE_KW = [
  60.     "zawarcie umowy", "istotn", "znaczac", "znaczn", "umow",
  61.     "kontrakt", "wygran", "zysk", "dywidend", "skup akcji wlasnych",
  62.     "skup wlasnych", "buyback", "rekomendacja", "nabyci", "zakup",
  63.     "intencyjn", "porozumieni", "aneks", "wzrost", "rozszerzeni",
  64.     "przedluzeni", "przyznan", "certyfikat", "zamowieni",
  65.     "podpisani", "zatwierdzeni ugod", "strateg",
  66. ]
  67.  
  68. NEGATIVE_KW = [
  69.     "strat", "rezygnacj", "odwolanie", "odwolan",
  70.     "zmniejszeni udzialu", "zbyci akcji", "sprzedaz akcji",
  71.     "emisja akcji", "wydanie akcji", "nowa emisja", "emisja obligacji",
  72.     "zamiana obligacji", "rozwodnieni", "obnizeni", "spadek",
  73.     "upadlosc", "restrukturyzacj", "postepowani", "pozew",
  74.     "zawiadomieni o zmianie udzialu", "zmiana udzialu w ogolnej",
  75.     "wezwanie", "wycofanie", "delisting", "zawieszenie",
  76.     "ostrzezenie", "kara", "sankcj", "wykreslenie",
  77. ]
  78.  
  79. NEUTRAL_KW = [
  80.     "terminy publikacji", "raport okresowy", "raport biezacy",
  81.     "kalendarium", "qsr", " ps ", " q ",
  82.     "zawiadomieni o transakcj",
  83. ]
  84.  
  85.  
  86. def _normalize(s: str) -> str:
  87.     # Remove Polish diacritics for matching robustness
  88.     table = str.maketrans("ąćęłńóśźżĄĆĘŁŃÓŚŹŻ", "acelnoszzACELNOSZZ")
  89.     return s.translate(table).lower()
  90.  
  91.  
  92. def classify_sentiment(title: str) -> int:
  93.     t = _normalize(title)
  94.     # Neutrals first (they'd otherwise match some positive keywords)
  95.     for kw in NEUTRAL_KW:
  96.         if kw in t:
  97.             return 0
  98.     neg = any(kw in t for kw in NEGATIVE_KW)
  99.     pos = any(kw in t for kw in POSITIVE_KW)
  100.     if neg and not pos:
  101.         return -1
  102.     if pos and not neg:
  103.         return 1
  104.     if neg and pos:
  105.         # Both found - weigh by which keyword appears first
  106.         neg_pos = min((t.find(kw) for kw in NEGATIVE_KW if kw in t), default=1_000_000)
  107.         pos_pos = min((t.find(kw) for kw in POSITIVE_KW if kw in t), default=1_000_000)
  108.         return -1 if neg_pos < pos_pos else 1
  109.     return 0
  110.  
  111.  
  112. def fetch(url: str) -> str:
  113.     r = requests.get(url, headers={"User-Agent": UA}, timeout=20)
  114.     r.raise_for_status()
  115.     return r.text
  116.  
  117.  
  118. def parse_page(html: str, path: str) -> list[EspiEvent]:
  119.     """
  120.    Struktura: tabela gdzie tr.day zawiera date, a kolejne tr zawieraja
  121.    komunikaty az do nastepnego tr.day.
  122.    """
  123.     soup = BeautifulSoup(html, "html.parser")
  124.     table = soup.find("table")
  125.     if not table:
  126.         return []
  127.  
  128.     events: list[EspiEvent] = []
  129.     current_date: str | None = None  # YYYY-MM-DD
  130.  
  131.     for tr in table.find_all("tr"):
  132.         day_td = tr.find("td", class_="day")
  133.         if day_td:
  134.             # format: "2026.04.15 - Środa"
  135.             m = re.search(r"(\d{4})\.(\d{2})\.(\d{2})", day_td.get_text(" ", strip=True))
  136.             if m:
  137.                 current_date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
  138.             continue
  139.  
  140.         tds = tr.find_all("td")
  141.         if len(tds) < 4 or current_date is None:
  142.             continue
  143.  
  144.         time_txt = tds[0].get_text(strip=True)
  145.         if not re.match(r"^\d{1,2}:\d{2}$", time_txt):
  146.             continue
  147.  
  148.         number = tds[1].get_text(strip=True)
  149.         company = tds[2].get_text(strip=True)
  150.         title_a = tds[3].find("a")
  151.         title = title_a.get_text(" ", strip=True) if title_a else tds[3].get_text(" ", strip=True)
  152.         href = title_a["href"] if title_a and title_a.has_attr("href") else ""
  153.         if href.startswith("/"):
  154.             href = BASE + href
  155.  
  156.         # Warsaw -> UTC
  157.         hh, mm = map(int, time_txt.split(":"))
  158.         y, mo, d = map(int, current_date.split("-"))
  159.         dt_warsaw = datetime(y, mo, d, hh, mm, tzinfo=WARSAW)
  160.         unix_utc = int(dt_warsaw.astimezone(UTC).timestamp())
  161.  
  162.         events.append(EspiEvent(
  163.             date=current_date,
  164.             time=time_txt,
  165.             unix_utc=unix_utc,
  166.             number=number,
  167.             company=company,
  168.             title=title,
  169.             url=href,
  170.             sentiment=classify_sentiment(title),
  171.         ))
  172.  
  173.     return events
  174.  
  175.  
  176. def parse_article(url: str) -> EspiEvent:
  177.     """
  178.    Parsuje pojedynczy artykul z biznes.pap.pl/wiadomosci/firmy/...
  179.    Wypluwa EspiEvent gotowy do format_paste().
  180.  
  181.    Selektory weryfikowane na realnym artykule:
  182.      - <h1 class="articleTitle"> ... </h1>      <- pelny tytul z numerem
  183.      - <div class="publicationDate">Publikacja: 2026-04-30 12:51</div>
  184.      - fallback tytulu z <title>...| Biznes PAP</title>
  185.      - fallback numeru z <p class="field--name-field-lead">Raport biezacy 13/2026</p>
  186.    """
  187.     html = fetch(url)
  188.     soup = BeautifulSoup(html, "html.parser")
  189.  
  190.     # ----- title -----
  191.     title = ""
  192.     h1 = soup.find("h1", class_="articleTitle")
  193.     if h1:
  194.         title = h1.get_text(" ", strip=True)
  195.     if not title:
  196.         t = soup.find("title")
  197.         if t:
  198.             # zwykle: "TYTUL | Biznes PAP" — odetnij sufiks
  199.             title = t.get_text(strip=True).rsplit("|", 1)[0].strip()
  200.     if not title:
  201.         raise ValueError(f"nie znalazlem tytulu w {url}")
  202.  
  203.     # ----- publikacja -----
  204.     pub_div = soup.find("div", class_="publicationDate")
  205.     if not pub_div:
  206.         raise ValueError(f"nie znalazlem <div class=publicationDate> w {url}")
  207.     pub_text = pub_div.get_text(" ", strip=True)
  208.     m = re.search(r"(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})", pub_text)
  209.     if not m:
  210.         raise ValueError(f"nie sparsowalem daty z '{pub_text}'")
  211.     y, mo, d, hh, mm = (int(g) for g in m.groups())
  212.     date_str = f"{y:04d}-{mo:02d}-{d:02d}"
  213.     time_str = f"{hh:02d}:{mm:02d}"
  214.  
  215.     # ----- numer raportu -----
  216.     number = ""
  217.     nm = re.search(r"\((\d+/\d{4})\)", title)
  218.     if nm:
  219.         number = nm.group(1)
  220.     else:
  221.         lead = soup.find("p", class_="field--name-field-lead")
  222.         if lead:
  223.             ln = re.search(r"(\d+/\d{4})", lead.get_text(" ", strip=True))
  224.             if ln:
  225.                 number = ln.group(1)
  226.  
  227.     # ----- spolka -----
  228.     company = ""
  229.     cm = re.match(r"^([^(]+)\(", title)
  230.     if cm:
  231.         company = cm.group(1).strip()
  232.  
  233.     # ----- unix UTC -----
  234.     dt_warsaw = datetime(y, mo, d, hh, mm, tzinfo=WARSAW)
  235.     unix_utc = int(dt_warsaw.astimezone(UTC).timestamp())
  236.  
  237.     return EspiEvent(
  238.         date=date_str,
  239.         time=time_str,
  240.         unix_utc=unix_utc,
  241.         number=number,
  242.         company=company,
  243.         title=title,
  244.         url=url,
  245.         sentiment=classify_sentiment(title),
  246.     )
  247.  
  248.  
  249. def scrape(company_id: int, year: int, limit: int = 100, max_pages: int = 20) -> list[EspiEvent]:
  250.     all_events: list[EspiEvent] = []
  251.     seen: set[tuple[str, str]] = set()
  252.  
  253.     for page in range(max_pages):
  254.         params = {
  255.             "company": company_id,
  256.             "selectCompany": company_id,
  257.             "limit": limit,
  258.             "page": page,
  259.         }
  260.         url = f"{BASE}/espi/espi/{year}?{urlencode(params)}"
  261.         html = fetch(url)
  262.         page_events = parse_page(html, url)
  263.  
  264.         new_count = 0
  265.         for e in page_events:
  266.             key = (e.date + e.time, e.number)
  267.             if key not in seen:
  268.                 seen.add(key)
  269.                 all_events.append(e)
  270.                 new_count += 1
  271.  
  272.         if new_count == 0:
  273.             break
  274.         time.sleep(0.5)  # Be nice
  275.  
  276.     return all_events
  277.  
  278.  
  279. def search_companies(query: str) -> list[tuple[str, str]]:
  280.     """Pobiera liste spolek ESPI (wszystkie strony) i zwraca pasujace do query."""
  281.     out = []
  282.     q = query.lower()
  283.     page = 0
  284.     while True:
  285.         url = f"{BASE}/espi/company" if page == 0 else f"{BASE}/espi/company?page={page}"
  286.         html = fetch(url)
  287.         soup = BeautifulSoup(html, "html.parser")
  288.         links = soup.select("a[href*='company=']")
  289.         if not links:
  290.             break
  291.         for a in links:
  292.             name = a.get_text(strip=True)
  293.             m = re.search(r"company=(\d+)", a["href"])
  294.             if m and name and q in name.lower():
  295.                 out.append((m.group(1), name))
  296.         # sprawdz czy jest nastepna strona
  297.         next_link = soup.select_one("a[rel='next']")
  298.         if not next_link:
  299.             break
  300.         page += 1
  301.         time.sleep(0.3)
  302.     return sorted(set(out), key=lambda x: int(x[0]))
  303.  
  304.  
  305. def format_pine_inputs(events: list[EspiEvent], max_slots: int = 10) -> str:
  306.     """
  307.    Generuje linie gotowe do wklejenia w inputs Pine Script.
  308.    Wskaznik ma 10 slotow -> bierzemy najnowsze (najwyzsze unix).
  309.    """
  310.     top = sorted(events, key=lambda e: e.unix_utc, reverse=True)[:max_slots]
  311.     lines = ["// === Wklej do inputs wskaznika ESPI Front-Runner ==="]
  312.     for i, e in enumerate(top, start=1):
  313.         lines.append(f"// #{i}: {e.date} {e.time} CET/CEST | {e.number} | {e.title[:80]}")
  314.         lines.append(f"//     unix = {e.unix_utc}")
  315.     lines.append("")
  316.     lines.append("// Skrocone (same liczby do szybkiego wklejenia):")
  317.     for i, e in enumerate(top, start=1):
  318.         lines.append(f"ev{i}  = {e.unix_utc}   // {e.date} {e.time}")
  319.     return "\n".join(lines)
  320.  
  321.  
  322. PINE_TEMPLATE = '''// @version=6
  323. // ESPI Front-Runner - "Przeciek" | {TICKER_UPPER}
  324. // Timestampy wygenerowane automatycznie przez espi_scraper.py
  325. // Wygenerowano: {GEN_DATE}
  326. // Eventow: {N_EVENTS}
  327.  
  328. indicator("ESPI Front-Runner [{TICKER_UPPER}]", shorttitle="ESPI-{TICKER_UPPER}", overlay=true, max_lines_count=500, max_labels_count=500, max_boxes_count=500)
  329.  
  330. // ============== INPUTS ==============
  331.  
  332. grpMode = "Tryb"
  333. modeSel = input.string("Auto", "Tryb analizy", options=["Auto", "Intraday (minuty)", "HTF bars (bary)"], group=grpMode)
  334.  
  335. grpWindow  = "Okno analizy - Intraday"
  336. leadMin    = input.int(5,   "Okno pre-ESPI (minuty)",   minval=1, maxval=60,  group=grpWindow)
  337. postMin    = input.int(5,   "Okno post-ESPI (minuty)",  minval=1, maxval=60,  group=grpWindow)
  338. baseMin    = input.int(60,  "Baseline lookback (minuty)",minval=15,maxval=600, group=grpWindow)
  339.  
  340. grpHtf = "Okno analizy - HTF"
  341. preBarsIn  = input.int(1,  "Pre bars",     minval=1, maxval=20, group=grpHtf)
  342. postBarsIn = input.int(1,  "Post bars",    minval=1, maxval=20, group=grpHtf)
  343. baseBarsIn = input.int(20, "Baseline bars",minval=5, maxval=200,group=grpHtf)
  344.  
  345. grpThresh  = "Progi anomalii"
  346. volMult    = input.float(3.0, "Volume x baseline do alertu",  minval=1.5, step=0.5, group=grpThresh)
  347. retPct     = input.float(0.5, "Return %  do alertu (|pre ret|)", minval=0.1, step=0.1, group=grpThresh)
  348.  
  349. grpView    = "Wyglad"
  350. showBox    = input.bool(true,  "Rysuj box okna pre",  group=grpView)
  351. showPost   = input.bool(true,  "Rysuj box okna post", group=grpView)
  352. showLabel  = input.bool(true,  "Etykieta z metrykami",group=grpView)
  353. colPre     = input.color(color.new(color.red, 75),    "Kolor box pre",    group=grpView)
  354. colPost    = input.color(color.new(color.green, 80),  "Kolor box post",   group=grpView)
  355. colLine    = input.color(color.yellow,                "Kolor linii ESPI", group=grpView)
  356.  
  357. // ============== ESPI EVENTS ({N_EVENTS}) ==============
  358. var int[] events = array.from(
  359. {EVENTS_BLOCK}
  360. )
  361.  
  362. // ============== TRYB ==============
  363. tfSec = timeframe.in_seconds(timeframe.period)
  364. htfAuto  = tfSec >= 15 * 60
  365. useHtf   = modeSel == "HTF bars (bary)" or (modeSel == "Auto" and htfAuto)
  366.  
  367. preBars  = useHtf ? preBarsIn  : math.max(1, math.round(leadMin * 60 / tfSec))
  368. postBars = useHtf ? postBarsIn : math.max(1, math.round(postMin * 60 / tfSec))
  369. baseBars = useHtf ? baseBarsIn : math.max(1, math.round(baseMin  * 60 / tfSec))
  370.  
  371. msPerMin  = 60 * 1000
  372. barSizeMs = tfSec * 1000
  373.  
  374. // ============== LOGIKA ==============
  375.  
  376. var line[]  lns   = array.new_line()
  377. var box[]   bxPre = array.new_box()
  378. var box[]   bxPost= array.new_box()
  379. var label[] lbls  = array.new_label()
  380.  
  381. f_drawnAlready(int ts) =>
  382.    found = false
  383.    if array.size(lns) > 0
  384.        for i = 0 to array.size(lns) - 1
  385.            ln = array.get(lns, i)
  386.            if line.get_x1(ln) == ts * 1000
  387.                found := true
  388.                break
  389.    found
  390.  
  391. if array.size(events) > 0
  392.    for i = 0 to array.size(events) - 1
  393.        ts = array.get(events, i)
  394.        tsMs = ts * 1000
  395.  
  396.        eventBarStart = tsMs - (tsMs % barSizeMs)
  397.        postEnd       = eventBarStart + postBars * barSizeMs
  398.        preStart      = eventBarStart - preBars * barSizeMs
  399.  
  400.        if time >= postEnd and time < postEnd + barSizeMs and not f_drawnAlready(ts)
  401.            postFirst = 1
  402.            postLast  = postBars
  403.            preFirst  = postBars + 1
  404.            preLast   = postBars + preBars
  405.            baseFirst = postBars + preBars + 1
  406.            baseLast  = postBars + preBars + baseBars
  407.  
  408.            baseVolSum = 0.0
  409.            for k = baseFirst to baseLast
  410.                baseVolSum += nz(volume[k])
  411.            baseVolAvg = baseVolSum / baseBars
  412.  
  413.            preVolSum = 0.0
  414.            for k = preFirst to preLast
  415.                preVolSum += nz(volume[k])
  416.            preVolAvg = preVolSum / preBars
  417.  
  418.            postVolSum = 0.0
  419.            for k = postFirst to postLast
  420.                postVolSum += nz(volume[k])
  421.            postVolAvg = postVolSum / postBars
  422.  
  423.            pPreAnchor = close[preLast + 1]
  424.            pPreEnd    = close[preFirst]
  425.            retPre     = (pPreEnd / pPreAnchor - 1) * 100
  426.  
  427.            pPostAnchor= close[postLast + 1]
  428.            pPostEnd   = close[postFirst]
  429.            retPost    = (pPostEnd / pPostAnchor - 1) * 100
  430.  
  431.            volRatio     = baseVolAvg > 0 ? preVolAvg  / baseVolAvg : 0.0
  432.            volRatioPost = baseVolAvg > 0 ? postVolAvg / baseVolAvg : 0.0
  433.  
  434.            ln = line.new(tsMs, low - (high-low), tsMs, high + (high-low), xloc=xloc.bar_time, extend=extend.both, color=colLine, width=2, style=line.style_dashed)
  435.            array.push(lns, ln)
  436.  
  437.            if showBox
  438.                priceHi = ta.highest(high, preBars)[preFirst]
  439.                priceLo = ta.lowest (low,  preBars)[preFirst]
  440.                b = box.new(preStart, priceHi, eventBarStart, priceLo, xloc=xloc.bar_time, bgcolor=colPre, border_color=color.new(color.red, 30))
  441.                array.push(bxPre, b)
  442.  
  443.            if showPost
  444.                priceHi2 = ta.highest(high, postBars)[postFirst]
  445.                priceLo2 = ta.lowest (low,  postBars)[postFirst]
  446.                b2 = box.new(eventBarStart, priceHi2, postEnd, priceLo2, xloc=xloc.bar_time, bgcolor=colPost, border_color=color.new(color.green, 30))
  447.                array.push(bxPost, b2)
  448.  
  449.            if showLabel
  450.                suspicious = (math.abs(retPre) >= retPct) or (volRatio >= volMult)
  451.                modeStr = useHtf ? "HTF" : "INTRA"
  452.                unit    = useHtf ? "bars" : "min"
  453.                txt = (suspicious ? "⚠ PRZECIEK?\\n" : "ESPI\\n") +
  454.                     "[" + modeStr + " " + str.tostring(preBars) + "/" + str.tostring(postBars) + " " + unit + "]\\n" +
  455.                     "pre ret: "  + str.tostring(retPre,  "#.##") + "%\\n" +
  456.                     "post ret: " + str.tostring(retPost, "#.##") + "%\\n" +
  457.                     "pre vol x base: "  + str.tostring(volRatio,     "#.##") + "x\\n" +
  458.                     "post vol x base: " + str.tostring(volRatioPost, "#.##") + "x"
  459.                lb = label.new(tsMs, high, txt, xloc=xloc.bar_time, style=label.style_label_down, color=suspicious ? color.new(color.red, 20) : color.new(color.gray, 30), textcolor=color.white, size=size.small)
  460.                array.push(lbls, lb)
  461. '''
  462.  
  463.  
  464. def write_pine_full(events: list[EspiEvent], path: Path, ticker: str) -> None:
  465.     """Generuje gotowy Pine Script ze wszystkimi eventami w array.from()."""
  466.     # Pine ma limit ~4096 tokenow w jednej linii, wiec dzielimy na wiele linii
  467.     sorted_events = sorted(events, key=lambda e: e.unix_utc, reverse=True)
  468.     lines = []
  469.     for e in sorted_events:
  470.         comment = f"{e.date} {e.time} | {e.number} | {e.title[:60]}"
  471.         lines.append(f"     {e.unix_utc},  // {comment}")
  472.     # remove trailing comma on last item (Pine doesn't like it)
  473.     if lines:
  474.         last = lines[-1]
  475.         lines[-1] = last.replace(",  //", "   //", 1)
  476.  
  477.     events_block = "\n".join(lines)
  478.  
  479.     out = PINE_TEMPLATE.format(
  480.         TICKER_UPPER=ticker.upper(),
  481.         GEN_DATE=datetime.now(WARSAW).strftime("%Y-%m-%d"),
  482.         N_EVENTS=len(sorted_events),
  483.         EVENTS_BLOCK=events_block,
  484.     )
  485.     path.write_text(out, encoding="utf-8")
  486.  
  487.  
  488. def format_paste(events: list[EspiEvent], compact: bool = False) -> str:
  489.     """
  490.    Format do wklejenia w input.text_area:
  491.        <unix> <sentiment> # <data> <godzina> | <numer> | <tytul>
  492.    Pine parser: pierwsza liczba = timestamp, druga (ze znakiem) = sentiment.
  493.  
  494.    compact=True: pomija komentarz (#...), samo <unix> <sentiment>.
  495.    TradingView input.text_area limit ~4096 znakow.
  496.    Pelny format: ~110 znakow/event -> max ~37 eventow.
  497.    Compact:       ~12 znakow/event -> max ~340 eventow.
  498.    """
  499.     sorted_events = sorted(events, key=lambda e: e.unix_utc, reverse=True)
  500.     lines = []
  501.     for e in sorted_events:
  502.         sent_str = f"{e.sentiment:+d}"
  503.         if compact:
  504.             lines.append(f"{e.unix_utc} {sent_str}")
  505.         else:
  506.             lines.append(f"{e.unix_utc} {sent_str} # {e.date} {e.time} | {e.number} | {e.title[:70]}")
  507.     return "\n".join(lines)
  508.  
  509.  
  510. def write_csv(events: list[EspiEvent], path: Path) -> None:
  511.     with path.open("w", newline="", encoding="utf-8") as f:
  512.         w = csv.writer(f)
  513.         w.writerow(["date", "time", "unix_utc", "sentiment", "number", "company", "title", "url"])
  514.         for e in sorted(events, key=lambda e: e.unix_utc):
  515.             w.writerow([e.date, e.time, e.unix_utc, e.sentiment, e.number, e.company, e.title, e.url])
  516.  
  517.  
  518. CACHE_DIR = Path.home() / ".espi_cache"
  519.  
  520.  
  521. def cache_path(company_id: int) -> Path:
  522.     return CACHE_DIR / f"{company_id}.txt"
  523.  
  524.  
  525. def save_cache(company_id: int, paste: str) -> Path:
  526.     CACHE_DIR.mkdir(exist_ok=True)
  527.     p = cache_path(company_id)
  528.     p.write_text(paste, encoding="utf-8")
  529.     return p
  530.  
  531.  
  532. def load_from_file(path: Path) -> str:
  533.     if not path.exists():
  534.         raise FileNotFoundError(f"Plik nie istnieje: {path}")
  535.     return path.read_text(encoding="utf-8")
  536.  
  537.  
  538. def main() -> int:
  539.     ap = argparse.ArgumentParser(description="Scrape ESPI events from biznes.pap.pl")
  540.     ap.add_argument("company_id", nargs="?", type=int, help="ID spolki (np. 521 dla Polimex)")
  541.     ap.add_argument("years", nargs="*", type=int, help="Lata do pobrania (np. 2026 2025)")
  542.     ap.add_argument("--csv", type=Path, help="Zapisz wszystkie eventy do CSV")
  543.     ap.add_argument("--slots", type=int, default=10, help="Ile topowych eventow do Pine inputs (domyslnie 10)")
  544.     ap.add_argument("--pine-full", type=Path, help="Wygeneruj gotowy plik Pine ze WSZYSTKIMI eventami jako array.from()")
  545.     ap.add_argument("--ticker", default="TICKER", help="Ticker do nazwy wskaznika (np. PXM)")
  546.     ap.add_argument("--paste", action="store_true", help="Wypluj format do wklejenia w input.text_area Pine (jeden timestamp per linia + komentarz)")
  547.     ap.add_argument("--compact", action="store_true", help="Jak --paste ale bez komentarzy: samo <unix> <sentiment>. Miesci sie w limicie 4096 znakow TradingView dla setek eventow.")
  548.     ap.add_argument("--list-companies", metavar="QUERY", help="Znajdz ID spolki po nazwie")
  549.     ap.add_argument("--link", metavar="URL", help="Pojedynczy artykul z biznes.pap.pl -> jedna linia paste (unix +sent # data godz | nr | tytul)")
  550.     ap.add_argument("--load", metavar="COMPANY_ID", type=int, help="Wczytaj paste z cache (~/.espi_cache/<ID>.txt) zamiast scrapowac")
  551.     ap.add_argument("--from-file", metavar="PATH", type=Path, dest="from_file", help="Wczytaj paste z podanego pliku zamiast scrapowac")
  552.     ap.add_argument("--save", metavar="PATH", type=Path, help="Zapisz paste do pliku (dodatkowo do stdout)")
  553.     ap.add_argument("--no-cache", action="store_true", help="Nie zapisuj automatycznie do cache po scrapowaniu")
  554.     args = ap.parse_args()
  555.  
  556.     if args.list_companies:
  557.         results = search_companies(args.list_companies)
  558.         if not results:
  559.             print(f"Brak dopasowan dla '{args.list_companies}'", file=sys.stderr)
  560.             return 1
  561.         for cid, name in results:
  562.             print(f"{cid:>6}  {name}")
  563.         return 0
  564.  
  565.     if args.link:
  566.         try:
  567.             ev = parse_article(args.link)
  568.         except Exception as e:
  569.             print(f"BLAD: {e}", file=sys.stderr)
  570.             return 1
  571.         print(format_paste([ev]))
  572.         return 0
  573.  
  574.     # --- tryb: wczytaj z pliku zamiast scrapowac ---
  575.     if args.load is not None:
  576.         p = cache_path(args.load)
  577.         try:
  578.             paste = load_from_file(p)
  579.         except FileNotFoundError:
  580.             print(f"Brak cache dla ID {args.load}. Uzyj najpierw: espi {args.load} <lata>", file=sys.stderr)
  581.             return 1
  582.         print(paste)
  583.         return 0
  584.  
  585.     if args.from_file is not None:
  586.         try:
  587.             paste = load_from_file(args.from_file)
  588.         except FileNotFoundError as e:
  589.             print(f"BLAD: {e}", file=sys.stderr)
  590.             return 1
  591.         print(paste)
  592.         return 0
  593.  
  594.     if not args.company_id or not args.years:
  595.         ap.print_help()
  596.         return 1
  597.  
  598.     all_events: list[EspiEvent] = []
  599.     for year in args.years:
  600.         print(f"[*] Scraping company={args.company_id} year={year}...", file=sys.stderr)
  601.         evs = scrape(args.company_id, year)
  602.         print(f"    -> {len(evs)} events", file=sys.stderr)
  603.         all_events.extend(evs)
  604.  
  605.     if not all_events:
  606.         print("Brak eventow.", file=sys.stderr)
  607.         return 1
  608.  
  609.     if args.csv:
  610.         write_csv(all_events, args.csv)
  611.         print(f"[*] CSV: {args.csv}", file=sys.stderr)
  612.  
  613.     if args.pine_full:
  614.         write_pine_full(all_events, args.pine_full, args.ticker)
  615.         print(f"[*] Pine (full): {args.pine_full} ({len(all_events)} events)", file=sys.stderr)
  616.         return 0
  617.  
  618.     compact = args.compact
  619.     paste = format_paste(all_events, compact=compact)
  620.  
  621.     # auto-cache po scrapowaniu (mozna wylaczyc --no-cache)
  622.     if not args.no_cache and args.company_id:
  623.         saved = save_cache(args.company_id, paste)
  624.         print(f"[*] Cache: {saved}", file=sys.stderr)
  625.  
  626.     # jawny zapis do pliku (--save)
  627.     if args.save:
  628.         args.save.parent.mkdir(parents=True, exist_ok=True)
  629.         args.save.write_text(paste, encoding="utf-8")
  630.         print(f"[*] Zapisano: {args.save}", file=sys.stderr)
  631.  
  632.     print(paste)
  633.     return 0
  634.  
  635.  
  636. if __name__ == "__main__":
  637.     sys.exit(main())
  638.  
Add Comment
Please, Sign In to add comment