Finance and markets

finviz.com

Finviz - Stock fundamentals and preset screeners

Notes an AI agent reads before it works on finviz.com: what to open, what to call, what breaks. Written by browser-harness contributors after real runs, and kept on GitHub under the MIT license.

Give it to your agent

Any coding agent

Start the task with this line. The Markdown version is the same notes without the page around them.

Read https://browser-use.com/skills/finviz.md, then <your task>

Browser Harness

Save the files into your workspace and turn domain skills on. The code below calls its helpers, such as http_get and js. What Browser Harness is.

d="${BH_AGENT_WORKSPACE:-$HOME/.config/browser-harness/agent-workspace}/domain-skills/finviz"
mkdir -p "$d" && cd "$d"
curl -sLO https://raw.githubusercontent.com/browser-use/browser-harness/main/agent-workspace/domain-skills/finviz/scraping.md
export BH_DOMAIN_SKILLS=1

Field-tested against finviz.com on 2026-10-31 using a Browser Use cloud browser, logged out (plus plain HTTP for robots.txt).

robots.txt (fetched 2026-10-31) has no rule matching /stock?t=<T>, so stock pages are allowed; it disallows /screener?*, /screener/stocks?*, /export and /api/v1/screener-export-csv (custom screeners and CSV export) but explicitly allows 35 preset screeners such as /screener/stocks?v=340&s=ta_topgainers, and the site's pager only appends &r=<n>, which still matches the longer Allow rule.

Quick summary

  • Stock page https://finviz.com/stock?t=<T> is server-rendered: 6 table.js-snapshot-table tables hold 84 label/value cells (valuation, growth, ownership, margins, technicals, performance), plus a header with company, sector, industry, country, exchange, website and last price. A <script id="income-breakdown-init-data-0"> JSON block adds a 2-year annual income statement summary.
  • The old /quote.ashx?t=AAPL redirects to /quote?t=AAPL, which redirects to /stock?t=AAPL; the browser lands on /stock?t=AAPL&p=d.
  • Preset screeners: each page embeds its rows in a <script type="application/json"> block ("screenerType" key). Views v=340 (snapshot) and v=320 (news) carry 10 rows per page with price, change %, volume, avg volume, market cap. View v=210 (charts) carries 12 tickers per page with company, industry, country and market cap only.
  • Only the 35 presets robots.txt names are usable, each with its fixed view. Changing v, adding filters f= or sort o= turns it into a disallowed custom screener.
  • Plain Python urllib gets HTTP 403 (Cloudflare error 1010) on stock and screener pages. Use the browser.
  • Pages are ad-heavy. Close the task tab when done: leftover Finviz tabs stalled the cloud browser twice in testing.

What works

1. Stock fundamentals table (browser)

Self-contained. Opens one tab, reads 3 tickers about 3 s apart, closes the tab, saves JSON. Returns None for a ticker Finviz does not know (it redirects to /search, which robots.txt disallows, so the page is not read). Raises on a Cloudflare challenge or a short table.

import json, os, re, tempfile, time, urllib.parse
 
def js_retry(expr, tries=6):
    # Finviz pages are ad-heavy; CDP calls sometimes miss the 5 s daemon timeout while the page is busy.
    for _ in range(tries):
        try:
            return js(expr)
        except TimeoutError:
            pass
        except RuntimeError as e:
            if "timed out" not in str(e):
                raise
        time.sleep(2)
    raise RuntimeError(f"page stayed busy for {tries} tries: {expr[:60]}")
 
def fv_open(url, timeout=45, settle=2.5):
    """Open url; return the landed URL. Raises on a Cloudflare challenge or if the page never loads."""
    prev = js_retry("location.href")
    try:
        goto_url(url)
    except TimeoutError:
        pass  # Page.navigate can exceed 5 s while the previous page unloads; the navigation still runs
    end = time.time() + timeout
    while True:
        if time.time() > end:
            raise RuntimeError(f"{url} did not finish loading in {timeout}s")
        time.sleep(1)
        href, state = js_retry("[location.href, document.readyState]")
        if state == "complete" and (href != prev or prev == url):
            break
    time.sleep(settle)
    title = js_retry("document.title") or ""
    challenge = js_retry("!!document.querySelector('#challenge-form, iframe[src*=\"challenges.cloudflare.com\"]')")
    if "Just a moment" in title or challenge:
        raise RuntimeError(f"Cloudflare challenge on {url}: stop and tell the user; do not retry around it")
    return js_retry("location.href")
 
