Newsletter 365tipů můžete číst zdarma. Placené předplatné je dobrovolná podpora, která pomáhá webu pokračovat.
Podpořit 365tipů

TIP#3080: Jak (s pomocí Pythonu) získat čistý obsah z určité URL. Bez balastu a do schránky pro další použití

Tohle je vlastně jen vedlejší efekt sumarizace přes ChatGPT API – už tam je totiž dobře fungující skript co stáhne z URL jen samotný obsah. 

Ukázalo se ale, že něco takového se bude hodit i samostatně – ChatGPT totiž velmi často nedokáže otevřít webovou adresu (weby ji blokují, jsou tam nesmyslné cookie lišty, atd), takže je třeba obsah z této adresy poskytnout ručně. 

Jenže ukládat to jako PDF nebo to copy-paste vkládat postupně do ChatGPT není moc šikovné. 

Co ale jde, je využít už hotového skriptu a jen ho osamostatnit. Ten vezme URL ve schránce/clipboard, stáhne obsah pomocí trafilatura a stažené vloží do schránky (clipboardu). Zachováno zůstalo i logování, spouštění pomocí RayCastu na jedné klávese.

Vrací čistý text, ale trafilatura umí vrátit i markdown – jen je ve volání trafilature.extract případně nutné doplnit jeden parametr navíc – output_format=“markdown“

Ještě by šlo udělat to, že sumarizační python si zavolá tento skript, aby v tom sumarizačním nemusel být znovu ten samý kód. Ale to si nechám někdy na později.

TIP: Sumarizační skript viz Jak na ChatGPT API pro souhrn článků na Internetu. Další velmi dobrý příklad včetně kompletního skriptu 💎

Funguje to?

Drtivá většina použití funguje dobře, čas od času to ale nevrátí obsah, nebo to vrátí jen nějaké zmatky – jsou weby co například generují obsah stránky až v prohlížeči na klientovi. Nebo mají nějakou zoufale šílenou paranoidní ochranu proti robotům. Případně vyžadují nějaké kliknutí (ne nutně paywall, ale může být i ten), aby se dalo dostat k obsahu.

Na podobné případy je tohle řešení krátké, ale není problém to pro tenhle účet prostě otevřít “starým způsobem”, tedy klasicky v prohlížeči a použít buď PDF nebo copy/paste.

To že to dobře funguje je věc postupného ladění pro předchozí použití – ukázalo se, že je nutné mít user-agent co opravdu vypadá jako prohlížeč (Chrome), ohlídat si aby server neposílal gzip (dalo by se řešit i rozbalením, dá se to detekovat). Jsou tam doplněné i nějak fall-back na amp verze a podobně (ale k tomu skoro nedochází).

Pro případné další ladění je tam nechané aktivní logování – v .log souboru je vidět co se dělo a do .html souboru se ukládá co vlastně přesně přišlo jako odpověď. 

Samotný grabURL.py

Připomenu, že vše se odehrává ze schránky do schránky a jde o Python, který lze spouštět i z Terminalu, ale v mém případě je přes RayCast spouštěný klávesovou zkratkou.

#!/usr/bin/env python3
# @raycast.schemaVersion 1
# @raycast.title Copy Article Text from URL
# @raycast.mode compact
# @raycast.packageName Tools
# @raycast.icon 📰

import os, sys, re, subprocess, urllib.error, traceback, datetime, pathlib, urllib.request

# --- Log do stejné složky jako skript (při startu vymažeme) ---
LOG_FILE = pathlib.Path(__file__).with_suffix(".log")
try:
 LOG_FILE.write_text("", encoding="utf-8")
except Exception:
 pass

def log(msg: str):
 ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 try:
 with open(LOG_FILE, "a", encoding="utf-8") as f:
 f.write(f"[{ts}] {msg}\n")
 except Exception:
 pass

# --- util ---
def pbpaste() -> str:
 return subprocess.check_output("pbpaste", text=True)

def pbcopy(text: str) -> None:
 subprocess.run("pbcopy", text=True, input=text, check=True)

def beep(times=1):
 try:
 subprocess.run(["osascript", "-e", f'beep {times}'])
 except Exception:
 pass

def is_url(s: str) -> bool:
 return bool(re.match(r'^https?://', s.strip(), re.I))

