Compare commits

..

No commits in common. "fa7e3eb545e58538a808a1170b522d65e98f8992" and "011017bde7ae696c785957cff650f6ce941a3c01" have entirely different histories.

10 changed files with 0 additions and 532 deletions

View File

@ -1,25 +0,0 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: python -m pytest tests/ -v

6
.gitignore vendored
View File

@ -1,6 +0,0 @@
__pycache__/
*.pyc
.env
*.log
cache/*
!cache/.gitkeep

View File

@ -1,9 +0,0 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates/ templates/
COPY cache/ cache/
EXPOSE 5001
CMD ["python", "app.py"]

View File

@ -1,52 +0,0 @@
# 10k Viewer
Web interface for browsing SEC 10-K filings. Query any publicly traded company by ticker, fetch filings data from SEC EDGAR API, and render structured HTML views.
## Features
- Search 10-K filings by company ticker symbol
- Built-in fallback CIK database for S&P 500 companies
- SEC EDGAR API integration with retry logic
- Flask-based web interface with template rendering
- Local caching for fast repeat queries
- Docker-ready for deployment
## Tech Stack
- **Backend:** Python 3.12, Flask
- **Frontend:** HTML templates (Jinja2)
- **API:** SEC EDGAR REST API
- **Deployment:** Docker, port 5001
## Setup
### Local
```bash
pip install flask
python app.py
```
Navigate to `http://localhost:5001`
### Docker
```bash
docker build -t 10k-viewer .
docker run -p 5001:5001 10k-viewer
```
## Usage
Enter a stock ticker (e.g. `AAPL`, `MSFT`, `GOOGL`) to fetch and browse the latest 10-K filing data. Supports any company with a valid CIK identifier.
## Tests
```bash
pip install -r requirements.txt pytest
python -m pytest tests/
```
## License
MIT

233
app.py
View File

@ -1,233 +0,0 @@
#!/usr/bin/env python3
"""Minimal SEC filings viewer."""
import gzip, json, logging, re, time, urllib.request, urllib.error
from pathlib import Path
from flask import Flask, render_template, abort, send_from_directory, request, make_response
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("10k")
UA = "10k-viewer/1.0 (Jarian Cottingham jarianc@proton.me)"
CACHE_DIR = Path(__file__).parent / "cache"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
MAX_RETRIES = 1
PAGE_SIZE = 10
FALLBACK = {
"AAPL": {"cik": "0000320193", "name": "Apple Inc."},
"MSFT": {"cik": "0000789019", "name": "Microsoft Corp"},
"GOOGL": {"cik": "0001652044", "name": "Alphabet Inc."},
"AMZN": {"cik": "0001018724", "name": "Amazon.com Inc."},
"META": {"cik": "0001326801", "name": "Meta Platforms Inc."},
"NVDA": {"cik": "0001045810", "name": "NVIDIA Corp"},
"TSLA": {"cik": "0001318605", "name": "Tesla Inc."},
"JPM": {"cik": "0000019617", "name": "JPMorgan Chase"},
"V": {"cik": "0001403161", "name": "Visa Inc."},
"JNJ": {"cik": "0000200406", "name": "Johnson & Johnson"},
"WMT": {"cik": "0000104169", "name": "Walmart Inc."},
"PG": {"cik": "0000080424", "name": "Procter & Gamble"},
"MA": {"cik": "0001141391", "name": "Mastercard Inc."},
"HD": {"cik": "0000354950", "name": "Home Depot Inc."},
"DIS": {"cik": "0001001039", "name": "Walt Disney Co"},
"NFLX": {"cik": "0001065280", "name": "Netflix Inc."},
"ADBE": {"cik": "0000796343", "name": "Adobe Inc."},
"CRM": {"cik": "0001108524", "name": "Salesforce Inc."},
"INTC": {"cik": "0000050863", "name": "Intel Corp"},
"AMD": {"cik": "0000002488", "name": "Advanced Micro Devices"},
"COST": {"cik": "0000909832", "name": "Costco Wholesale"},
"KO": {"cik": "0000021344", "name": "Coca-Cola Co"},
"AVGO": {"cik": "0001730168", "name": "Broadcom Inc."},
"ORCL": {"cik": "0001341439", "name": "Oracle Corp"},
"LLY": {"cik": "0000059478", "name": "Eli Lilly & Co"},
"CSCO": {"cik": "0000858877", "name": "Cisco Systems"},
"MCD": {"cik": "0000063908", "name": "McDonald's Corp"},
"NKE": {"cik": "0000320187", "name": "Nike Inc."},
"QCOM": {"cik": "0000804328", "name": "Qualcomm Inc."},
"TXN": {"cik": "0000010454", "name": "Texas Instruments"},
"SBUX": {"cik": "0000829224", "name": "Starbucks Corp"},
"LOW": {"cik": "0000300026", "name": "Lowe's Cos Inc."},
"PANW": {"cik": "0001742704", "name": "Palo Alto Networks"},
"NOW": {"cik": "0001590894", "name": "ServiceNow Inc."},
"AMAT": {"cik": "0000002017", "name": "Applied Materials"},
"MU": {"cik": "0000727119", "name": "Micron Technology"},
"ASML": {"cik": "0001647072", "name": "ASML Holding"},
"SNPS": {"cik": "0000002915", "name": "Synopsys Inc."},
"CDNS": {"cik": "0000007643", "name": "Cadence Design"},
"ADI": {"cik": "0000006281", "name": "Analog Devices"},
"FTNT": {"cik": "0001645674", "name": "Fortinet Inc."},
"CRWD": {"cik": "0001864275", "name": "CrowdStrike"},
"ZS": {"cik": "0001783140", "name": "Zscaler Inc."},
"DDOG": {"cik": "0001779937", "name": "Datadog Inc."},
"SNOW": {"cik": "0001640185", "name": "Snowflake Inc."},
"PLTR": {"cik": "0001781878", "name": "Palantir Tech"},
"COIN": {"cik": "0001679788", "name": "Coinbase Global"},
"RBLX": {"cik": "0001816155", "name": "Roblox Corp"},
"SQ": {"cik": "0001594968", "name": "Block Inc."},
"SHOP": {"cik": "0001594805", "name": "Shopify Inc."},
"UBER": {"cik": "0001543151", "name": "Uber Technologies"},
"DASH": {"cik": "0001715041", "name": "DoorDash Inc."},
"ABNB": {"cik": "0001792947", "name": "Airbnb Inc."},
"RIVN": {"cik": "0001874178", "name": "Rivian Automotive"},
"LCID": {"cik": "0001963077", "name": "Lucid Group"},
"NIO": {"cik": "0001841781", "name": "NIO Inc."},
"BABA": {"cik": "0001577552", "name": "Alibaba Group"},
}
_tickers = None
_filings_cache = {}
def fetch_json(url):
for _ in range(1 + MAX_RETRIES):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=15) as r:
d = r.read()
if r.headers.get("Content-Encoding") == "gzip":
d = gzip.decompress(d)
return json.loads(d.decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code in (403, 404, 429):
raise
except Exception:
pass
raise Exception(f"Failed {url}")
def fetch_filing(url):
for _ in range(1 + MAX_RETRIES):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read()
except Exception as e:
log.warning(f"Download failed: {e}")
raise Exception(f"Download failed {url}")
def get_tickers():
global _tickers
if _tickers:
return _tickers
cf = CACHE_DIR / "tickers.json"
if cf.exists():
try:
_tickers = json.loads(cf.read_text())
return _tickers
except Exception:
pass
_tickers = FALLBACK
return _tickers
def parse_filings(subs, cik, forms):
recent = subs.get("filings", {}).get("recent", {})
fs = recent.get("form", [])
ds = recent.get("filingDate", [])
doc = recent.get("primaryDocument", [])
acc = recent.get("accessionNumber", [])
r = []
for i, f in enumerate(fs):
if f not in forms:
continue
a = acc[i].replace("-", "")
r.append({
"form": f,
"date": ds[i] if i < len(ds) else "?",
"file": f"{ds[i]}_{a}.html" if i < len(ds) else f"?_{a}.html",
"url": f"https://www.sec.gov/Archives/edgar/data/{cik}/{a}/{doc[i]}",
})
return r
def get_filings(cik, forms):
key = f"{cik}:{','.join(sorted(forms))}"
if key in _filings_cache:
return _filings_cache[key]
subs = fetch_json(f"https://data.sec.gov/submissions/CIK{cik}.json")
r = parse_filings(subs, cik, forms)
_filings_cache[key] = r
return r
def clean_ereader(html):
t = html.decode("utf-8", errors="replace")
t = re.sub(r'<script[^>]*>.*?</script>', '', t, flags=re.DOTALL|re.I)
t = re.sub(r'<style[^>]*>.*?</style>', '', t, flags=re.DOTALL|re.I)
t = re.sub(r"""\s+style=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r"""\s+on\w+=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r"""\s+(class|id)=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r'<!--.*?-->', '', t, flags=re.DOTALL)
s = '<style>body{font-family:Georgia,serif!important;font-size:18px!important;line-height:1.6!important;max-width:700px;margin:0 auto;padding:20px;color:#000;background:#fff}table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px}th,td{border:1px solid #000;padding:4px 8px;text-align:left}th{font-weight:bold;background:#eee}a{color:#000}h1,h2,h3,h4,h5,h6{font-family:Georgia,serif!important;margin:16px 0 8px}p{margin:8px 0}</style>'
if '<head>' in t.lower():
t = t.replace('<head>', f'<head>{s}', 1)
elif '<body' in t.lower():
t = t.replace('<body', f'<body{s}', 1)
else:
t = s + t
return t.encode("utf-8")
@app.route("/")
def index():
q = request.args.get("q", "").strip()
t = get_tickers()
if q:
q2 = q.upper()
c = {k: v for k, v in t.items() if q2 in k.upper() or q2 in v["name"].upper()}
else:
c = dict(list(t.items())[:50])
q = ""
return render_template("index.html", companies=c, query=q, total=len(c))
@app.route("/<ticker>")
def company(ticker):
t = get_tickers()
if ticker not in t:
abort(404)
info = t[ticker]
ff = request.args.get("form", "").upper()
forms = {"10-K"} if ff == "10-K" else {"10-Q"} if ff == "10-Q" else {"10-K", "10-Q"}
try:
all_f = get_filings(info["cik"], forms)
except Exception as e:
log.error(f"Failed {ticker}: {e}")
abort(503, description=str(e))
p = request.args.get("page", 1, type=int)
s = (p - 1) * PAGE_SIZE
e = s + PAGE_SIZE
page = all_f[s:e]
tp = (len(all_f) + PAGE_SIZE - 1) // PAGE_SIZE
return render_template("company.html", ticker=ticker, company=info, cik=info["cik"],
filings=page, form_filter=ff, page=p, total_pages=tp, total=len(all_f))
@app.route("/<ticker>/view/<filename>")
def view(ticker, filename):
t = get_tickers()
if ticker not in t:
abort(404)
cik = t[ticker]["cik"]
dest = CACHE_DIR / "filings" / cik / filename
dest.parent.mkdir(parents=True, exist_ok=True)
if not dest.exists():
try:
fs = get_filings(cik, {"10-K", "10-Q"})
url = next((f["url"] for f in fs if f["file"] == filename), None)
if not url:
abort(404, description="Not found")
log.info(f"Caching {filename}...")
dest.write_bytes(fetch_filing(url))
except Exception as e:
log.error(f"Cache fail {ticker}/{filename}: {e}")
abort(503, description=str(e))
if request.args.get("reader", "").lower() in ("1", "true"):
return make_response(clean_ereader(dest.read_bytes())), {"Content-Type": "text/html"}
return send_from_directory(str(dest.parent), filename, mimetype="text/html")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)

0
cache/.gitkeep vendored
View File

View File

@ -1 +0,0 @@
flask>=3.0,<4

View File

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ ticker }} Filings</title>
<style>
body{font-family:Georgia,serif;font-size:18px;max-width:600px;margin:0 auto;padding:20px}
h1{font-size:1.4em}
ul{list-style:none;padding:0}
li{padding:8px 0;border-bottom:1px solid #ccc}
a{color:#000}
small{color:#555}
.back{font-size:0.85em}
.filter{margin:10px 0}
.filter a{margin-right:15px;font-size:0.9em}
.filter a.active{font-weight:bold;text-decoration:underline}
.ftag{display:inline-block;padding:1px 5px;font-size:0.75em;margin-left:5px}
.f10k{background:#000;color:#fff}
.f10q{background:#666;color:#fff}
.pg{margin:15px 0;font-size:0.9em}
.pg a{margin-right:10px}
</style>
</head>
<body>
<p class="back"><a href="/">← All companies</a></p>
<h1>{{ ticker }} — {{ company.name }}</h1>
<div class="filter">
<a href="/{{ ticker }}" class="{% if not form_filter %}active{% endif %}">All</a>
<a href="/{{ ticker }}?form=10-K" class="{% if form_filter == '10-K' %}active{% endif %}">10-K Annual</a>
<a href="/{{ ticker }}?form=10-Q" class="{% if form_filter == '10-Q' %}active{% endif %}">10-Q Quarterly</a>
</div>
<p>Total: {{ total }} filings · Page {{ page }} of {{ total_pages }}</p>
<ul>
{% for f in filings %}
<li>
<a href="/{{ ticker }}/view/{{ f.file }}?reader=1">{{ f.date }}</a>
<span class="ftag {% if f.form == '10-K' %}f10k{% else %}f10q{% endif %}">{{ f.form }}</span>
<small> · <a href="/{{ ticker }}/view/{{ f.file }}">raw</a></small>
</li>
{% else %}
<li>No filings found.</li>
{% endfor %}
</ul>
<div class="pg">
{% if page > 1 %}<a href="/{{ ticker }}?form={{ form_filter }}&page={{ page - 1 }}">← Prev</a>{% endif %}
{% if page < total_pages %}<a href="/{{ ticker }}?form={{ form_filter }}&page={{ page + 1 }}">Next →</a>{% endif %}
</div>
</body>
</html>

View File

@ -1,48 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>10-K Viewer</title>
<style>
body { font-family: Georgia, serif; font-size: 18px; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { font-size: 1.4em; }
form { margin: 10px 0 20px; }
input[type="text"] {
font-family: Georgia, serif;
font-size: 16px;
padding: 8px;
width: 80%;
border: 1px solid #000;
}
input[type="submit"] {
font-family: Georgia, serif;
font-size: 16px;
padding: 8px 16px;
background: #000;
color: #fff;
border: none;
cursor: pointer;
}
ul { list-style: none; padding: 0; }
li { padding: 8px 0; border-bottom: 1px solid #ccc; }
a { color: #000; }
small { color: #555; }
.count { color: #555; font-size: 0.85em; }
</style>
</head>
<body>
<h1>10-K Annual Reports</h1>
<form method="get" action="/">
<input type="text" name="q" value="{{ query }}" placeholder="Search ticker or company name...">
<input type="submit" value="Search">
</form>
{% if query %}
<p class="count">{{ total }} result{{ 's' if total != 1 else '' }} for "{{ query }}"</p>
{% endif %}
<ul>
{% for t, c in companies.items() %}
<li><a href="/{{ t }}">{{ t }}</a> &mdash; {{ c.name }}</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -1,109 +0,0 @@
"""Tests for the 10-K viewer Flask app."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import app as viewer # noqa: E402
SAMPLE_SUBS = {
"filings": {
"recent": {
"form": ["10-K", "8-K", "10-Q", "10-K"],
"filingDate": ["2024-11-01", "2024-10-15", "2024-08-01", "2023-11-02"],
"primaryDocument": [
"aapl-20240928.htm",
"aapl-20241015.htm",
"aapl-20240628.htm",
"aapl-20230930.htm",
],
"accessionNumber": [
"0000320193-24-000123",
"0000320193-24-000100",
"0000320193-24-000090",
"0000320193-23-000050",
],
}
}
}
@pytest.fixture
def client():
viewer._filings_cache.clear()
viewer._tickers = None
viewer.app.config["TESTING"] = True
with viewer.app.test_client() as c:
yield c
def test_parse_filings_filters_forms():
r = viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"10-K"})
assert len(r) == 2
assert all(f["form"] == "10-K" for f in r)
def test_parse_filings_builds_url_and_file():
r = viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"10-K", "10-Q"})
first = r[0]
assert first["file"] == "2024-11-01_000032019324000123.html"
assert (
first["url"]
== "https://www.sec.gov/Archives/edgar/data/0000320193/000032019324000123/aapl-20240928.htm"
)
def test_parse_filings_no_matching_forms():
assert viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"20-F"}) == []
def test_clean_ereader_strips_scripts_and_handlers():
html = b"<html><head><script>evil()</script></head><body><div onclick='x()' class='c' id='i'>hi</div></body></html>"
out = viewer.clean_ereader(html).decode("utf-8")
assert "<script>" not in out
assert "onclick" not in out
assert "class=" not in out
assert "id=" not in out
assert "Georgia" in out
def test_get_tickers_fallback():
viewer._tickers = None
t = viewer.get_tickers()
assert t["AAPL"]["cik"] == "0000320193"
def test_index_default_lists_companies(client):
resp = client.get("/")
assert resp.status_code == 200
assert "AAPL" in resp.get_data(as_text=True)
def test_index_search_filters(client):
resp = client.get("/?q=apple")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "AAPL" in body
assert "MSFT" not in body
def test_company_renders_filings(client, monkeypatch):
monkeypatch.setattr(viewer, "get_filings", lambda cik, forms: [
{"form": "10-K", "date": "2024-11-01", "file": "f1.html", "url": "https://x/f1.html"},
])
resp = client.get("/AAPL")
assert resp.status_code == 200
assert "Apple Inc." in resp.get_data(as_text=True)
assert "f1.html" in resp.get_data(as_text=True)
def test_company_unknown_ticker_404(client):
assert client.get("/NOPE").status_code == 404
def test_view_unknown_ticker_404(client):
assert client.get("/NOPE/view/f1.html").status_code == 404