SNAPSHOT_JS = """(() => {
  const rows = [];
  for (const t of document.querySelectorAll('table.js-snapshot-table'))
    for (const tr of t.rows) {
      const c = [...tr.cells];
      for (let i = 0; i + 1 < c.length; i += 2)
        rows.push([c[i].innerText.trim(), c[i + 1].innerText.trim(), c[i].getAttribute('data-boxover-html') || '']);
    }
  const txt = s => { const e = document.querySelector(s); return e ? e.innerText.trim() : null; };
  const link = p => { const a = document.querySelector(`a[href*="f=${p}_"]`); return a ? a.innerText.trim() : null; };
  const site = [...document.querySelectorAll('.quote-header a[href^="http"]')].map(a => a.href)[0] || null;
  const inc = document.getElementById('income-breakdown-init-data-0');
  return {rows, company: txt('.quote-header h2'), price: txt('.quote-price_price'), price_date: txt('.quote-price_date'),
          sector: link('sec'), industry: link('ind'), country: link('geo'), exchange: link('exch'), website: site,
          income: inc ? JSON.parse(inc.textContent).statement : null};
})()"""
 
def finviz_stock(ticker):
    """Snapshot fundamentals for one ticker from /stock?t=<T>. None if Finviz does not know the ticker."""
    t = ticker.strip().upper()
    if not re.fullmatch(r"[A-Z0-9.\-]{1,10}", t):
        raise ValueError(f"bad ticker {ticker!r}")
    url = "https://finviz.com/stock?" + urllib.parse.urlencode({"t": t})
    landed = urllib.parse.urlparse(fv_open(url))
    if landed.path != "/stock":
        return None  # unknown tickers redirect to /search?p=..., which robots.txt disallows: not read
    if urllib.parse.parse_qs(landed.query).get("t", [""])[0].upper() != t:
        raise RuntimeError(f"asked for {t}, landed on {landed.geturl()}")
    d = js_retry(SNAPSHOT_JS)
    if len(d["rows"]) < 80:
        raise RuntimeError(f"{t}: only {len(d['rows'])} snapshot cells; layout changed?")
    snap = {}
    for label, value, tip in d["rows"]:
        key = label if label not in snap else (tip or label + " #2")  # 'EPS next Y' appears twice
        snap[key] = value
    return {"ticker": t, "company": d["company"], "url": landed.geturl(), "sector": d["sector"], "industry": d["industry"],
            "country": d["country"], "exchange": d["exchange"], "website": d["website"],
            "last_price": d["price"], "price_time": d["price_date"],
            "snapshot": snap, "income_statement": d["income"]}  # last 2 fiscal years, millions of income["currency"]
 
tab = new_tab("about:blank")
out = {}
try:
    for i, t in enumerate(["AAPL", "NVDA", "ZZZZQ"]):
        if i:
            time.sleep(3)
        s = finviz_stock(t)
        if s is None:
            print(t, "-> not on Finviz")
            continue
        out[t] = s
        k = s["snapshot"]
        print(t, s["company"], s["exchange"], s["sector"], "/", s["industry"], "| price", s["last_price"], s["price_time"],
              "| cells", len(k), "| P/E", k["P/E"], "| Market Cap", k["Market Cap"], "| EPS next Y", k["EPS next Y"],
              "| EPS growth next year", k["EPS growth next year"])
finally:
    cdp("Target.closeTarget", targetId=tab)  # an open Finviz tab keeps running ads and can stall a small browser