# --- HTTP fetch s fallbacky ---
def fetch_with_status(url: str, timeout=20):
 import gzip

 headers = {
 "User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) "
 "AppleWebKit/537.36 (KHTML, like Gecko) "
 "Chrome/126.0.0.0 Safari/537.36"),
 "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9,"
 "image/avif,image/webp,image/apng,*/*;q=0.8"),
 "Accept-Language": "cs-CZ,cs;q=0.9,en-US;q=0.8,en;q=0.7",
 "Cache-Control": "no-cache",
 "Pragma": "no-cache",
 "Connection": "keep-alive",
 "Upgrade-Insecure-Requests": "1",
 }
 req = urllib.request.Request(url, headers=headers)

 try:
 with urllib.request.urlopen(req, timeout=timeout) as resp:
 status = getattr(resp, "status", None)
 hdrs = dict(resp.headers)
 log(f"HTTP status: {status}")
 log(f"Content-Type: {hdrs.get('Content-Type', 'NA')}")
 raw = resp.read()

 # manuální gzip, pokud server neposlal Content-Encoding
 if raw.startswith(b"\x1f\x8b"):
 try:
 raw = gzip.decompress(raw)
 log("Applied manual gzip decompression")
 except Exception as e:
 log(f"Gzip decompress failed: {e}")

 charset = "utf-8"
 m = re.search(r"charset=([a-zA-Z0-9_\-]+)", hdrs.get("Content-Type", ""))
 if m:
 charset = m.group(1).strip()

 try:
 html = raw.decode(charset, errors="ignore")
 except Exception:
 html = raw.decode("utf-8", errors="ignore")

 return status, hdrs, html

 except urllib.error.HTTPError as e:
 log(f"HTTPError {e.code}: {e.reason}")
 return e.code, {}, None
 except urllib.error.URLError as e:
 log(f"URLError: {e.reason}")
 return None, {}, None
 except Exception as e:
 log(f"FETCH EXCEPTION: {e}")
 return None, {}, None

def try_fallback_urls(original_url: str):
 cands = []
 base = original_url[:-1] if original_url.endswith("/") else original_url
 cands.extend([
 base + "/amp",
 base + "/amp/",
 base + "?outputType=amp",
 base + "?amp",
 base + "/print",
 base + "/print/",
 ])
 seen, result = set(), []
 for u in cands:
 if u not in seen:
 result.append(u); seen.add(u)
 return result

# --- Extrakce článku (trafilatura) ---
def extract_article_text(url: str) -> str:
 try:
 import trafilatura
 except Exception:
 print("ERROR: chybí 'trafilatura'. Nainstaluj: python3 -m pip install --user trafilatura")
 sys.exit(1)

 log(f"Fetching {url}")
 status, headers, html = fetch_with_status(url)
 if not html or (status in (401, 403)):
 log(f"Primary fetch failed or blocked (status={status}); trying fallbacks…")
 for fu in try_fallback_urls(url):
 log(f"Fallback fetch {fu}")
 status, headers, html = fetch_with_status(fu)
 if html and (status is None or 200 <= status < 300):
 log(f"Fallback OK with {fu}")
 break

 if not html:
 raise RuntimeError(f"Stahování selhalo, status={status}, hlavičky={headers}")

 try:
 text = trafilatura.extract(
 html,
 include_comments=False,
 include_tables=False,
 favor_recall=True,
 with_metadata=False, # čistý text
 )
 except Exception as e:
 log(f"trafilatura.extract exception: {type(e).__name__}: {e}")
 raise

 clean = (text or "").strip()

 # minimální sanity check
 if len(clean) < 200:
 preview = (clean[:500] + ("…" if len(clean) > 500 else "")) if clean else "None"
 log(f"Extraction too short ({len(clean)} chars). Preview:\n{preview}")
 # Pro debug ulož kus HTML
 debug_html = LOG_FILE.with_suffix(".debug.html")
 try:
 debug_html.write_text(html[:20000], encoding="utf-8", errors="ignore")
 log(f"Saved first 20kB of HTML to {debug_html}")
 except Exception as e:
 log(f"Failed to write debug HTML: {e}")
 raise RuntimeError("Extrakce nenašla dostatek článkového textu.")

 # lehké vyčištění nových řádků
 clean = re.sub(r'\n{3,}', '\n\n', clean).strip()
 log(f"Extracted text length: {len(clean)} chars")
 return clean

def main():
 clip = pbpaste().strip()
 if not is_url(clip):
 print("ERROR: Ve schránce není URL")
 beep(3); sys.exit(1)

 try:
 article_text = extract_article_text(clip)
 except Exception as e:
 log("Extract ERROR: " + str(e))
 print(f"ERROR: Extrakce textu selhala: {e}")
 beep(3); sys.exit(1)

 # výsledek do schránky + echo do konzole
 pbcopy(article_text)
 print(f"OK ({len(article_text)} znaků) – text článku je ve schránce.")
 log("Done OK")
 beep(1)

if __name__ == "__main__":
 try:
 main()
 except Exception as e:
 log("FATAL ERROR: " + str(e))
 log(traceback.format_exc())
 print(f"ERROR: {e}")
 beep(3)
 sys.exit(1)