fetch() reads URLs with plain HTTP or a browser-backed tier. to_md() cleans HTML. The module also crawls sites, discovers XHR calls, and reads YouTube, arXiv, and GitHub sources.
def to_md( res_or_html, # Page dict (from fetch/crawl) or raw HTML string sel:str=None, # CSS selector to extract before conversion; returns '' if no match multi:bool=False, # Return all selector matches joined wrap_tag:str=None, # Wrap each multi-result in <wrap_tag>...</wrap_tag>; only used when multi=True ignore_links:bool=True, ignore_images:bool=False, readability:bool=True, # False keeps the whole document, for local files and saved pages)->str:
Convert a Page dict or HTML string to clean markdown
The document’s own title: <title> if present, else the first <h1>
to_md/html2md run readability’s article extractor by default — right for a fetched page, since it drops nav, ads and furniture. For a document somebody saved deliberately, pass readability=False: it converts the whole document, minus script/style/noscript/template and comments.
SSRF guard
check_url blocks private/link-local/metadata targets unless FOSSICK_ALLOW_PRIVATE or allow_private=True.
Fetch url. session=True (or a port int) drives the persistent debug Chrome via CDP, reusing its logged-in cookies.
Every fetch in fossick arrives here, which is why the private-address check lives here and not in fetch: crawl, replay_xhr, download_files and http_get would each have to remember.
try: get_page('https://httpbun.com/put', method='PUT', verify=False)exceptAssertionErroras e: test_fail(str(e) =='Only GET and POST methods are supported')
def fetch( url:str, # URL to fetch sel:str=None, # CSS selector to extract (None = full page) method:str='GET', # HTTP method; 'POST' sends payload as JSON body payload:dict=None, # POST body (JSON) or GET query params heavy:bool=False, # Full JS rendering via headless browser stealthy:bool=False, # Anti-bot stealth fetcher (Cloudflare etc.) capture_xhr:bool=False, # Capture XHR calls made by the page (only works with heavy or stealthy) session:bool=False, # Route through the persistent debug Chrome (reuses its logged-in cookies); True or a port int auto:bool=False, # Auto-escalate plain->heavy->stealthy->session on bot-block detection allow_private:bool=None, # Allow localhost/intranet targets (default: refuse; $FOSSICK_ALLOW_PRIVATE=1)**kw)->Response: # Extra kwargs passed to scrapling (e.g. verify, headers)
Fetch url, return a Response with .html (raw or sel-extracted), .xhr, .status.
Bot walls
_blocked() detects Cloudflare/Anubis-style interstitials. auto=True escalates tiers until one is clean.
# A site behind Cloudflare: a plain fetch hits the bot wall, and _blocked() flags it for escalationcf = fetch('https://www.scrapingcourse.com/cloudflare-challenge', verify=False)assert cf.status ==403, f'expected a Cloudflare 403 challenge, got {cf.status}'assert _blocked(cf) isTrue# fetch(auto=True) escalates from here (heavy -> stealthy -> session)print('cloudflare:', cf.status, '| blocked?', _blocked(cf))
# A site behind Anubis (proof-of-work bot wall): _blocked() now recognizes it, so fetch(auto=True) escalatesan = fetch('https://anubis.techaro.lol/', verify=False)assert'making sure'in an.html.lower(), 'expected the Anubis challenge page'assert _blocked(an) isTrue# Anubis markers are in _BLOCK_MARKERS -> auto escalates past this wallprint('anubis wall detected by _blocked?', _blocked(an))
# regression: a normal page that merely *mentions* captcha must NOT read as a bot wall.# Wikipedia embeds hCaptcha config keys in its page JS -- the word is there, but there's no challenge widget.wiki = fetch('https://en.wikipedia.org/wiki/Anubis', verify=False)assert wiki.status ==200and'captcha'in wiki.html.lower() # the word IS present on the page...assert _blocked(wiki) isFalse# ...but _BLOCK_MARKERS matches captcha *widgets* (g-recaptcha/h-captcha/...), not the word
# fetch(auto=True): walks plain->heavy->stealthy->session, stops at the first unblocked tier, tags .tierplain = fetch('https://example.com', auto=True, verify=False)assert plain.status ==200and plain.tier =='plain'# a static page needs no escalationprint('example.com ->', plain.tier)
# browser_session(): open ONE browser, reuse it across fetches (skips per-URL relaunch)with browser_session() as bf: a = bf('https://example.com') b = bf('https://httpbin.org/html')assert a.status ==200and b.status ==200assertlen(a.html) >0andlen(b.html) >0
# fetch(session=True): route through the persistent debug Chrome, reusing its logged-in cookiespg = fetch('https://httpbin.org/headers', session=True)assert pg.status ==200print(pg.html[:200])
def crawl( start_url:str, # URL to start from sel:str=None, # CSS selector to extract per page follow_sel:str='a[href]', # CSS selector for links to follow same_domain:bool=True, # Only follow links on same domain max_pages:int=10, # Max pages to visit delay:float=0, # Seconds to wait between requests (polite crawling) heavy:bool=False, # Full JS rendering via headless browser stealthy:bool=False, # Anti-bot stealth fetcher (Cloudflare etc.) reuse:bool=True, # For heavy/stealthy crawls, keep one browser open across pages**kw)->list: # Extra kwargs passed to scrapling (e.g. verify, timeout)
Crawl from start_url, following follow_sel links, return list of Page dicts
def fetch_all( urls:list, # URLs to fetch sel:str=None, # CSS selector to extract per page (None = full page) concurrency:int=8, # Max parallel fetches heavy:bool=False, # Full JS rendering via headless browser stealthy:bool=False, # Anti-bot stealth fetcher (Cloudflare etc.)**kw)->list: # Extra kwargs passed to fetch()
Fetch a list of URLs in parallel; returns Page dicts in the same order as urls