print(json.dumps({x: out["AAPL"]["snapshot"][x] for x in list(out["AAPL"]["snapshot"])[:12]}, indent=1))
inc = out["AAPL"]["income_statement"]
print(inc["currency"], inc["data"]["Period"], "Total Revenue", inc["data"]["Total Revenue"], "Net Income", inc["data"]["Net Income"])
path = os.path.join(tempfile.gettempdir(), "finviz_stocks.json")
with open(path, "w") as f:
    json.dump(out, f, indent=1)
print("saved", path)

Output from the test run (saved path shortened):

AAPL Apple Inc NASD Technology / Consumer Electronics | price 344.63 Oct 30 • 3:59 PM ET | cells 84 | P/E 39.51 | Market Cap 5029.59B | EPS next Y 9.56 | EPS growth next year 8.13%
NVDA NVIDIA Corp NASD Technology / Semiconductors | price 204.64 Oct 30 • 3:59 PM ET | cells 84 | P/E 25.87 | Market Cap 4931.82B | EPS next Y 15.81 | EPS growth next year 70.15%
ZZZZQ -> not on Finviz
{
 "Index": "DJIA, NDX, S&P 500",
 "Market Cap": "5029.59B",
 "Enterprise Value": "5051.54B",
 "Income": "128.93B",
 "Sales": "466.82B",
 "Book/sh": "7.36",
 "Cash/sh": "4.28",
 "Dividend Est.": "1.10 (0.32%)",
 "Dividend TTM": "1.06 (0.31%)",
 "Dividend Ex-Date": "Aug 10, 2026",
 "Dividend Gr. 3/5Y": "4.26% 4.98%",
 "Payout": "13.66%"
}
USD ['2025FY', '2024FY'] Total Revenue ['416,161.00', '391,035.00'] Net Income ['112,010.00', '93,736.00']
saved <tempdir>/finviz_stocks.json

2. Preset screeners, the site's own pages (browser)

Self-contained. Refuses any preset robots.txt does not list, reads the page's embedded JSON, follows the site's pager (&r=11, &r=21 ... for 10-row views; &r=13, &r=25 ... for the 12-row chart view) up to max_pages, and marks the result truncated when rows remain. About 3 s between page loads.

import html, json, os, re, tempfile, time
 
def js_retry(expr, tries=6):
    # Finviz pages are ad-heavy; CDP calls sometimes miss the 5 s daemon timeout while the page is busy.
    for _ in range(tries):
        try:
            return js(expr)
        except TimeoutError:
            pass
        except RuntimeError as e:
            if "timed out" not in str(e):
                raise
        time.sleep(2)
    raise RuntimeError(f"page stayed busy for {tries} tries: {expr[:60]}")
 
def fv_open(url, timeout=45, settle=2.5):
    """Open url; return the landed URL. Raises on a Cloudflare challenge or if the page never loads."""
    prev = js_retry("location.href")
    try:
        goto_url(url)
    except TimeoutError:
        pass  # Page.navigate can exceed 5 s while the previous page unloads; the navigation still runs
    end = time.time() + timeout
    while True:
        if time.time() > end:
            raise RuntimeError(f"{url} did not finish loading in {timeout}s")
        time.sleep(1)
        href, state = js_retry("[location.href, document.readyState]")
        if state == "complete" and (href != prev or prev == url):
            break
    time.sleep(settle)
    title = js_retry("document.title") or ""
    challenge = js_retry("!!document.querySelector('#challenge-form, iframe[src*=\"challenges.cloudflare.com\"]')")
    if "Just a moment" in title or challenge:
        raise RuntimeError(f"Cloudflare challenge on {url}: stop and tell the user; do not retry around it")
    return js_retry("location.href")
 
# The presets robots.txt allows (checked 2026-10-31). Every other /screener?... or /screener/stocks?... URL is disallowed,
# so the view (v) and signal (s) pair must be exactly one of these; nothing here changes v, filters or sort order.
ALLOWED_PRESETS = {
    "ta_topgainers": 340, "ta_toplosers": 340, "ta_newhigh": 340, "ta_newlow": 340, "n_upgrades": 340,
    "n_downgrades": 340, "n_earningsbefore": 340, "n_earningsafter": 340, "it_latestbuys": 340,
    "it_latestsales": 340, "f_ssr": 340, "f_halted": 340,
    "ta_mostvolatile": 320, "ta_mostactive": 320, "ta_unusualvolume": 320, "n_majornews": 320,
    "ta_overbought": 210, "ta_oversold": 210, "ta_p_tlsupport": 210, "ta_p_tlresistance": 210,
    "ta_p_horizontal": 210, "ta_p_wedgeup": 210, "ta_p_wedge": 210, "ta_p_wedgedown": 210,
    "ta_p_wedgeresistance": 210, "ta_p_wedgesupport": 210, "ta_p_channelup": 210, "ta_p_channel": 210,
    "ta_p_channeldown": 210, "ta_p_doubletop": 210, "ta_p_multipletop": 210, "ta_p_doublebottom": 210,
    "ta_p_multiplebottom": 210, "ta_p_headandshoulders": 210, "ta_p_headandshouldersinv": 210,
}
 
STATE_JS = """(() => { const s = [...document.scripts].find(s => s.type === 'application/json' && s.textContent.includes('"screenerType"'));
  return s ? s.textContent : null })()"""
TOTAL_JS = """(() => { const e = [...document.querySelectorAll('span')].find(e => /^#\\d+ \\/ \\d+ Total$/.test(e.innerText.trim()));
  return e ? e.innerText.trim() : null })()"""
 
def finviz_preset_page(signal, row=1):
    """One page of a robots-allowed preset. row is the site's own pager offset: 1, 11, 21 ... (v=340/320) or 1, 13, 25 ... (v=210)."""
    if signal not in ALLOWED_PRESETS:
        raise ValueError(f"{signal}: not a robots.txt-allowed preset")
    v = ALLOWED_PRESETS[signal]
    url = f"https://finviz.com/screener/stocks?v={v}&s={signal}" + (f"&r={row}" if row > 1 else "")
    landed = fv_open(url)
    if landed != url:
        raise RuntimeError(f"asked for {url}, landed on {landed}; not reading it")
    raw = js_retry(STATE_JS)
    if not raw:
        raise RuntimeError(f"{url}: no screener state JSON; layout changed?")
    st = json.loads(raw)
    if "data" in st:  # v=340 snapshot and v=320 news views: full rows with price, change, volume
        d = st["data"]
        num = lambda r, k, nd=2: None if r["numbers"].get(k) is None else round(r["numbers"][k], nd)
        rows = [{"ticker": r["text"]["ticker"], "company": r["text"]["company"], "industry": r["text"].get("industry"),
                 "country": r["text"].get("country"), "exchange": r["text"].get("exchange"),
                 "price": num(r, "price", 4), "change_pct": num(r, "change"),
                 "volume": r["numbers"].get("volume"), "avg_volume": num(r, "averageVolume", 0),
                 "market_cap_musd": num(r, "marketCap"), "pe": num(r, "PE")} for r in d["rows"]]
        total, first, size = d["totalCount"], d["firstRow"] + 1, d["pageSize"]
    else:  # v=210 chart views: tickers only (price data is in chart images)
        rows = []
        for c in st["charts"]["charts"]:
            # boxoverHtml: "<b>Century Casinos Inc</b><br>Resorts &amp; Casinos • USA • 14.63M"
            name, _, rest = html.unescape(c["boxoverHtml"]).partition("<br>")
            rows.append({"ticker": c["ticker"], "company": re.sub(r"<[^>]+>", "", name).strip()}
                        | dict(zip(("industry", "country", "market_cap"), rest.split(" • "))))
        m = re.fullmatch(r"#(\d+) / (\d+) Total", js_retry(TOTAL_JS) or "")
        if not m:
            raise RuntimeError(f"{url}: no '#n / N Total' counter")
        first, total, size = int(m.group(1)), int(m.group(2)), 12
    if first != row:
        raise RuntimeError(f"{url}: page starts at row {first}, expected {row}")
    return {"url": url, "view": v, "total": total, "first_row": first, "page_size": size, "rows": rows}
 
def finviz_preset(signal, max_pages=2, pause=3):
    pages = [finviz_preset_page(signal)]
    size, total = pages[0]["page_size"], pages[0]["total"]
    while len(pages) < max_pages and len(pages) * size < total:
        time.sleep(pause)
        pages.append(finviz_preset_page(signal, row=len(pages) * size + 1))
    rows = [r for p in pages for r in p["rows"]]
    return {"signal": signal, "view": pages[0]["view"], "total": total, "rows": rows, "truncated": len(rows) < total}
 
tab = new_tab("about:blank")
out = {}
try:
    for i, (sig, n) in enumerate([("ta_topgainers", 2), ("ta_unusualvolume", 1), ("ta_oversold", 2)]):
        if i:
            time.sleep(3)
        p = finviz_preset(sig, max_pages=n)
        out[sig] = p
        print(f'{sig:17} v={p["view"]} total={p["total"]:<4} rows={len(p["rows"]):<3} truncated={p["truncated"]}')
        for r in p["rows"][:3]:
            print("   ", r)
finally:
    cdp("Target.closeTarget", targetId=tab)  # an open Finviz tab keeps running ads and can stall a small browser
print("top gainers, change % in page order:", [r["change_pct"] for r in out["ta_topgainers"]["rows"]])
try:
    finviz_preset_page("cap_mega")  # a custom filter, not a preset: refused before any request
except ValueError as e:
    print("refused:", e)
path = os.path.join(tempfile.gettempdir(), "finviz_presets.json")
with open(path, "w") as f:
    json.dump(out, f, indent=1)
print("saved", path)

Output from the test run (saved path shortened):

ta_topgainers     v=340 total=130  rows=20  truncated=True
    {'ticker': 'ONEG', 'company': 'OneConstruction Group Ltd', 'industry': 'Engineering & Construction', 'country': 'Hong Kong', 'exchange': 'NASD', 'price': 1.63, 'change_pct': 108.15, 'volume': 109703824, 'avg_volume': 40697.0, 'market_cap_musd': 26.08, 'pe': None}
    {'ticker': 'CHOW', 'company': 'ChowChow Cloud International Holdings Ltd', 'industry': 'Software - Infrastructure', 'country': 'Hong Kong', 'exchange': 'AMEX', 'price': 4.4, 'change_pct': 106.57, 'volume': 68954414, 'avg_volume': 907398.0, 'market_cap_musd': 15.62, 'pe': None}
    {'ticker': 'SLGB', 'company': 'Smart Logistics Global Ltd', 'industry': 'Integrated Freight & Logistics', 'country': 'Hong Kong', 'exchange': 'NASD', 'price': 0.3051, 'change_pct': 48.97, 'volume': 301221208, 'avg_volume': 4367960.0, 'market_cap_musd': 13.12, 'pe': None}
ta_unusualvolume  v=320 total=200  rows=10  truncated=True
    {'ticker': 'ONEG', 'company': 'OneConstruction Group Ltd', 'industry': 'Engineering & Construction', 'country': 'Hong Kong', 'exchange': 'NASD', 'price': 1.63, 'change_pct': 108.15, 'volume': 109703824, 'avg_volume': 40697.0, 'market_cap_musd': 26.08, 'pe': None}
    {'ticker': 'HUBC', 'company': 'Hub Cyber Security Ltd', 'industry': 'Software - Infrastructure', 'country': 'Israel', 'exchange': 'NASD', 'price': 0.1134, 'change_pct': -16.0, 'volume': 196784643, 'avg_volume': 1320169.0, 'market_cap_musd': 0.2, 'pe': None}
    {'ticker': 'ABOS', 'company': 'Acumen Pharmaceuticals Inc', 'industry': 'Biotechnology', 'country': 'USA', 'exchange': 'NASD', 'price': 0.2101, 'change_pct': -90.83, 'volume': 68105173, 'avg_volume': 517579.0, 'market_cap_musd': 15.2, 'pe': None}
ta_oversold       v=210 total=94   rows=24  truncated=True
    {'ticker': 'CNTY', 'company': 'Century Casinos Inc', 'industry': 'Resorts & Casinos', 'country': 'USA', 'market_cap': '14.63M'}
    {'ticker': 'ADBT', 'company': 'Advasa Holdings Inc', 'industry': 'Software - Application', 'country': 'Japan', 'market_cap': '51.97M'}
    {'ticker': 'CTNT', 'company': 'Cheetah Net Supply Chain Service Inc', 'industry': 'Integrated Freight & Logistics', 'country': 'USA', 'market_cap': '2.81M'}
top gainers, change % in page order: [108.15, 106.57, 48.97, 40.78, 30.53, 30.15, 29.82, 29.68, 27.95, 27.68, 27.54, 27.34, 26.34, 26.15, 24.31, 22.22, 20.71, 19.4, 19.25, 18.75]
refused: cap_mega: not a robots.txt-allowed preset
saved <tempdir>/finviz_presets.json

Fields

FieldSourceNotes
snapshottable.js-snapshot-table label/value cells84 keys, values are display strings ("5029.59B", "39.51", "4.26% 4.98%", "-" when missing)
snapshot["EPS next Y"] / ["EPS growth next year"]same label twicefirst is the EPS estimate, second the growth %; the helper keys the second by its tooltip (data-boxover-html)
snapshot["Trades"]empty string when logged out
last_price, price_timefirst .quote-price_price, .quote-price_dateregular-session last price; an aftermarket price follows it on the page. Quotes are delayed (page footer: "Stock quotes delayed by 1 minute")
sector, industry, country, exchangeheader links f=sec_, f=ind_, f=geo_, f=exch_text only; the links go to /screener?v=111&f=..., which robots.txt disallows
income_statement#income-breakdown-init-data-0 .statementcurrency, data.Period (e.g. 2025FY, 2024FY), rows as strings in millions ("416,161.00")
preset price, change_pct, volume, avg_volumeJSON data.rows[].numbersv=340/320 only; change_pct is percent
preset market_cap_musdnumbers.marketCapmillions USD (26.08 shows as "26.08M" on the page)
preset totaldata.totalCount or the #1 / N Total counterv=210 has no data, so the counter is parsed
chart-view market_capcharts.charts[].boxoverHtmlstring such as "14.63M"

Gotchas

  • CDP calls on Finviz sometimes exceed browser-harness's 5 s daemon timeout while ads load. The helpers retry (js_retry) and treat a timed-out Page.navigate as still running. With several Finviz tabs left open the cloud browser stopped answering ("required daemon ... is unhealthy: timed out") and had to be restarted; after closing the tab in finally, 6 consecutive runs of the two blocks above completed (25 to 39 s each).
  • ta_topgainers rows come in descending change_pct order (20 of 20 rows checked across 2 pages).
  • ta_unusualvolume reported totalCount 200 on 2026-10-31.
  • /screener?v=340&s=ta_topgainers redirects to /screener/stocks?v=340&s=ta_topgainers; robots.txt allows the same 35 presets under both paths. The helper requests the /screener/stocks form directly and refuses to read a page whose final URL differs from the one requested.
  • On a weekend the header shows "Last Close" for the last session (Oct 30 on Saturday Oct 31) and the aftermarket close.
  • Chart images on screener pages come from charts2-node.finviz.com; the helpers do not fetch them (/chart on finviz.com is disallowed).
  • No Cloudflare challenge appeared in more than 40 browser page loads at 2 to 3 s spacing. If one does, the helpers raise: stop and tell the user.

What does not work

  • Plain HTTP with Python urllib (its default user agent): HTTP 403, body error code: 1010, on /stock?t=AAPL and /screener/stocks?... (2026-10-31). Not worked around by changing the user agent.
  • Custom screeners (f= filters, other views such as the v=111 table, o= sort), name search (/search), and CSV export (/export, /api/v1/screener-export-csv): disallowed by robots.txt. Not used.
  • Price on v=210 chart-view presets: not in the page data, only in chart images. Use the stock page for a ticker's price.

scraping.md · updated Oct 31, 2026 · 2,601 words

Sites change. These notes describe what worked when they were last updated, not a guarantee they still do. If something broke, the fix is a pull request to browser-harness.