First commit

This commit is contained in:
Henrik Jess Nielsen
2026-05-26 22:21:27 +02:00
parent 2743a236b2
commit 05eed51e7d
90 changed files with 8690 additions and 0 deletions

15
.env Normal file
View File

@@ -0,0 +1,15 @@
anthropic_api_key=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
# Saxo Bank SIM API - 24 hour token (refresh daily at developer.saxobank.com/openapi/token)
SAXO_TOKEN=eyJhbGciOiJFUzI1NiIsIng1dCI6IjY3NEM0MjFEMzZEMUE1OUNFNjFBRTIzMjMyOTVFRTAyRTc3MDMzNTkifQ.eyJvYWEiOiI3Nzc3NSIsImlzcyI6Im9hIiwiYWlkIjoiMTA5IiwidWlkIjoiYVFHTDhPUTR2LUU1cjRnTVVFUlltQT09IiwiY2lkIjoiYVFHTDhPUTR2LUU1cjRnTVVFUlltQT09IiwiaXNhIjoiRmFsc2UiLCJ0aWQiOiIyMDAyIiwic2lkIjoiNjE4ZjdjY2YwM2QyNDI0ZTgwMjUyOGM2NjQzYmE3ZjciLCJkZ2kiOiI4NCIsImV4cCI6IjE3Nzk3NTMxNzkiLCJvYWwiOiIxRiIsImlpZCI6ImRmMTdkNTZkMmNkYTRlZmE2ZTM5MDhkZWI4YjExZDYxIn0.F1ltcLpAr_724NqYYpa1Th4A-ibftPpYHcI7vAFQBI-wbOY_VNqakRuCFLAcNN73A_dV99RF-lz6vrrXoIyFkQ
SAXO_BASE=https://gateway.saxobank.com/sim/openapi
# Saxo OAuth2 App credentials (MoneyMakerHJess)
SAXO_APP_KEY=bce4a7d403b84bc1b000461b25b2824a
SAXO_APP_SECRET_1=43d67cc692c6470796db3e81e493b12e
SAXO_APP_SECRET_2=9ef4f78b87eb4d948c2abdeaea920f14
SAXO_AUTH_URL=https://sim.logonvalidation.net/authorize
SAXO_TOKEN_URL=https://sim.logonvalidation.net/token
SAXO_REDIRECT=http://localhost:8765/callback
MM_DB_PASS=919129fd5326dfc7817be588
DATABASE_URL=postgresql://moneymaker:919129fd5326dfc7817be588@int.i80.dk:5432/moneymaker

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.saxo_token.json

68
Makefile Normal file
View File

@@ -0,0 +1,68 @@
PY := .venv/bin/python
.PHONY: help run signals buy fetch rss analyze force dry company saxo saxo-buy saxo-sell saxo-login saxo-status
help:
@echo ""
@echo " make → fetch + analyze new articles + vis board"
@echo " make run → samme som make"
@echo " make force → gen-analyser ALLE artikler (med Claude)"
@echo " make dry → dry-run uden at gemme noget"
@echo " make signals → vis signal board"
@echo " make buy → vis kun køb-kandidater"
@echo " make fetch → hent nye artikler fra Ground News"
@echo " make rss → hent danske RSS feeds (Børsen, Finans, Politiken)"
@echo " make orders → vis dagens køb/sælg/hold forslag"
@echo " make portfolio → vis åbne positioner + P&L"
@echo " make company → TICKER=NOVO-B make company"
@echo " make saxo → vis Saxo SIM konto status + positioner"
@echo " make saxo-buy → TICKER=NOVO-B N=5 make saxo-buy"
@echo " make saxo-sell → TICKER=NOVO-B N=5 make saxo-sell"
@echo ""
run: analyze signals
analyze:
$(PY) analyze.py
force:
$(PY) analyze.py --force
dry:
$(PY) analyze.py --dry-run --force
signals:
$(PY) signals.py board
buy:
$(PY) signals.py buy
fetch:
$(PY) -c "from ground_news import fetch_all, get_db; db=get_db(); fetch_all(db, force=True); print('Done')"
rss:
$(PY) rss_feeds.py
orders:
$(PY) portfolio.py orders
portfolio:
$(PY) portfolio.py status
company:
$(PY) signals.py company $(TICKER)
saxo-login:
$(PY) saxo_auth.py login
saxo-status:
$(PY) saxo_auth.py status
saxo:
$(PY) saxo_broker.py status
saxo-buy:
$(PY) saxo_broker.py buy $(TICKER) $(N)
saxo-sell:
$(PY) saxo_broker.py sell $(TICKER) $(N)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

562
analyze.py Normal file
View File

@@ -0,0 +1,562 @@
"""
analyze.py — C25 Financial Signal Extractor
Pipeline (v2):
1. Alias screen on title+desc for C25 mentions
2. Coverage-spread filter: skip low-quality / one-sided articles
3. NER upgrade: BERT-NER to confirm and expand matches
4. Full-text fetch + re-screen
5. FinBERT: quick sentiment — drop neutral < FINBERT_MIN_CONF
6. Claude: structured extraction (tickers, magnitude, timeframe)
7. yfinance: momentum check — direction alignment
8. signal_score = sentiment_confidence × coverage_spread × momentum_alignment
9. Alert if signal_score > ALERT_THRESHOLD
Usage:
python3 analyze.py # analyze new articles only
python3 analyze.py --force # re-analyze everything
python3 analyze.py --limit 20 # limit to 20 articles
python3 analyze.py --dry-run # show matches without storing
python3 analyze.py --no-claude # skip Claude step (no API cost)
"""
import re
import os
import sys
import json
import time
import math
import sqlite3
import logging
import warnings
from pathlib import Path
# Silence transformer noise before importing
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
warnings.filterwarnings("ignore")
logging.getLogger("transformers").setLevel(logging.ERROR)
import torch
from transformers import pipeline
from dotenv import load_dotenv
# Load .env (supports both ANTHROPIC_API_KEY and anthropic_api_key)
_env_file = Path(__file__).parent / ".env"
if _env_file.exists():
load_dotenv(_env_file, override=False)
for _k, _v in list(os.environ.items()):
if _k.lower() == "anthropic_api_key" and "ANTHROPIC_API_KEY" not in os.environ:
os.environ["ANTHROPIC_API_KEY"] = _v
# Ground News helpers
sys.path.insert(0, str(Path(__file__).parent))
from ground_news import get_db, fetch_article_text, fetch_all
from rss_feeds import fetch_all_rss
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
C25_PATH = Path(__file__).parent / "c25.json"
_c25_raw = json.loads(C25_PATH.read_text())
C25: dict[str, dict] = {k: v for k, v in _c25_raw.items() if not k.startswith("_")}
# Build alias → ticker lookup (lower-cased for matching)
ALIAS_MAP: dict[str, str] = {}
for _ticker, _data in C25.items():
for _alias in _data["aliases"]:
al = _alias.lower()
if al not in ALIAS_MAP: # first alias wins (most specific first in c25.json)
ALIAS_MAP[al] = _ticker
DEVICE = -1 # always CPU — Quadro P400 (CC 6.1) too old + too little VRAM for these models
# Quality thresholds
MIN_SOURCES = 1 # coverage_spread naturally weights single-source articles near zero
MIN_COVERAGE_SPREAD = 0.0 # disabled: signal_score naturally zeros out single-source articles
FINBERT_MIN_CONF = 0.70 # drop neutral articles below this FinBERT confidence
ALERT_THRESHOLD = 0.35 # signal_score > this → alert
# ---------------------------------------------------------------------------
# Model loading
# ---------------------------------------------------------------------------
_ner_model = None
_finbert_model = None
def get_ner():
global _ner_model
if _ner_model is None:
print("[analyze] Loading dslim/bert-base-NER …", flush=True)
_ner_model = pipeline(
"ner",
model="dslim/bert-base-NER",
aggregation_strategy="simple",
device=DEVICE,
)
return _ner_model
def get_finbert():
global _finbert_model
if _finbert_model is None:
print("[analyze] Loading ProsusAI/finbert …", flush=True)
_finbert_model = pipeline(
"sentiment-analysis",
model="ProsusAI/finbert",
device=DEVICE,
truncation=True,
max_length=512,
)
return _finbert_model
# ---------------------------------------------------------------------------
# C25 alias matching
# ---------------------------------------------------------------------------
def match_c25(text: str) -> dict[str, float]:
"""
Find C25 companies mentioned in text.
Returns {ticker: confidence_score}.
"""
text_lower = text.lower()
matches: dict[str, float] = {}
for alias_lower, ticker in ALIAS_MAP.items():
if ticker in matches:
continue # already found this company
# Always use word boundaries — prevents "sonic" matching "hypersonic",
# "net" matching "internet", "iss" matching "mission", etc.
pat = r"(?<![a-zA-Z0-9])" + re.escape(alias_lower) + r"(?![a-zA-Z0-9])"
if re.search(pat, text_lower):
# Confidence: longer alias match = more reliable
conf = min(0.99, 0.70 + len(alias_lower) * 0.01)
matches[ticker] = conf
return matches
def merge_ner_matches(ner_result: list[dict], base: dict[str, float]) -> dict[str, float]:
"""
Cross-reference NER ORG entities with alias map.
Requires whole-token match to avoid 'EMA' matching 'd-ema-nt'.
"""
merged = dict(base)
for ent in ner_result:
if ent.get("entity_group") not in ("ORG", "PER"):
continue
word_tokens = set(re.split(r"[\s\-_/]+", ent["word"].lower().strip("##")))
for alias_lower, ticker in ALIAS_MAP.items():
if len(alias_lower) < 4:
continue
alias_tokens = set(re.split(r"[\s\-_/]+", alias_lower))
# Need significant token overlap, not just substring containment
overlap = alias_tokens & word_tokens
if overlap and len(overlap) / max(len(alias_tokens), len(word_tokens)) >= 0.5:
score = ent.get("score", 0.7)
if score > merged.get(ticker, 0):
merged[ticker] = score
return merged
# ---------------------------------------------------------------------------
# Coverage spread scoring
# ---------------------------------------------------------------------------
def coverage_spread_score(row) -> float:
"""
Quality score (01) based on source count and bias diversity.
High = many sources from left + right + centre. Low = few or echo chamber.
"""
src = row["source_count"] or 0
left = row["left_src_count"] or 0
right = row["right_src_count"] or 0
ctr = row["ctr_src_count"] or 0
if src < MIN_SOURCES:
return 0.0
quantity = min(1.0, math.log(max(1, src)) / math.log(50))
fl, fr, fc = left / src, right / src, ctr / src
diversity = min(1.0, (fl * fr * fc) ** (1 / 3) * 9) # peaks at equal thirds
return round(quantity * 0.6 + diversity * 0.4, 3)
# ---------------------------------------------------------------------------
# Claude structured extraction
# ---------------------------------------------------------------------------
def claude_extract(title: str, text: str, tickers: list[str]) -> dict:
"""
Use Claude Haiku to extract structured financial signal.
Returns {"confirmed_tickers", "magnitude", "timeframe", "reasoning"}.
"""
import anthropic
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": "(no API key)"}
client = anthropic.Anthropic(api_key=api_key)
ticker_ctx = "\n".join(
f" {t}: {C25[t]['name']} ({C25[t]['sector']})" for t in tickers if t in C25
)
prompt = f"""You are a financial analyst specializing in Scandinavian equities.
Analyze this news article and assess its financial impact on the listed Danish C25 companies.
## Companies to analyze:
{ticker_ctx}
## Article:
Title: {title}
{text[:1500]}
Respond ONLY with valid JSON (no markdown fences):
{{
"confirmed_tickers": ["NOVO-B"],
"magnitude": 7,
"timeframe": "days",
"reasoning": "Two sentences max on financial impact and direction."
}}
Fields:
- confirmed_tickers: only companies truly affected (can be [])
- magnitude: 110 (1=irrelevant, 10=major market mover)
- timeframe: "hours", "days", "weeks", or "months"
- reasoning: brief analyst note"""
try:
msg = client.messages.create(
model="claude-haiku-4-5",
max_tokens=256,
messages=[{"role": "user", "content": prompt}],
)
raw = msg.content[0].text.strip()
raw = re.sub(r"^```(?:json)?\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw)
return json.loads(raw)
except Exception as e:
print(f" [warn] Claude failed: {e}")
return {"confirmed_tickers": tickers, "magnitude": 5, "timeframe": "days", "reasoning": str(e)[:120]}
# ---------------------------------------------------------------------------
# yfinance momentum
# ---------------------------------------------------------------------------
_momentum_cache: dict[str, dict] = {}
def momentum_check(ticker: str) -> dict:
"""5-day price momentum for a C25 ticker via yfinance."""
if ticker in _momentum_cache:
return _momentum_cache[ticker]
import yfinance as yf
company = C25.get(ticker, {})
yahoo_ticker = company.get("yahoo_ticker", ticker + ".CO")
result: dict = {"direction": "unknown", "pct_5d": 0.0, "pct_20d": 0.0}
try:
hist = yf.Ticker(yahoo_ticker).history(period="1mo", auto_adjust=True)
if len(hist) >= 5:
close = hist["Close"]
pct_5d = float((close.iloc[-1] / close.iloc[-5] - 1) * 100)
pct_20d = float((close.iloc[-1] / close.iloc[0] - 1) * 100) if len(hist) >= 20 else 0.0
direction = "up" if pct_5d > 1.5 else ("down" if pct_5d < -1.5 else "flat")
result = {"direction": direction, "pct_5d": round(pct_5d, 2), "pct_20d": round(pct_20d, 2)}
except Exception:
pass
_momentum_cache[ticker] = result
return result
# ---------------------------------------------------------------------------
# Signal score
# ---------------------------------------------------------------------------
def calc_signal_score(sent_score: float, sentiment: str, coverage: float, momentum: dict) -> float:
"""signal_score = sentiment_confidence × coverage_spread × momentum_alignment"""
d = momentum.get("direction", "unknown")
if d == "unknown":
alignment = 0.5
elif d == "flat":
alignment = 0.7
elif (sentiment == "positive" and d == "up") or (sentiment == "negative" and d == "down"):
alignment = 1.0
else:
alignment = 0.4 # contrarian
return round(sent_score * coverage * alignment, 3)
# ---------------------------------------------------------------------------
# DB schema migration
# ---------------------------------------------------------------------------
def migrate_db(db) -> None:
"""Apply schema migrations for SQLite. No-op for Postgres (schema managed by db.py)."""
if hasattr(db, "db_type") and db.db_type == "postgres":
return
existing = {row[1] for row in db.execute("PRAGMA table_info(article_signals)").fetchall()}
new_cols = [
("coverage_spread", "REAL DEFAULT 0"),
("claude_tickers", "TEXT"),
("claude_magnitude", "INTEGER DEFAULT 5"),
("claude_timeframe", "TEXT"),
("claude_reasoning", "TEXT"),
("momentum_dir", "TEXT"),
("momentum_pct_5d", "REAL DEFAULT 0"),
("signal_score", "REAL DEFAULT 0"),
("alert", "INTEGER DEFAULT 0"),
]
for col_name, col_def in new_cols:
if col_name not in existing:
db.execute(f"ALTER TABLE article_signals ADD COLUMN {col_name} {col_def}")
db.commit()
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def analyze_articles(
*,
force: bool = False,
limit: int | None = None,
dry_run: bool = False,
use_claude: bool = True,
auto_fetch: bool = True,
) -> None:
db = get_db()
migrate_db(db)
# Auto-refresh articles (respects 30-min cache TTL)
if auto_fetch and not dry_run:
before = db.execute("SELECT COUNT(*) AS cnt FROM articles").fetchone()["cnt"]
print("[analyze] Refreshing Ground News feed …")
fetch_all(db)
print("[analyze] Henter danske RSS feeds …")
fetch_all_rss(db)
after = db.execute("SELECT COUNT(*) AS cnt FROM articles").fetchone()["cnt"]
if after > before:
print(f"[analyze] +{after - before} new articles")
base_q = """
SELECT slug, title, description, source_count,
left_src_count, right_src_count, ctr_src_count,
left_pct, right_pct, ctr_pct
FROM articles {where}
ORDER BY source_count DESC
"""
rows = db.execute(
base_q.format(where="" if force else
"WHERE slug NOT IN (SELECT DISTINCT article_slug FROM article_signals)")
).fetchall()
if limit:
rows = rows[:limit]
total = len(rows)
print(f"[analyze] {total} articles to process (force={force} dry_run={dry_run} claude={use_claude})")
if total == 0:
print("[analyze] Nothing to do.")
db.close()
return
# ------------------------------------------------------------------
# Phase 1 — Alias screen + coverage spread filter
# ------------------------------------------------------------------
print("[analyze] Phase 1: alias screen + coverage filter …")
screened: list[tuple] = []
dropped_cov = 0
for row in rows:
cov = coverage_spread_score(row)
if cov < MIN_COVERAGE_SPREAD:
dropped_cov += 1
continue
text = f"{row['title']}. {row['description'] or ''}"
matches = match_c25(text)
if matches:
screened.append((row, matches, cov))
print(f"[analyze] {len(screened)}/{total} passed ({dropped_cov} dropped by coverage filter)")
if not screened:
db.close()
return
# ------------------------------------------------------------------
# Phase 2 — NER upgrade
# ------------------------------------------------------------------
print("[analyze] Phase 2: NER upgrade …")
ner = get_ner()
texts = [f"{r['title']}. {r['description'] or ''}" for r, _, _ in screened]
BATCH = 16
all_ner = []
for i in range(0, len(texts), BATCH):
all_ner.extend(ner(texts[i : i + BATCH]))
enriched = [
(row, merge_ner_matches(ner_res, base), cov)
for (row, base, cov), ner_res in zip(screened, all_ner)
]
# ------------------------------------------------------------------
# Phase 3 — Full text + re-screen
# ------------------------------------------------------------------
print(f"[analyze] Phase 3: fetching full text for {len(enriched)} articles …")
final: list[tuple] = []
for idx, (row, matches, cov) in enumerate(enriched, 1):
slug = row["slug"]
if idx % 5 == 0 or idx == len(enriched):
print(f" {idx}/{len(enriched)}: {slug[:55]}")
# RSS artikler har teksten gemt i page_cache som "rss:{slug}"
cats = row["categories"] if "categories" in row.keys() else ""
if cats and cats.startswith("rss:"):
cache_row = db.execute(
"SELECT content FROM page_cache WHERE url = ?",
(f"rss:{slug}",),
).fetchone()
full_text = cache_row["content"] if cache_row else f"{row['title']}. {row['description'] or ''}"
else:
full_text = fetch_article_text(slug, db)
full_matches = match_c25(full_text)
for ticker, score in full_matches.items():
if score > matches.get(ticker, 0):
matches[ticker] = score
if matches:
final.append((row, matches, cov, full_text))
print(f"[analyze] {len(final)} articles with confirmed C25 mentions")
# ------------------------------------------------------------------
# Phase 4 — FinBERT sentiment (confidence filter)
# ------------------------------------------------------------------
print("[analyze] Phase 4: FinBERT sentiment …")
finbert = get_finbert()
now = int(time.time())
signals_written = 0
alerts_triggered = 0
for row, matches, cov, full_text in final:
slug = row["slug"]
title = row["title"]
try:
fb = finbert(" ".join(full_text.split()[:400]))[0]
sentiment = fb["label"].lower()
sent_score = round(fb["score"], 4)
except Exception as e:
print(f" [warn] FinBERT: {e}")
sentiment, sent_score = "neutral", 0.5
if sentiment == "neutral" and sent_score < FINBERT_MIN_CONF:
continue # drop low-confidence neutral noise
# ------------------------------------------------------------------
# Phase 5 — Claude extraction
# ------------------------------------------------------------------
claude_data: dict = {}
if use_claude and not dry_run and os.environ.get("ANTHROPIC_API_KEY"):
print(f" [claude] {slug[:50]}")
claude_data = claude_extract(title, full_text, list(matches.keys()))
# ------------------------------------------------------------------
# Phase 6 — yfinance momentum + scoring
# ------------------------------------------------------------------
for ticker, entity_score in matches.items():
company = C25[ticker]
full_lower = full_text.lower()
mention_count = max(1, sum(
len(re.findall(
r"(?<![a-zA-Z0-9])" + re.escape(a.lower()) + r"(?![a-zA-Z0-9])",
full_lower,
))
for a in company["aliases"]
))
momentum = momentum_check(ticker) if not dry_run else {}
sig_score = calc_signal_score(sent_score, sentiment, cov, momentum)
alert = sig_score > ALERT_THRESHOLD and sentiment != "neutral"
if dry_run:
print(
f" DRY: {slug[:38]:<38} | {ticker:<8} | "
f"{sentiment:<8} {sent_score:.2f} | cov={cov:.2f} | sig={sig_score:.3f}"
f"{'' if alert else ''}"
)
else:
db.upsert(
"article_signals",
["article_slug", "ticker"],
[
"article_slug", "ticker", "company_name", "sector",
"sentiment", "sentiment_score", "entity_score",
"mention_count", "full_text_used", "analyzed_at",
"coverage_spread", "claude_tickers", "claude_magnitude",
"claude_timeframe", "claude_reasoning",
"momentum_dir", "momentum_pct_5d", "signal_score", "alert",
],
(
slug, ticker, company["name"], company["sector"],
sentiment, float(sent_score), round(float(entity_score), 4),
mention_count, 1, now,
float(cov),
json.dumps(claude_data.get("confirmed_tickers", [])) or None,
claude_data.get("magnitude", 5),
claude_data.get("timeframe", "days"),
claude_data.get("reasoning", ""),
momentum.get("direction", "unknown"),
float(momentum.get("pct_5d", 0.0)),
float(sig_score),
int(alert),
),
)
signals_written += 1
if alert:
alerts_triggered += 1
icon = "" if sentiment == "positive" else ""
print(
f" ⚡ ALERT: {icon} {ticker} ({company['name']}) | "
f"{sentiment} {sent_score:.2f} | sig={sig_score:.3f} | {slug[:40]}"
)
if not dry_run:
db.commit()
print(f"[analyze] Done. {signals_written} signals written, {alerts_triggered} alerts triggered.")
else:
print(f"[analyze] Dry-run complete. {len(final)} articles matched.")
db.close()
# ---------------------------------------------------------------------------
# CLI — brug Makefile i stedet for at huske flags
# ---------------------------------------------------------------------------
def main() -> None:
import sys
force = "--force" in sys.argv
dry_run = "--dry" in sys.argv
analyze_articles(force=force, dry_run=dry_run)
if __name__ == "__main__":
main()

339
c25.json Normal file
View File

@@ -0,0 +1,339 @@
{
"_meta": {
"description": "C25 company universe with NER aliases, sector tags, price tiers, and leveraged instruments",
"updated": "2026-05-25",
"price_source": "Yahoo Finance (DKK, indicative — refresh with yfinance)",
"leveraged_source": "WisdomTree ETP catalogue (Xetra/Euronext Paris)",
"tiers": {
"budget": "< 200 DKK — buy directly, multiple shares",
"accessible": "200-500 DKK — buy 1-2 shares",
"expensive": "500-2000 DKK — consider leveraged ETP",
"inaccessible": "> 2000 DKK — leveraged ETP strongly recommended"
}
},
"AMBU-B": {
"name": "Ambu A/S",
"ticker_yahoo": "AMBU-B.CO",
"sector": "medtech",
"tier": "budget",
"price_dkk_approx": 68,
"aliases": [
"Ambu", "Ambu A/S", "Ambu B", "AMBU"
],
"keywords": ["endoscopy", "single-use", "endoscope", "bronchoscope", "disposable medical"],
"leveraged": []
},
"GN": {
"name": "GN Store Nord A/S",
"ticker_yahoo": "GN.CO",
"sector": "medtech",
"tier": "budget",
"price_dkk_approx": 95,
"aliases": [
"GN Store Nord", "GN Audio", "Jabra", "GN Group", "SteelSeries", "Beltone"
],
"keywords": ["hearing aids", "headsets", "audio", "cochlear implants", "Jabra headset"],
"leveraged": []
},
"TRYG": {
"name": "Tryg A/S",
"ticker_yahoo": "TRYG.CO",
"sector": "insurance",
"tier": "budget",
"price_dkk_approx": 156,
"aliases": [
"Tryg", "Tryg Forsikring", "Tryg A/S", "TrygVesta"
],
"keywords": ["insurance", "Danish insurance", "P&C insurance", "Nordic insurance"],
"leveraged": []
},
"ORSTED": {
"name": "Ørsted A/S",
"ticker_yahoo": "ORSTED.CO",
"sector": "energy",
"tier": "budget",
"price_dkk_approx": 167,
"aliases": [
"Ørsted", "Orsted", "DONG Energy", "Ørsted A/S", "Oersted"
],
"keywords": ["offshore wind", "wind energy", "renewable energy", "green energy",
"wind farm", "hydrogen", "North Sea wind"],
"leveraged": [
{"ticker": "3ORE", "name": "WisdomTree Ørsted 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SORE", "name": "WisdomTree Ørsted -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"ROCK-B": {
"name": "Rockwool International B",
"ticker_yahoo": "ROCK-B.CO",
"sector": "materials",
"tier": "budget",
"price_dkk_approx": 192,
"aliases": [
"Rockwool", "ROCKWOOL", "Rockwool International", "Rockfon", "Roxul"
],
"keywords": ["insulation", "stone wool", "building materials", "energy efficiency",
"fire safety insulation"],
"leveraged": []
},
"VWS": {
"name": "Vestas Wind Systems A/S",
"ticker_yahoo": "VWS.CO",
"sector": "energy",
"tier": "budget",
"price_dkk_approx": 195,
"aliases": [
"Vestas", "Vestas Wind", "Vestas Wind Systems", "VWS"
],
"keywords": ["wind turbines", "wind power", "offshore wind", "onshore wind",
"turbine manufacturer", "renewable energy"],
"leveraged": [
{"ticker": "3VWS", "name": "WisdomTree Vestas Wind Systems 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SVWS", "name": "WisdomTree Vestas Wind Systems -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"BAVA": {
"name": "Bavarian Nordic A/S",
"ticker_yahoo": "BAVA.CO",
"sector": "pharma",
"tier": "budget",
"price_dkk_approx": 197,
"aliases": [
"Bavarian Nordic", "BavNordic", "BAVA", "Imvamune", "Imvanex", "Jynneos",
"BN Biopharma"
],
"keywords": ["vaccines", "mpox vaccine", "smallpox vaccine", "RSV vaccine",
"cancer immunotherapy", "monkeypox"],
"leveraged": []
},
"DEMANT": {
"name": "Demant A/S",
"ticker_yahoo": "DEMANT.CO",
"sector": "medtech",
"tier": "accessible",
"price_dkk_approx": 247,
"aliases": [
"Demant", "William Demant", "Oticon", "Bernafon", "Sonic", "Philips Hearing"
],
"keywords": ["hearing aids", "hearing implants", "audiological equipment",
"cochlear implants", "Oticon hearing"],
"leveraged": []
},
"ISS": {
"name": "ISS A/S",
"ticker_yahoo": "ISS.CO",
"sector": "services",
"tier": "accessible",
"price_dkk_approx": 265,
"aliases": [
"ISS", "ISS A/S", "ISS World Services", "ISS Facility Services"
],
"keywords": ["facility services", "cleaning services", "facility management",
"workplace services"],
"leveraged": []
},
"NOVO-B": {
"name": "Novo Nordisk B",
"ticker_yahoo": "NOVO-B.CO",
"sector": "pharma",
"tier": "accessible",
"price_dkk_approx": 289,
"aliases": [
"Novo Nordisk", "Novo", "NVO", "NNO", "NovoNordisk",
"Ozempic", "Wegovy", "Rybelsus", "Victoza", "Semaglutide",
"Tresiba", "Levemir", "NovoLog", "Fiasp"
],
"keywords": ["GLP-1", "diabetes", "obesity", "insulin", "semaglutide",
"weight loss drug", "FDA approval", "EMA approval",
"weight management", "cardiovascular"],
"leveraged": [
{"ticker": "3NOV", "name": "WisdomTree Novo Nordisk 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SNOV", "name": "WisdomTree Novo Nordisk -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"ZEAL": {
"name": "Zealand Pharma A/S",
"ticker_yahoo": "ZEAL.CO",
"sector": "pharma",
"tier": "accessible",
"price_dkk_approx": 318,
"aliases": [
"Zealand Pharma", "Zealand", "ZEAL", "Zegalogue", "Dasiglucagon",
"Petrelintide", "Glepaglutide"
],
"keywords": ["peptide drugs", "GLP-1", "obesity", "short bowel syndrome",
"glucagon", "clinical trials", "FDA"],
"leveraged": []
},
"DANSKE": {
"name": "Danske Bank A/S",
"ticker_yahoo": "DANSKE.CO",
"sector": "finance",
"tier": "accessible",
"price_dkk_approx": 332,
"aliases": [
"Danske Bank", "Danske", "Danica Pension", "Northern Bank",
"Realkredit Danmark"
],
"keywords": ["Danish bank", "banking", "mortgage", "money laundering",
"Nordic finance", "interest rates"],
"leveraged": []
},
"NETC": {
"name": "Netcompany Group A/S",
"ticker_yahoo": "NETC.CO",
"sector": "technology",
"tier": "accessible",
"price_dkk_approx": 340,
"aliases": [
"Netcompany", "NETC", "Netcompany Group"
],
"keywords": ["IT consulting", "digital transformation", "public sector IT",
"cloud services", "Danish IT"],
"leveraged": []
},
"COLO-B": {
"name": "Coloplast B",
"ticker_yahoo": "COLO-B.CO",
"sector": "medtech",
"tier": "accessible",
"price_dkk_approx": 404,
"aliases": [
"Coloplast", "Coloplast B", "COLO", "Coloplast A/S"
],
"keywords": ["ostomy", "wound care", "continence care", "urology",
"medical devices", "stoma"],
"leveraged": []
},
"FLS": {
"name": "FLSmidth & Co. A/S",
"ticker_yahoo": "FLS.CO",
"sector": "industrials",
"tier": "expensive",
"price_dkk_approx": 516,
"aliases": [
"FLSmidth", "FL Smidth", "FLS", "FLSmidth & Co"
],
"keywords": ["cement equipment", "mining equipment", "green transition mining",
"cement plant", "copper mining"],
"leveraged": []
},
"PNDORA": {
"name": "Pandora A/S",
"ticker_yahoo": "PNDORA.CO",
"sector": "retail",
"tier": "expensive",
"price_dkk_approx": 556,
"aliases": [
"Pandora", "Pandora A/S", "Pandora jewelry", "Pandora charms",
"Pandora bracelet"
],
"keywords": ["jewelry", "silver jewelry", "charm bracelets",
"luxury goods", "consumer discretionary"],
"leveraged": []
},
"CARL-B": {
"name": "Carlsberg B",
"ticker_yahoo": "CARL-B.CO",
"sector": "beverages",
"tier": "expensive",
"price_dkk_approx": 878,
"aliases": [
"Carlsberg", "Carlsberg B", "Carlsberg Group", "Tuborg",
"Kronenbourg", "1664", "Baltika", "Astra"
],
"keywords": ["beer", "brewery", "lager", "alcohol", "beverage company",
"Western European beer", "Eastern European beer"],
"leveraged": [
{"ticker": "3CAR", "name": "WisdomTree Carlsberg 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SCAR", "name": "WisdomTree Carlsberg -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"NKT": {
"name": "NKT A/S",
"ticker_yahoo": "NKT.CO",
"sector": "industrials",
"tier": "expensive",
"price_dkk_approx": 1112,
"aliases": [
"NKT", "NKT A/S", "NKT Cables", "NKT cable"
],
"keywords": ["power cables", "high voltage cables", "offshore cables",
"subsea cables", "grid infrastructure", "energy transition"],
"leveraged": []
},
"DSV": {
"name": "DSV A/S",
"ticker_yahoo": "DSV.CO",
"sector": "logistics",
"tier": "inaccessible",
"price_dkk_approx": 1522,
"aliases": [
"DSV", "DSV A/S", "DSV Panalpina", "Panalpina", "DSV Air & Sea",
"DSV Road", "Schenker", "DB Schenker"
],
"keywords": ["freight", "logistics", "supply chain", "air freight",
"sea freight", "global logistics", "3PL"],
"leveraged": [
{"ticker": "3DSV", "name": "WisdomTree DSV 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SDSV", "name": "WisdomTree DSV -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"GMAB": {
"name": "Genmab A/S",
"ticker_yahoo": "GMAB.CO",
"sector": "pharma",
"tier": "inaccessible",
"price_dkk_approx": 1724,
"aliases": [
"Genmab", "GMAB", "Genmab A/S", "Darzalex", "Daratumumab",
"Epkinly", "Epcoritamab", "Kesimpta", "Ofatumumab"
],
"keywords": ["antibody drugs", "oncology", "multiple myeloma",
"cancer treatment", "bispecific antibodies", "FDA approval"],
"leveraged": [
{"ticker": "3GEN", "name": "WisdomTree Genmab 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SGEN", "name": "WisdomTree Genmab -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
},
"MAERSK-B": {
"name": "A.P. Møller - Mærsk B",
"ticker_yahoo": "MAERSK-B.CO",
"sector": "shipping",
"tier": "inaccessible",
"price_dkk_approx": 15280,
"aliases": [
"Mærsk", "Maersk", "A.P. Moller", "A.P. Møller", "AP Moller",
"Møller-Mærsk", "Moller-Maersk", "APM", "APM Terminals",
"Maersk Line", "Mærsk Line", "Sealand"
],
"keywords": ["container shipping", "shipping rates", "ocean freight",
"logistics", "Red Sea", "Suez Canal", "port congestion",
"supply chain", "container rates", "spot rates"],
"leveraged": [
{"ticker": "3MRS", "name": "WisdomTree A.P. Møller-Mærsk 3x Daily Long", "exchange": "Xetra", "direction": "long"},
{"ticker": "SMRS", "name": "WisdomTree A.P. Møller-Mærsk -3x Daily Short", "exchange": "Xetra", "direction": "short"}
]
}
}

327
dashboard.py Normal file
View File

@@ -0,0 +1,327 @@
"""
dashboard.py — MoneyMaker live monitoring dashboard.
Usage:
python dashboard.py # starts on http://localhost:5001
python dashboard.py --port 5002
Auto-refreshes every 60 seconds. Shows:
• Portfolio P&L + C25 benchmark
• Open positions with live prices
• Closed trades (win/loss)
• Signal accuracy
• Recent runner log tail
"""
import argparse
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from flask import Flask, render_template_string
from db import get_conn, DB_TYPE
from report import _c25_day_return, _unrealised_pnl, _realised_pnl, _total_fees, _signal_accuracy
CAPITAL = 10_000
LOG_DIR = Path(__file__).parent / "logs"
REFRESH = 60 # seconds
app = Flask(__name__)
# ── HTML template ────────────────────────────────────────────────────────────
TEMPLATE = """
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{ refresh }}">
<title>MoneyMaker Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e2e8f0; padding: 24px; }
h1 { font-size: 1.4rem; font-weight: 700; color: #7dd3fc; margin-bottom: 4px; }
.subtitle { font-size: 0.8rem; color: #64748b; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #1e2535; border-radius: 10px; padding: 18px 20px; border: 1px solid #2d3748; }
.card .label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; color: #64748b; margin-bottom: 6px; }
.card .value { font-size: 1.6rem; font-weight: 700; }
.card .sub { font-size: 0.78rem; color: #94a3b8; margin-top: 4px; }
.pos { color: #4ade80; }
.neg { color: #f87171; }
.neu { color: #94a3b8; }
.warn { color: #fbbf24; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th { text-align: left; padding: 8px 12px; background: #1e2535; color: #64748b; font-size: 0.72rem; text-transform: uppercase; letter-spacing: .06em; border-bottom: 1px solid #2d3748; }
td { padding: 9px 12px; border-bottom: 1px solid #1a2030; }
tr:hover td { background: #1e2535; }
.section { background: #161c2d; border-radius: 10px; border: 1px solid #2d3748; margin-bottom: 20px; overflow: hidden; }
.section-title { padding: 14px 18px; font-size: 0.8rem; font-weight: 600; color: #7dd3fc; text-transform: uppercase; letter-spacing: .08em; border-bottom: 1px solid #2d3748; background: #1a2236; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 99px; font-size: 0.72rem; font-weight: 600; }
.badge-green { background: #14532d; color: #4ade80; }
.badge-red { background: #450a0a; color: #f87171; }
.badge-gray { background: #1e2535; color: #94a3b8; }
pre { font-family: 'Cascadia Code', 'Fira Mono', monospace; font-size: 0.75rem; color: #94a3b8; padding: 16px 18px; overflow-x: auto; white-space: pre-wrap; line-height: 1.6; }
.footer { font-size: 0.72rem; color: #334155; text-align: center; margin-top: 32px; }
</style>
</head>
<body>
<h1>📈 MoneyMaker</h1>
<div class="subtitle">DB: {{ db_type }} &nbsp;·&nbsp; Opdateret: {{ now }} &nbsp;·&nbsp; Refresh om {{ refresh }}s</div>
<!-- KPI cards -->
<div class="grid">
<div class="card">
<div class="label">Net P&amp;L</div>
<div class="value {{ 'pos' if net_pnl >= 0 else 'neg' }}">{{ "{:+,.0f}".format(net_pnl) }} kr</div>
<div class="sub">{{ "{:+.2f}%".format(net_pct) }}</div>
</div>
<div class="card">
<div class="label">Urealiseret</div>
<div class="value {{ 'pos' if unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(unreal) }} kr</div>
<div class="sub">{{ open_count }} åben{{ 'e' if open_count != 1 else '' }} position{{ 'er' if open_count != 1 else '' }}</div>
</div>
<div class="card">
<div class="label">Realiseret</div>
<div class="value {{ 'pos' if realised >= 0 else 'neg' }}">{{ "{:+,.0f}".format(realised) }} kr</div>
<div class="sub">Gebyrer: {{ "{:,.0f}".format(fees) }} kr</div>
</div>
<div class="card">
<div class="label">C25 i dag</div>
{% if c25_ret is not none %}
<div class="value {{ 'pos' if c25_ret >= 0 else 'neg' }}">{{ "{:+.2f}%".format(c25_ret) }}</div>
<div class="sub {{ 'pos' if vs_bench and vs_bench >= 0 else 'neg' }}">
vs benchmark: {{ "{:+.2f}%".format(vs_bench) if vs_bench is not none else "" }}
</div>
{% else %}
<div class="value neu">—</div>
<div class="sub">Marked lukket?</div>
{% endif %}
</div>
<div class="card">
<div class="label">Signal accuracy</div>
{% if sig.total_trades > 0 %}
<div class="value {{ 'pos' if sig.accuracy_pct >= 50 else 'neg' }}">{{ "{:.0f}%".format(sig.accuracy_pct) }}</div>
<div class="sub">{{ sig.correct }} / {{ sig.total_trades }} handler korrekte</div>
{% else %}
<div class="value neu">—</div>
<div class="sub">Ingen lukkede handler</div>
{% endif %}
</div>
<div class="card">
<div class="label">Kapital</div>
<div class="value neu">{{ "{:,.0f}".format(capital) }} kr</div>
<div class="sub">Kontant: {{ "{:,.0f}".format(cash) }} kr</div>
</div>
</div>
<!-- Open positions -->
<div class="section">
<div class="section-title">📊 Åbne positioner</div>
{% if positions %}
<table>
<tr><th>Ticker</th><th>Antal</th><th>Købt</th><th>Nu</th><th>P&amp;L</th><th>Ændring</th><th>Stop</th><th>Take</th><th>Status</th></tr>
{% for p in positions %}
<tr>
<td><strong>{{ p.ticker }}</strong></td>
<td>{{ "{:.0f}".format(p.shares) }}</td>
<td>{{ "{:,.0f}".format(p.entry) }}</td>
<td>{{ "{:,.0f}".format(p.last) }}</td>
<td class="{{ 'pos' if p.unreal >= 0 else 'neg' }}">{{ "{:+,.0f}".format(p.unreal) }}</td>
<td class="{{ 'pos' if p.pct >= 0 else 'neg' }}">{{ "{:+.1f}%".format(p.pct) }}</td>
<td class="neg">{{ "{:,.0f}".format(p.stop) }}</td>
<td class="pos">{{ "{:,.0f}".format(p.take) }}</td>
<td>
{% if p.stop_hit %}<span class="badge badge-red">🔴 STOP</span>
{% elif p.take_hit %}<span class="badge badge-green">🟡 TAKE</span>
{% else %}<span class="badge badge-gray">⏳ HOLD</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen åbne positioner.</p>
{% endif %}
</div>
<!-- Closed trades -->
<div class="section">
<div class="section-title">🏁 Lukkede handler</div>
{% if trades %}
<table>
<tr><th>Ticker</th><th>Handling</th><th>Antal</th><th>Kurs</th><th>Total</th><th>P&amp;L</th><th>Signal</th><th>Dato</th></tr>
{% for t in trades %}
<tr>
<td><strong>{{ t.ticker }}</strong></td>
<td>{{ t.action.upper() }}</td>
<td>{{ "{:.0f}".format(t.shares) }}</td>
<td>{{ "{:,.0f}".format(t.price) }}</td>
<td>{{ "{:,.0f}".format(t.total_dkk) }}</td>
<td class="{{ 'pos' if t.pnl_dkk and t.pnl_dkk >= 0 else ('neg' if t.pnl_dkk else 'neu') }}">
{{ "{:+,.0f}".format(t.pnl_dkk) if t.pnl_dkk is not none else "" }}
</td>
<td>
{% if t.signal_correct == 1 %}<span class="badge badge-green">✅ korrekt</span>
{% elif t.signal_correct == 0 %}<span class="badge badge-red">❌ forkert</span>
{% else %}<span class="badge badge-gray">—</span>{% endif %}
</td>
<td>{{ t.event_date }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p style="padding:16px 18px; color:#64748b; font-size:0.85rem;">Ingen handler endnu.</p>
{% endif %}
</div>
<!-- Signal pipeline stats -->
<div class="section">
<div class="section-title">🔬 NLP signal pipeline</div>
<table>
<tr><th>Analyserede signaler</th><th>Alert-triggers (≥threshold)</th><th>Gns. score</th></tr>
<tr>
<td>{{ sig.total }}</td>
<td>{{ sig.alerts }}</td>
<td>{{ "{:.3f}".format(sig.avg_score) }}</td>
</tr>
</table>
</div>
<!-- Log tail -->
{% if log_tail %}
<div class="section">
<div class="section-title">📋 Seneste log ({{ log_file }})</div>
<pre>{{ log_tail }}</pre>
</div>
{% endif %}
<div class="footer">MoneyMaker · {{ db_type }} · hjess-desktop · auto-refresh {{ refresh }}s</div>
</body>
</html>
"""
# ── Data helpers ─────────────────────────────────────────────────────────────
def _latest_log_tail(lines: int = 40) -> tuple[str, str]:
"""Return (filename, last N lines) from most recent runner log."""
if not LOG_DIR.exists():
return "", ""
logs = sorted(LOG_DIR.glob("runner_*.log"), reverse=True)
if not logs:
return "", ""
path = logs[0]
try:
content = path.read_text(errors="replace")
tail = "\n".join(content.splitlines()[-lines:])
return path.name, tail
except Exception:
return path.name, ""
def _open_positions_live(db) -> list[dict]:
"""Fetch open positions with live yfinance prices."""
rows = db.execute("SELECT * FROM positions").fetchall()
result = []
for p in rows:
ticker = p["ticker"]
from report import C25
yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
try:
last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or p["entry_price"]
except Exception:
last = p["entry_price"]
entry = float(p["entry_price"])
shares = float(p["shares"])
pct = (last - entry) / entry * 100
result.append({
"ticker": ticker,
"shares": shares,
"entry": entry,
"last": last,
"unreal": shares * (last - entry),
"pct": pct,
"stop": float(p["stop_loss"]),
"take": float(p["take_profit"]),
"stop_hit": last <= p["stop_loss"],
"take_hit": last >= p["take_profit"],
})
return result
def _closed_trades(db, limit: int = 20) -> list[dict]:
rows = db.execute("""
SELECT ticker, action, shares, price, total_dkk, pnl_dkk, signal_correct, event_date
FROM position_events
ORDER BY id DESC LIMIT ?
""", (limit,)).fetchall()
return [dict(r) for r in rows]
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route("/")
def index():
db = get_conn()
try:
positions = _open_positions_live(db)
trades = _closed_trades(db)
unreal = sum(p["unreal"] for p in positions)
invested = sum(p["shares"] * p["entry"] for p in positions)
realised = _realised_pnl(db)
fees = _total_fees(db)
sig = _signal_accuracy(db)
c25_ret = _c25_day_return()
net_pnl = realised + unreal - fees
net_pct = net_pnl / CAPITAL * 100
vs_bench = (net_pct - c25_ret) if c25_ret is not None else None
cash = CAPITAL - invested
log_file, log_tail = _latest_log_tail()
now = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M UTC")
return render_template_string(
TEMPLATE,
now=now,
db_type=DB_TYPE,
refresh=REFRESH,
capital=CAPITAL,
cash=cash,
unreal=unreal,
realised=realised,
fees=fees,
net_pnl=net_pnl,
net_pct=net_pct,
c25_ret=c25_ret,
vs_bench=vs_bench,
positions=positions,
open_count=len(positions),
trades=trades,
sig=type("Sig", (), sig)(),
log_file=log_file,
log_tail=log_tail,
)
finally:
db.close()
@app.route("/health")
def health():
return {"status": "ok", "db": DB_TYPE, "ts": time.time()}
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="MoneyMaker Dashboard")
parser.add_argument("--port", type=int, default=5001)
parser.add_argument("--host", default="0.0.0.0")
args = parser.parse_args()
print(f"\n MoneyMaker Dashboard → http://localhost:{args.port}\n")
app.run(host=args.host, port=args.port, debug=False)
if __name__ == "__main__":
main()

491
db.py Normal file
View File

@@ -0,0 +1,491 @@
"""
db.py — Database abstraction layer for MoneyMaker.
Supports both PostgreSQL (production, int.i80.dk) and SQLite (local dev).
Usage:
from db import get_conn, DB_TYPE
conn = get_conn()
rows = conn.execute("SELECT * FROM articles").fetchall()
conn.commit()
conn.close()
Set DATABASE_URL in .env to use PostgreSQL:
DATABASE_URL=postgresql://moneymaker:pass@int.i80.dk:5432/moneymaker
Without DATABASE_URL, falls back to SQLite (ground_news.db).
"""
import os
import sqlite3
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "")
SQLITE_PATH = Path(__file__).parent / "ground_news.db"
DB_TYPE = "postgres" if DATABASE_URL else "sqlite"
# ── DBConn wrapper ────────────────────────────────────────────────────────────
class DBConn:
"""
Unified connection wrapper for SQLite and PostgreSQL.
- execute(sql, params): normalises ? → %s for Postgres; returns dict-row cursor
- upsert(table, pk, cols, vals): cross-DB INSERT OR REPLACE / ON CONFLICT DO UPDATE
- commit() / rollback() / close()
"""
def __init__(self, conn, db_type: str):
self._conn = conn
self.db_type = db_type
def _sql(self, sql: str) -> str:
"""Translate ? placeholders to %s for Postgres."""
if self.db_type == "postgres":
return sql.replace("?", "%s")
return sql
def execute(self, sql: str, params=None):
sql = self._sql(sql)
if self.db_type == "postgres":
import psycopg2.extras
cur = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
return cur
else:
return self._conn.execute(sql, params)
def executemany(self, sql: str, seq):
sql = self._sql(sql)
if self.db_type == "postgres":
cur = self._conn.cursor()
cur.executemany(sql, seq)
else:
self._conn.executemany(sql, seq)
def upsert(self, table: str, pk, cols: list, vals: tuple):
"""
Cross-DB upsert.
pk: single column name (str) or list of column names for composite PK.
SQLite: INSERT OR REPLACE INTO table (cols) VALUES (?,...)
Postgres: INSERT INTO table (cols) VALUES (%s,...) ON CONFLICT (pk) DO UPDATE SET ...
"""
if isinstance(pk, str):
pk = [pk]
if self.db_type == "postgres":
col_sql = ", ".join(cols)
val_sql = ", ".join(["%s"] * len(cols))
non_pk = [c for c in cols if c not in pk]
pk_sql = ", ".join(pk)
if non_pk:
update = ", ".join(f"{c}=EXCLUDED.{c}" for c in non_pk)
sql = (
f"INSERT INTO {table} ({col_sql}) VALUES ({val_sql}) "
f"ON CONFLICT ({pk_sql}) DO UPDATE SET {update}"
)
else:
sql = (
f"INSERT INTO {table} ({col_sql}) VALUES ({val_sql}) "
f"ON CONFLICT ({pk_sql}) DO NOTHING"
)
cur = self._conn.cursor()
cur.execute(sql, vals)
else:
col_sql = ", ".join(cols)
val_sql = ", ".join(["?"] * len(cols))
self._conn.execute(
f"INSERT OR REPLACE INTO {table} ({col_sql}) VALUES ({val_sql})",
vals,
)
def commit(self):
self._conn.commit()
def rollback(self):
self._conn.rollback()
def close(self):
self._conn.close()
def __enter__(self):
return self
def __exit__(self, exc_type, *_):
if exc_type:
self.rollback()
else:
self.commit()
self.close()
# ── Placeholder helpers (kept for legacy usage) ───────────────────────────────
def ph(n: int = 1) -> str:
"""Return query placeholder: %s for postgres, ? for sqlite."""
return "%s" if DB_TYPE == "postgres" else "?"
def placeholders(n: int) -> str:
"""Return comma-separated placeholders for n values."""
p = ph()
return ", ".join([p] * n)
# ── Connection ────────────────────────────────────────────────────────────────
def get_conn() -> DBConn:
"""Return a DBConn wrapper (Postgres or SQLite)."""
if DB_TYPE == "postgres":
import psycopg2
conn = psycopg2.connect(DATABASE_URL)
conn.autocommit = False
return DBConn(conn, "postgres")
else:
conn = sqlite3.connect(str(SQLITE_PATH))
conn.row_factory = sqlite3.Row
return DBConn(conn, "sqlite")
# ── Schema ────────────────────────────────────────────────────────────────────
SCHEMA_SQLITE = """
CREATE TABLE IF NOT EXISTS page_cache (
url TEXT PRIMARY KEY,
page_type TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rss_feed_cache (
feed_id TEXT PRIMARY KEY,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS articles (
slug TEXT PRIMARY KEY,
story_id TEXT,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
source_count INTEGER,
bias_src_count INTEGER,
left_pct REAL,
ctr_pct REAL,
right_pct REAL,
left_src_count INTEGER,
ctr_src_count INTEGER,
right_src_count INTEGER,
overall_bias REAL,
blindspot TEXT,
factuality_json TEXT,
interests_json TEXT,
categories TEXT,
first_seen INTEGER,
last_seen INTEGER
);
CREATE TABLE IF NOT EXISTS article_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_slug TEXT NOT NULL,
ticker TEXT NOT NULL,
company_name TEXT NOT NULL,
sector TEXT,
sentiment TEXT,
sentiment_score REAL,
entity_score REAL,
mention_count INTEGER,
full_text_used INTEGER,
analyzed_at INTEGER NOT NULL,
coverage_spread REAL,
claude_tickers TEXT,
claude_magnitude INTEGER,
claude_timeframe TEXT,
claude_reasoning TEXT,
momentum_dir TEXT,
momentum_pct_5d REAL,
signal_score REAL,
alert INTEGER
);
CREATE TABLE IF NOT EXISTS positions (
ticker TEXT PRIMARY KEY,
shares REAL NOT NULL,
entry_price REAL NOT NULL,
entry_date TEXT NOT NULL,
stop_loss REAL NOT NULL,
take_profit REAL NOT NULL,
note TEXT
);
CREATE TABLE IF NOT EXISTS position_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
action TEXT NOT NULL,
shares REAL NOT NULL,
price REAL NOT NULL,
total_dkk REAL NOT NULL,
fee_dkk REAL,
pnl_dkk REAL,
signal_correct INTEGER,
event_date TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saxo_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
direction TEXT NOT NULL,
shares REAL NOT NULL,
price_dkk REAL,
total_dkk REAL,
fee_dkk REAL,
saxo_order_id TEXT,
status TEXT NOT NULL,
signal_score REAL,
analyst_rec TEXT,
placed_at TEXT NOT NULL,
filled_at TEXT,
note TEXT
);
"""
SCHEMA_POSTGRES = """
CREATE TABLE IF NOT EXISTS page_cache (
url TEXT PRIMARY KEY,
page_type TEXT NOT NULL,
fetched_at BIGINT NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rss_feed_cache (
feed_id TEXT PRIMARY KEY,
fetched_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS articles (
slug TEXT PRIMARY KEY,
story_id TEXT,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
source_count INTEGER,
bias_src_count INTEGER,
left_pct REAL,
ctr_pct REAL,
right_pct REAL,
left_src_count INTEGER,
ctr_src_count INTEGER,
right_src_count INTEGER,
overall_bias REAL,
blindspot TEXT,
factuality_json TEXT,
interests_json TEXT,
categories TEXT,
first_seen BIGINT,
last_seen BIGINT
);
CREATE TABLE IF NOT EXISTS article_signals (
id SERIAL PRIMARY KEY,
article_slug TEXT NOT NULL,
ticker TEXT NOT NULL,
company_name TEXT NOT NULL,
sector TEXT,
sentiment TEXT,
sentiment_score REAL,
entity_score REAL,
mention_count INTEGER,
full_text_used INTEGER,
analyzed_at BIGINT NOT NULL,
coverage_spread REAL,
claude_tickers TEXT,
claude_magnitude INTEGER,
claude_timeframe TEXT,
claude_reasoning TEXT,
momentum_dir TEXT,
momentum_pct_5d REAL,
signal_score REAL,
alert INTEGER,
UNIQUE(article_slug, ticker)
);
CREATE TABLE IF NOT EXISTS positions (
ticker TEXT PRIMARY KEY,
shares REAL NOT NULL,
entry_price REAL NOT NULL,
entry_date TEXT NOT NULL,
stop_loss REAL NOT NULL,
take_profit REAL NOT NULL,
note TEXT
);
CREATE TABLE IF NOT EXISTS position_events (
id SERIAL PRIMARY KEY,
ticker TEXT NOT NULL,
action TEXT NOT NULL,
shares REAL NOT NULL,
price REAL NOT NULL,
total_dkk REAL NOT NULL,
fee_dkk REAL,
pnl_dkk REAL,
signal_correct SMALLINT,
event_date TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saxo_orders (
id SERIAL PRIMARY KEY,
ticker TEXT NOT NULL,
direction TEXT NOT NULL,
shares REAL NOT NULL,
price_dkk REAL,
total_dkk REAL,
fee_dkk REAL,
saxo_order_id TEXT,
status TEXT NOT NULL,
signal_score REAL,
analyst_rec TEXT,
placed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
filled_at TIMESTAMPTZ,
note TEXT
);
"""
def init_schema():
"""Create all tables if they don't exist. Safe to run multiple times."""
if DB_TYPE == "postgres":
import psycopg2
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
for stmt in SCHEMA_POSTGRES.split(";"):
stmt = stmt.strip()
if stmt:
cur.execute(stmt)
# Add UNIQUE constraint for article_signals (article_slug, ticker) if missing
cur.execute("""
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_article_ticker'
AND conrelid = 'article_signals'::regclass
""")
if not cur.fetchone():
cur.execute(
"ALTER TABLE article_signals "
"ADD CONSTRAINT uq_article_ticker UNIQUE (article_slug, ticker)"
)
conn.commit()
cur.close()
conn.close()
else:
conn = sqlite3.connect(str(SQLITE_PATH))
conn.executescript(SCHEMA_SQLITE)
conn.commit()
conn.close()
print(f" Schema initialiseret ({DB_TYPE})")
# ── SQLite → Postgres migration ───────────────────────────────────────────────
def migrate_sqlite_to_postgres():
"""
One-time migration: copy all data from local SQLite to Postgres.
Safe to run multiple times (uses INSERT OR IGNORE / ON CONFLICT DO NOTHING).
"""
if DB_TYPE != "postgres":
print("DATABASE_URL ikke sat — kører ikke migration")
return
import psycopg2
sqlite_conn = sqlite3.connect(str(SQLITE_PATH))
sqlite_conn.row_factory = sqlite3.Row
pg_conn = get_conn()
pg_cur = pg_conn.cursor()
tables = [
"page_cache", "rss_feed_cache", "articles",
"article_signals", "positions", "position_events"
]
for table in tables:
rows = sqlite_conn.execute(f"SELECT * FROM {table}").fetchall()
if not rows:
print(f" {table}: 0 rækker — skip")
continue
cols = list(rows[0].keys())
cols_sql = ", ".join(cols)
vals_sql = ", ".join(["%s"] * len(cols))
# SERIAL columns (id) — let Postgres auto-assign
insert_cols = [c for c in cols if c != "id"] if "id" in cols else cols
insert_vals = ", ".join(["%s"] * len(insert_cols))
insert_cols_sql = ", ".join(insert_cols)
inserted = 0
skipped = 0
for row in rows:
# Coerce bytes (SQLite blob) to float/None for Postgres REAL columns
values = []
for c in insert_cols:
v = row[c]
if isinstance(v, (bytes, bytearray)):
try:
import struct
v = struct.unpack('d', v)[0]
except Exception:
v = None
values.append(v)
values = tuple(values)
try:
pg_cur.execute(
f"INSERT INTO {table} ({insert_cols_sql}) VALUES ({insert_vals}) "
f"ON CONFLICT DO NOTHING",
values
)
if pg_cur.rowcount > 0:
inserted += 1
else:
skipped += 1
except Exception as e:
skipped += 1
pg_conn.commit()
print(f" {table}: {inserted} indsat, {skipped} sprunget over")
sqlite_conn.close()
pg_conn.close()
print(" Migration færdig!")
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd == "init":
init_schema()
elif cmd == "migrate":
print(f"Migrerer SQLite → PostgreSQL ...")
init_schema()
migrate_sqlite_to_postgres()
elif cmd == "status":
conn = get_conn()
if DB_TYPE == "postgres":
raw = conn._conn.cursor()
raw.execute("SELECT tablename FROM pg_tables WHERE schemaname='public'")
tables = [r[0] for r in raw.fetchall()]
for t in sorted(tables):
raw.execute(f"SELECT COUNT(*) FROM {t}")
print(f" {t}: {raw.fetchone()[0]} rækker")
raw.close()
else:
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
for t in tables:
n = conn.execute(f"SELECT COUNT(*) AS cnt FROM {t['name']}").fetchone()["cnt"]
print(f" {t['name']}: {n} rækker")
conn.close()
print(f"\n DB type: {DB_TYPE}")
else:
print("Brug: python db.py [init|migrate|status]")

Binary file not shown.

BIN
ground-news-ext/src/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
ground-news-ext/src/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 B

BIN
ground-news-ext/src/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

BIN
ground-news-ext/src/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 B

View File

@@ -0,0 +1,277 @@
Manifest-Version: 1.0
Name: manifest.json
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: V2IFvyo6JSf58YUHJqeIHIVd8E0=
SHA256-Digest: M69JukMYgZUNWY5jUT7q2g/FMX2PXGWy9PwNjwI0vug=
Name: ground-symbol.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 5uwl9dTCzc0AQhNv/F3NJ9ut+9o=
SHA256-Digest: cZMyBTOYwSPONcdGNpzsYniDOrCRIZYzmj2lHi6GuD0=
Name: popup.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: zd6EwEaQEtVLluTkn9Qa1cmSlBM=
SHA256-Digest: Cl3i0OIY42Ua17VkMTW5fV5ibdGpMC0XZdTOa46FQTc=
Name: ground-symbol.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: kJidXptlh8QBtcvPwMXpE7/cmcg=
SHA256-Digest: ey8u+oxBDz1R4Q3ufxVSro2lR/OEJveygFDU+iGWYF8=
Name: 48.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 7TqQN9wX+SMdWlfHtDc8ERPuqEQ=
SHA256-Digest: giJa2wAqDjcuFUhFHWVEGzt8MBA5lgnTPCTGdCS9Vn0=
Name: menu.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 1ey0NcYZx5NDTSD76kqaCyW8f/A=
SHA256-Digest: Z7g61Y7ThdKbvxy3ynCZVDBM4LBnsyL7I0gya/dVXHw=
Name: 16.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 28sU5Whyop4p03vu+iOpWePL8Kc=
SHA256-Digest: 2AbeMUuiz3Oel0suwHBBLXUw5lOgSgcHRlVnxRoB08k=
Name: frame.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: qQtfJt0TPw66a+OZUy/f9TcKMkg=
SHA256-Digest: YAl5l7WMe0/XJuynQ4TXKF+/V42PwZG73R/s8/vX/18=
Name: 128.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: tZEoUW0ARt4P+NYAyqj0WEq71sQ=
SHA256-Digest: pqWObGJS7LTHpHYP1cdonw3V+V8FQp8BpReuEVEQkh4=
Name: 32.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: QaTkY7NOk/8VVnaX0an28ddSDcs=
SHA256-Digest: jRtP4s1aGgeYFbSUJJ5WhS3+EWixi9CI3GP1iLiCqM0=
Name: options.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: akU7E1yGbFYopo5ni9ddunplHqE=
SHA256-Digest: w49o/x8U5+6stagI/4bUiUFeWQ7kDIZnDnrtTBG/ocE=
Name: js/facebook.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: cj7aAD1Z5g0H0Urujpec9+aBqt0=
SHA256-Digest: gnS69LtD/hZle1w6Mkc+gvCfQ7CiydY1pXbyoHywVT0=
Name: js/popup.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: AwazIGkUjD2NGwgDmaHhOTngBW4=
SHA256-Digest: t7v8gi72iMcKYn4FD+Wil3Aa6HSTASE7RB4/k74R0Ls=
Name: js/background.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: /TYelJW8I50597wbu69V9S45zLM=
SHA256-Digest: 0z3cbY19uJhPqmCJgh1jji6VLOg70wZVmgw9HnsnDis=
Name: js/bluesky.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: MNCsuEQO93hn6xAjNksh/3678wY=
SHA256-Digest: gRf74GPcfIql/AriGau6sFJJWi/cBfTGWoQ5euJ43pU=
Name: js/ground.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Z61BeUMG3Zx3wbpDXvmhd0Cw0fA=
SHA256-Digest: USQGUOKIO2vkzT4m1lkwvHotj22y1vZi1Mz6/+EaWN0=
Name: js/index.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 2jmj7l5rSw0yVb/vlWAYkK/YBwk=
SHA256-Digest: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
Name: js/reddit.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: saXwwYIfh/QNKgEN0MDsI1YlvVA=
SHA256-Digest: BvhbExjoLWewpuaKseghfprRLpcUXWrD6YNNzFDS/a4=
Name: js/options.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 2jmj7l5rSw0yVb/vlWAYkK/YBwk=
SHA256-Digest: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
Name: js/content_script.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 9R3v5pGCWxLFQn+25JVUN2fv5/s=
SHA256-Digest: oGvKwnOBHK95FBzYz+wE57a3T8hsERlX48rv4nSDW8Q=
Name: js/bluesky.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/content_script.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/linkedin.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: rujsrH7/oW7qcOTozsJFrdw3nkw=
SHA256-Digest: cStig9r2199Q+qT/pnz/0XWmWIMMJ5p/XajjdEjt7Zg=
Name: js/menu.js.LICENSE.txt
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: pZUsR2vKEMwO/5CvnF81Xe0Y0nc=
SHA256-Digest: d9DmR6YgLL2mplsHeBjLZlK49guK2hllnzO1Gro2mjA=
Name: js/reddit.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/linkedin.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/twitter.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: iGu05BP2isx45kVOVcb/O68TWWc=
SHA256-Digest: +9FaLzeoca1V/mGIKuxZz2uPzm/36aVki/vX3/JSXCo=
Name: js/menu.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: FeWa454d9X6GXyH7HkFSwTrTFnQ=
SHA256-Digest: TH42cXnLQY3wjmXLd0MCK+IXHLGOuDBHKnPuF8QR4ts=
Name: js/twitter.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/facebook.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: fonts/UniversalSans-800.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: R01ScgAtNGc7qQFIUyrZGcCHE4s=
SHA256-Digest: s/7cq8YWjWQRWg4xJ0JZFBw17O5NxNZXG7O5/F5lggE=
Name: fonts/UniversalSans-680.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Lwp0bnjR0mnTLP0g/j4H1iMpIBk=
SHA256-Digest: ynJEQsy28v6X9Y/gqOs0lQ9hFK7AkwuMUMvXN0JA8Do=
Name: fonts/UniversalSans-800.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: z8F3l76le5ETg3V6kpTcmcvKLg4=
SHA256-Digest: eCmjTqBZPuAgdgXawQu5+l6GbGVqCsd5x8RbhAVdabM=
Name: fonts/UniversalSans-480.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: hOizxWx9ZO5V4hb+ur3xcOCh7ls=
SHA256-Digest: ALRea9OYPxfKRpuUwYuikx4D2NPuYQxxGmtDKvtBH7A=
Name: fonts/UniversalSans-680.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Z3iFwBl47GvgfYXY3CCtip8EoMA=
SHA256-Digest: CX6/qn4t6JdXyMGjleJB19YwZgw0hujgG7CgRhT2a4Q=
Name: fonts/UniversalSans-480.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: QHIRDYioCWTm+u0Y4XHmy2pUdEE=
SHA256-Digest: 0+IQVOpNNsM1YiCLxuLsZKEHsQIuAemWVwgu1vnFXLY=
Name: icons/info.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: xSN0y22E9y+SnvukSpK5lVPmkgI=
SHA256-Digest: uzYDK5vpqHyFwtDSDbBu7XcOXxPiR2yYchY85rtR8LY=
Name: icons/bookmark-1-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: lpcL3lTckaLVyyWFmNyvtncPlRc=
SHA256-Digest: EdsShQWkFxTvHJ5HdshXS3G6RpW0x43Zp9SaJm2DMYo=
Name: icons/copy.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: OXhh/FV76zBJ7bPARhO1kXbJ8mc=
SHA256-Digest: I5ZkF6AVqZ6CBh9tDNQXVLWKDiORSsz14S1ozDCsY10=
Name: icons/bar-chart-2.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ymsSQfHsB2Yhrc/9oYGvTC4QCYg=
SHA256-Digest: wbOZE5ZTvjXZ33eI8MpdL7GuVwG5NPzh0sIWuyVSj/E=
Name: icons/twitter.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: sdv/f+LCM/KDPUdStFcbQs2S1Qs=
SHA256-Digest: LfCEPjbtgSnkkXQ/424WfEqTGU485RFa7QndyYK4XlQ=
Name: icons/factuality-graph.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Jw9QdSBN9QQAhNutol7WcXsfSNs=
SHA256-Digest: UIIYoCko4OPvm8EkoooE24pG6YNN+qYdtCP+Mdp4xEY=
Name: icons/bookmark-1-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Gri9o5cr44Z8yMLvXMruu6lZbFM=
SHA256-Digest: SBcRZjzR9t2TMdk0k7fkjGn1cqmnntmltV/2Siulhdw=
Name: icons/question-solid.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ztagl+8AjUba4r4vcu8MPPB99Os=
SHA256-Digest: A+asnnSbjJRZb0szivuTdmsjq4DMHBnNTIkezTR8oPk=
Name: icons/close.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: qBwoyv33PlWAH497qkZ9EZ4f7fQ=
SHA256-Digest: xJdqjC9p05q/gLVPX5vsn53mJGTcCrajg8CPIZizJc4=
Name: icons/question-solid-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: YcWQZb3R3LPG5WfArPacQzhy0pM=
SHA256-Digest: q5y7JCL0wywNCGPR76k/EPqdRLlLIohdIXDEIZKh2+o=
Name: icons/facebook.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 7AaS3nkHGIhqC3LRrIAEFIucH/s=
SHA256-Digest: +PGwndbOb6KBtu5rWKrifGt4vgcmi4TuwHZqEKTu7jM=
Name: icons/circle-dashed-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: p4Ah/FbsnwfeKDMa+w+lEdnMlIc=
SHA256-Digest: 26vt1XYxiy1OBY7sh3EGvAwsy3a85ZM7pCK5g75mY3M=
Name: icons/book-open-1-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: UI1c5voVCUTx4Az1THzMZIyxJbA=
SHA256-Digest: rM+8F1keTjIr9EVBwHNdMjdZGL0X9zuu31YN1m/RY70=
Name: icons/bookmark-all-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: xJqkz+nuT8QIJI5Davu4lOfk2So=
SHA256-Digest: U2HWym+rA41IBX+vP2doYA2j57iNjRH9sW0LaT2noL0=
Name: icons/linkedin.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: RWz56AE4nLkOHRP3Yn4FNNOs6To=
SHA256-Digest: Y+y19EnAQP4YOb+I/ElIJzlpTrwC7+dnoqxjgB5iFks=
Name: icons/triangle-exclamation-solid.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ArXBoIpovMCiYpnmC1D04se37fo=
SHA256-Digest: qKKteohNK7X+43+NvMPNHSgnoUH3PL/MN2Alfv40u7c=
Name: icons/reddit.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: mjVp6/sQZ4APXhqDy6fBA8e7Z2g=
SHA256-Digest: CFH+sfL4ZCfy9bFGUuV2dGbN2yZQ1zegphlrEh9OSR4=
Name: icons/access-block.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: llyHO/XPtj9GiKooWGUICDgcmkY=
SHA256-Digest: rYFcWtObJ6u7OaLbvemwaO37VNihaZjugHs2zFFJUBQ=
Name: icons/lock.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Np+svInXuBwn2mqXKUQaJK3+6Sk=
SHA256-Digest: tHS1JJz2NJwYgcpM6VUA7Q9XhskazpUTAi3CzeICZK8=

Binary file not shown.

View File

@@ -0,0 +1,287 @@
Manifest-Version: 1.0
Name: manifest.json
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: V2IFvyo6JSf58YUHJqeIHIVd8E0=
SHA256-Digest: M69JukMYgZUNWY5jUT7q2g/FMX2PXGWy9PwNjwI0vug=
Name: ground-symbol.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 5uwl9dTCzc0AQhNv/F3NJ9ut+9o=
SHA256-Digest: cZMyBTOYwSPONcdGNpzsYniDOrCRIZYzmj2lHi6GuD0=
Name: popup.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: zd6EwEaQEtVLluTkn9Qa1cmSlBM=
SHA256-Digest: Cl3i0OIY42Ua17VkMTW5fV5ibdGpMC0XZdTOa46FQTc=
Name: ground-symbol.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: kJidXptlh8QBtcvPwMXpE7/cmcg=
SHA256-Digest: ey8u+oxBDz1R4Q3ufxVSro2lR/OEJveygFDU+iGWYF8=
Name: 48.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 7TqQN9wX+SMdWlfHtDc8ERPuqEQ=
SHA256-Digest: giJa2wAqDjcuFUhFHWVEGzt8MBA5lgnTPCTGdCS9Vn0=
Name: menu.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 1ey0NcYZx5NDTSD76kqaCyW8f/A=
SHA256-Digest: Z7g61Y7ThdKbvxy3ynCZVDBM4LBnsyL7I0gya/dVXHw=
Name: 16.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 28sU5Whyop4p03vu+iOpWePL8Kc=
SHA256-Digest: 2AbeMUuiz3Oel0suwHBBLXUw5lOgSgcHRlVnxRoB08k=
Name: frame.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: qQtfJt0TPw66a+OZUy/f9TcKMkg=
SHA256-Digest: YAl5l7WMe0/XJuynQ4TXKF+/V42PwZG73R/s8/vX/18=
Name: 128.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: tZEoUW0ARt4P+NYAyqj0WEq71sQ=
SHA256-Digest: pqWObGJS7LTHpHYP1cdonw3V+V8FQp8BpReuEVEQkh4=
Name: 32.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: QaTkY7NOk/8VVnaX0an28ddSDcs=
SHA256-Digest: jRtP4s1aGgeYFbSUJJ5WhS3+EWixi9CI3GP1iLiCqM0=
Name: options.html
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: akU7E1yGbFYopo5ni9ddunplHqE=
SHA256-Digest: w49o/x8U5+6stagI/4bUiUFeWQ7kDIZnDnrtTBG/ocE=
Name: js/facebook.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: cj7aAD1Z5g0H0Urujpec9+aBqt0=
SHA256-Digest: gnS69LtD/hZle1w6Mkc+gvCfQ7CiydY1pXbyoHywVT0=
Name: js/popup.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: AwazIGkUjD2NGwgDmaHhOTngBW4=
SHA256-Digest: t7v8gi72iMcKYn4FD+Wil3Aa6HSTASE7RB4/k74R0Ls=
Name: js/background.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: /TYelJW8I50597wbu69V9S45zLM=
SHA256-Digest: 0z3cbY19uJhPqmCJgh1jji6VLOg70wZVmgw9HnsnDis=
Name: js/bluesky.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: MNCsuEQO93hn6xAjNksh/3678wY=
SHA256-Digest: gRf74GPcfIql/AriGau6sFJJWi/cBfTGWoQ5euJ43pU=
Name: js/ground.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Z61BeUMG3Zx3wbpDXvmhd0Cw0fA=
SHA256-Digest: USQGUOKIO2vkzT4m1lkwvHotj22y1vZi1Mz6/+EaWN0=
Name: js/index.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 2jmj7l5rSw0yVb/vlWAYkK/YBwk=
SHA256-Digest: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
Name: js/reddit.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: saXwwYIfh/QNKgEN0MDsI1YlvVA=
SHA256-Digest: BvhbExjoLWewpuaKseghfprRLpcUXWrD6YNNzFDS/a4=
Name: js/options.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 2jmj7l5rSw0yVb/vlWAYkK/YBwk=
SHA256-Digest: 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
Name: js/content_script.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 9R3v5pGCWxLFQn+25JVUN2fv5/s=
SHA256-Digest: oGvKwnOBHK95FBzYz+wE57a3T8hsERlX48rv4nSDW8Q=
Name: js/bluesky.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/content_script.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/linkedin.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: rujsrH7/oW7qcOTozsJFrdw3nkw=
SHA256-Digest: cStig9r2199Q+qT/pnz/0XWmWIMMJ5p/XajjdEjt7Zg=
Name: js/menu.js.LICENSE.txt
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: pZUsR2vKEMwO/5CvnF81Xe0Y0nc=
SHA256-Digest: d9DmR6YgLL2mplsHeBjLZlK49guK2hllnzO1Gro2mjA=
Name: js/reddit.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/linkedin.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/twitter.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: iGu05BP2isx45kVOVcb/O68TWWc=
SHA256-Digest: +9FaLzeoca1V/mGIKuxZz2uPzm/36aVki/vX3/JSXCo=
Name: js/menu.js
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: FeWa454d9X6GXyH7HkFSwTrTFnQ=
SHA256-Digest: TH42cXnLQY3wjmXLd0MCK+IXHLGOuDBHKnPuF8QR4ts=
Name: js/twitter.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: js/facebook.css
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 0EXvmh97NhU9/2mZOAsjDNTnkCQ=
SHA256-Digest: ltCM6CqPE2jp8ayZEwoXWseUp6KfqtXOcIEJ9428nAI=
Name: fonts/UniversalSans-800.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: R01ScgAtNGc7qQFIUyrZGcCHE4s=
SHA256-Digest: s/7cq8YWjWQRWg4xJ0JZFBw17O5NxNZXG7O5/F5lggE=
Name: fonts/UniversalSans-680.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Lwp0bnjR0mnTLP0g/j4H1iMpIBk=
SHA256-Digest: ynJEQsy28v6X9Y/gqOs0lQ9hFK7AkwuMUMvXN0JA8Do=
Name: fonts/UniversalSans-800.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: z8F3l76le5ETg3V6kpTcmcvKLg4=
SHA256-Digest: eCmjTqBZPuAgdgXawQu5+l6GbGVqCsd5x8RbhAVdabM=
Name: fonts/UniversalSans-480.woff
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: hOizxWx9ZO5V4hb+ur3xcOCh7ls=
SHA256-Digest: ALRea9OYPxfKRpuUwYuikx4D2NPuYQxxGmtDKvtBH7A=
Name: fonts/UniversalSans-680.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Z3iFwBl47GvgfYXY3CCtip8EoMA=
SHA256-Digest: CX6/qn4t6JdXyMGjleJB19YwZgw0hujgG7CgRhT2a4Q=
Name: fonts/UniversalSans-480.ttf
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: QHIRDYioCWTm+u0Y4XHmy2pUdEE=
SHA256-Digest: 0+IQVOpNNsM1YiCLxuLsZKEHsQIuAemWVwgu1vnFXLY=
Name: icons/info.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: xSN0y22E9y+SnvukSpK5lVPmkgI=
SHA256-Digest: uzYDK5vpqHyFwtDSDbBu7XcOXxPiR2yYchY85rtR8LY=
Name: icons/bookmark-1-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: lpcL3lTckaLVyyWFmNyvtncPlRc=
SHA256-Digest: EdsShQWkFxTvHJ5HdshXS3G6RpW0x43Zp9SaJm2DMYo=
Name: icons/copy.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: OXhh/FV76zBJ7bPARhO1kXbJ8mc=
SHA256-Digest: I5ZkF6AVqZ6CBh9tDNQXVLWKDiORSsz14S1ozDCsY10=
Name: icons/bar-chart-2.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ymsSQfHsB2Yhrc/9oYGvTC4QCYg=
SHA256-Digest: wbOZE5ZTvjXZ33eI8MpdL7GuVwG5NPzh0sIWuyVSj/E=
Name: icons/twitter.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: sdv/f+LCM/KDPUdStFcbQs2S1Qs=
SHA256-Digest: LfCEPjbtgSnkkXQ/424WfEqTGU485RFa7QndyYK4XlQ=
Name: icons/factuality-graph.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Jw9QdSBN9QQAhNutol7WcXsfSNs=
SHA256-Digest: UIIYoCko4OPvm8EkoooE24pG6YNN+qYdtCP+Mdp4xEY=
Name: icons/bookmark-1-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Gri9o5cr44Z8yMLvXMruu6lZbFM=
SHA256-Digest: SBcRZjzR9t2TMdk0k7fkjGn1cqmnntmltV/2Siulhdw=
Name: icons/question-solid.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ztagl+8AjUba4r4vcu8MPPB99Os=
SHA256-Digest: A+asnnSbjJRZb0szivuTdmsjq4DMHBnNTIkezTR8oPk=
Name: icons/close.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: qBwoyv33PlWAH497qkZ9EZ4f7fQ=
SHA256-Digest: xJdqjC9p05q/gLVPX5vsn53mJGTcCrajg8CPIZizJc4=
Name: icons/question-solid-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: YcWQZb3R3LPG5WfArPacQzhy0pM=
SHA256-Digest: q5y7JCL0wywNCGPR76k/EPqdRLlLIohdIXDEIZKh2+o=
Name: icons/facebook.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 7AaS3nkHGIhqC3LRrIAEFIucH/s=
SHA256-Digest: +PGwndbOb6KBtu5rWKrifGt4vgcmi4TuwHZqEKTu7jM=
Name: icons/circle-dashed-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: p4Ah/FbsnwfeKDMa+w+lEdnMlIc=
SHA256-Digest: 26vt1XYxiy1OBY7sh3EGvAwsy3a85ZM7pCK5g75mY3M=
Name: icons/book-open-1-black.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: UI1c5voVCUTx4Az1THzMZIyxJbA=
SHA256-Digest: rM+8F1keTjIr9EVBwHNdMjdZGL0X9zuu31YN1m/RY70=
Name: icons/bookmark-all-white.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: xJqkz+nuT8QIJI5Davu4lOfk2So=
SHA256-Digest: U2HWym+rA41IBX+vP2doYA2j57iNjRH9sW0LaT2noL0=
Name: icons/linkedin.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: RWz56AE4nLkOHRP3Yn4FNNOs6To=
SHA256-Digest: Y+y19EnAQP4YOb+I/ElIJzlpTrwC7+dnoqxjgB5iFks=
Name: icons/triangle-exclamation-solid.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: ArXBoIpovMCiYpnmC1D04se37fo=
SHA256-Digest: qKKteohNK7X+43+NvMPNHSgnoUH3PL/MN2Alfv40u7c=
Name: icons/reddit.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: mjVp6/sQZ4APXhqDy6fBA8e7Z2g=
SHA256-Digest: CFH+sfL4ZCfy9bFGUuV2dGbN2yZQ1zegphlrEh9OSR4=
Name: icons/access-block.png
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: llyHO/XPtj9GiKooWGUICDgcmkY=
SHA256-Digest: rYFcWtObJ6u7OaLbvemwaO37VNihaZjugHs2zFFJUBQ=
Name: icons/lock.svg
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: Np+svInXuBwn2mqXKUQaJK3+6Sk=
SHA256-Digest: tHS1JJz2NJwYgcpM6VUA7Q9XhskazpUTAi3CzeICZK8=
Name: META-INF/cose.manifest
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: 9HFTS/zlmlOHYcXdrE2m6pb876E=
SHA256-Digest: xe5g1rLlpVs7kCmv4iRPiKvxuwSbtWWFBnG66CwPtiE=
Name: META-INF/cose.sig
Digest-Algorithms: SHA1 SHA256
SHA1-Digest: bRbKU9HRi1Cv/+d42kRoiVlgNa8=
SHA256-Digest: Am6hYP/HPPR/sGPHTwynjPLnWqk9RC9Dn7wMM9SMn/k=

Binary file not shown.

View File

@@ -0,0 +1,4 @@
Signature-Version: 1.0
SHA1-Digest-Manifest: aja76xPF0ppgA5xWeuljtiTIUOY=
SHA256-Digest-Manifest: fiRUfRWTKMY9g1E304egtSv6nkOpo7iqARA8//5xfeM=

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,18 @@
<style>
html,
body,
iframe {
margin: 0;
border: 0;
padding: 0;
display: block;
width: 100vw;
height: 42px;
background: black;
color: black;
overflow: hidden;
}
</style>
<body>
<script src="js/frame.js"></script>
</body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -0,0 +1,17 @@
<svg width="176" height="34" viewBox="0 0 176 34" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="15.9003" y="14.1624" width="4.12357" height="4.12166" fill="url(#pattern0)"/>
<path d="M17.008 33.0727C14.8368 33.0727 12.7309 32.6479 10.749 31.8099C8.8342 31.0004 7.1145 29.8413 5.63739 28.3649C4.16027 26.8885 3.00064 25.1696 2.19079 23.2556C1.35276 21.275 0.927734 19.1701 0.927734 17C0.927734 14.8298 1.35276 12.7249 2.19113 10.744C3.00099 8.83001 4.16062 7.11112 5.63773 5.6347C7.11485 4.15828 8.83454 2.99919 10.7494 2.18971C12.7309 1.35207 14.8368 0.927246 17.008 0.927246C19.1791 0.927246 21.285 1.35207 23.2669 2.19005C25.1817 2.99953 26.9014 4.15862 28.3786 5.63504C29.8557 7.11147 31.0153 8.83036 31.8252 10.7443C32.6632 12.7249 33.0882 14.8298 33.0882 17C33.0882 19.1701 32.6632 21.275 31.8248 23.256C31.015 25.1699 29.8553 26.8888 28.3782 28.3652C26.9011 29.8417 25.1814 31.0008 23.2666 31.8102C21.285 32.6479 19.1791 33.0727 17.008 33.0727Z" fill="#07090C"/>
<path d="M17.0079 1.85455C19.0544 1.85455 21.0386 2.25465 22.9054 3.04386C24.7096 3.80663 26.3303 4.89875 27.7222 6.29034C29.1145 7.68194 30.2071 9.30192 30.9702 11.1049C31.7602 12.9712 32.1605 14.9545 32.1605 17C32.1605 19.0455 31.7602 21.0288 30.9706 22.8947C30.2075 24.6981 29.1148 26.3181 27.7226 27.7093C26.3303 29.1009 24.7096 30.193 22.9057 30.9558C21.0386 31.7454 19.0544 32.1455 17.0079 32.1455C14.9615 32.1455 12.9772 31.7454 11.1105 30.9561C9.30626 30.1934 7.68552 29.1013 6.29362 27.7097C4.90138 26.3181 3.80875 24.6981 3.04562 22.8951C2.2557 21.0288 1.85541 19.0455 1.85541 17C1.85541 14.9545 2.2557 12.9712 3.04528 11.1053C3.8084 9.30192 4.90103 7.68194 6.29328 6.29069C7.68552 4.89909 9.30626 3.80697 11.1101 3.0442C12.9772 2.25465 14.9615 1.85455 17.0079 1.85455ZM17.0079 0C7.61474 0 0 7.61119 0 17C0 26.3888 7.61474 34 17.0079 34C26.4011 34 34.0159 26.3888 34.0159 17C34.0159 7.61119 26.4011 0 17.0079 0Z" fill="#FFD902"/>
<path d="M8.90283 13.3757V13.0924H16.8749V1.62921H17.1409V13.0924H25.113V13.3757H8.90283Z" fill="#07090C"/>
<path d="M18.0686 0.701965H15.9472V12.1651H7.9751V14.303H26.0407V12.1651H18.0686V0.701965Z" fill="#FFD902"/>
<path d="M22.9365 17.9922H11.0787V18.0458H22.9365V17.9922Z" fill="#07090C"/>
<path d="M23.8645 17.0649H10.151V18.9727H23.8645V17.0649Z" fill="#FFD902"/>
<path d="M21.887 21.9341H12.1289V23.6588H21.887V21.9341Z" fill="#07090C"/>
<path d="M21.887 21.9341H12.1289V23.6588H21.887V21.9341Z" fill="#FFD902"/>
<defs>
<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1">
<use xlink:href="#image0" transform="scale(0.000488281)"/>
</pattern>
<image id="image0" width="2048" height="2048" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACAAAAAgACAYAAACyp9MwAAEgi0lEQVR4AezdhY4e6bHH4TLtjHfH"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 20L18 4" stroke="#262626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 20V4" stroke="#262626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 20L6 4" stroke="#262626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 3H8C9.06087 3 10.0783 3.42143 10.8284 4.17157C11.5786 4.92172 12 5.93913 12 7V21C12 20.2044 11.6839 19.4413 11.1213 18.8787C10.5587 18.3161 9.79565 18 9 18H2V3Z" stroke="#262626" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 3H16C14.9391 3 13.9217 3.42143 13.1716 4.17157C12.4214 4.92172 12 5.93913 12 7V21C12 20.2044 12.3161 19.4413 12.8787 18.8787C13.4413 18.3161 14.2044 18 15 18H22V3Z" stroke="#262626" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 21L12 16L5 21V5C5 4.46957 5.21071 3.96086 5.58579 3.58579C5.96086 3.21071 6.46957 3 7 3H17C17.5304 3 18.0391 3.21071 18.4142 3.58579C18.7893 3.96086 19 4.46957 19 5V21Z" stroke="#262626" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,3 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6663 13L5.99967 9.66667L1.33301 13V2.33333C1.33301 1.97971 1.47348 1.64057 1.72353 1.39052C1.97358 1.14048 2.31272 1 2.66634 1H9.33301C9.68663 1 10.0258 1.14048 10.2758 1.39052C10.5259 1.64057 10.6663 1.97971 10.6663 2.33333V13Z" stroke="#EEEFE9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" fill="none">
<path d="M10.6663 13L5.99967 9.66667L1.33301 13V2.33333C1.33301 1.97971 1.47348 1.64057 1.72353 1.39052C1.97358 1.14048 2.31272 1 2.66634 1H9.33301C9.68663 1 10.0258 1.14048 10.2758 1.39052C10.5259 1.64057 10.6663 1.97971 10.6663 2.33333V13Z" stroke="#EEEFE9" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.8447 13.7555C21.4286 16.0892 20.1969 18.1993 18.3693 19.7092C16.5418 21.2191 14.2373 22.0307 11.8669 21.9991C9.49657 21.9676 7.21449 21.095 5.42778 19.537C3.64108 17.979 2.46597 15.8369 2.11206 13.4929L3.89855 13.2232C4.18852 15.1437 5.15132 16.8987 6.61521 18.1752C8.0791 19.4517 9.94887 20.1667 11.891 20.1925C13.8331 20.2184 15.7212 19.5534 17.2186 18.3163C18.7159 17.0792 19.7251 15.3504 20.066 13.4383L21.8447 13.7555Z" fill="#262626"/>
<path d="M9.59587 2.29329C7.43388 2.82877 5.51254 4.0707 4.13653 5.82215C2.76053 7.57359 2.00863 9.73428 2.00007 11.9616L3.84977 11.9687C3.85674 10.1534 4.46956 8.39235 5.59104 6.96487C6.71253 5.53739 8.27848 4.52518 10.0406 4.08875L9.59587 2.29329Z" fill="#262626"/>
<path d="M22 12C22 10.6057 21.7084 9.22686 21.144 7.95193C20.5796 6.677 19.7548 5.53419 18.7226 4.59687C17.6904 3.65955 16.4736 2.94844 15.1503 2.5092C13.8271 2.06995 12.4265 1.91228 11.0387 2.04631L11.2104 3.8238C12.3504 3.71371 13.5008 3.84322 14.5878 4.20403C15.6747 4.56483 16.6742 5.14896 17.5221 5.91889C18.37 6.68883 19.0475 7.62756 19.5111 8.67482C19.9747 9.72207 20.2142 10.8547 20.2142 12H22Z" fill="#262626"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.625 4.375L4.375 15.625" stroke="#262626" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M15.625 15.625L4.375 4.375" stroke="#262626" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.875 14.375V3.125H5.625" stroke="#262626" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.375 5.625H3.125V16.875H14.375V5.625Z" stroke="#262626" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm3 8h-1.35c-.538 0-.65.221-.65.778v1.222h2l-.209 2h-1.791v7h-3v-7h-2v-2h2v-2.308c0-1.769.931-2.692 3.029-2.692h1.971v3z" fill="#a6a6a1"/></svg>

After

Width:  |  Height:  |  Size: 310 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="#262626" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 7.5H8V11" stroke="#262626" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M7.91667 5.83333C8.28486 5.83333 8.58333 5.53486 8.58333 5.16667C8.58333 4.79848 8.28486 4.5 7.91667 4.5C7.54848 4.5 7.25 4.79848 7.25 5.16667C7.25 5.53486 7.54848 5.83333 7.91667 5.83333Z" fill="#262626"/>
</svg>

After

Width:  |  Height:  |  Size: 595 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-2 16h-2v-6h2v6zm-1-6.891c-.607 0-1.1-.496-1.1-1.109 0-.612.492-1.109 1.1-1.109s1.1.497 1.1 1.109c0 .613-.493 1.109-1.1 1.109zm8 6.891h-1.998v-2.861c0-1.881-2.002-1.722-2.002 0v2.861h-2v-6h2v1.093c.872-1.616 4-1.736 4 1.548v3.359z" fill="#a6a6a1"/></svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@@ -0,0 +1,5 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="16.5" width="36" height="25.5" stroke="#262626" stroke-width="3"/>
<path d="M17.2489 16.5V9.75C17.2489 7.95979 17.9601 6.2429 19.2259 4.97703C20.4918 3.71116 22.2087 3 23.9989 3C25.7891 3 27.506 3.71116 28.7719 4.97703C30.0377 6.2429 30.7489 7.95979 30.7489 9.75V16.5" stroke="#262626" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="24" cy="29.25" r="3" fill="#262626"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M96 96c-17.7 0-32 14.3-32 32s-14.3 32-32 32s-32-14.3-32-32C0 75 43 32 96 32h97c70.1 0 127 56.9 127 127c0 52.4-32.2 99.4-81 118.4l-63 24.5 0 18.1c0 17.7-14.3 32-32 32s-32-14.3-32-32V301.9c0-26.4 16.2-50.1 40.8-59.6l63-24.5C240 208.3 256 185 256 159c0-34.8-28.2-63-63-63H96zm48 384c-22.1 0-40-17.9-40-40s17.9-40 40-40s40 17.9 40 40s-17.9 40-40 40z" fill="#eeefe9"/></svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M96 96c-17.7 0-32 14.3-32 32s-14.3 32-32 32s-32-14.3-32-32C0 75 43 32 96 32h97c70.1 0 127 56.9 127 127c0 52.4-32.2 99.4-81 118.4l-63 24.5 0 18.1c0 17.7-14.3 32-32 32s-32-14.3-32-32V301.9c0-26.4 16.2-50.1 40.8-59.6l63-24.5C240 208.3 256 185 256 159c0-34.8-28.2-63-63-63H96zm48 384c-22.1 0-40-17.9-40-40s17.9-40 40-40s40 17.9 40 40s-17.9 40-40 40z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M14.238 15.348c.085.084.085.221 0 .306-.465.462-1.194.687-2.231.687l-.008-.002-.008.002c-1.036 0-1.766-.225-2.231-.688-.085-.084-.085-.221 0-.305.084-.084.222-.084.307 0 .379.377 1.008.561 1.924.561l.008.002.008-.002c.915 0 1.544-.184 1.924-.561.085-.084.223-.084.307 0zm-3.44-2.418c0-.507-.414-.919-.922-.919-.509 0-.923.412-.923.919 0 .506.414.918.923.918.508.001.922-.411.922-.918zm13.202-.93c0 6.627-5.373 12-12 12s-12-5.373-12-12 5.373-12 12-12 12 5.373 12 12zm-5-.129c0-.851-.695-1.543-1.55-1.543-.417 0-.795.167-1.074.435-1.056-.695-2.485-1.137-4.066-1.194l.865-2.724 2.343.549-.003.034c0 .696.569 1.262 1.268 1.262.699 0 1.267-.566 1.267-1.262s-.568-1.262-1.267-1.262c-.537 0-.994.335-1.179.804l-2.525-.592c-.11-.027-.223.037-.257.145l-.965 3.038c-1.656.02-3.155.466-4.258 1.181-.277-.255-.644-.415-1.05-.415-.854.001-1.549.693-1.549 1.544 0 .566.311 1.056.768 1.325-.03.164-.05.331-.05.5 0 2.281 2.805 4.137 6.253 4.137s6.253-1.856 6.253-4.137c0-.16-.017-.317-.044-.472.486-.261.82-.766.82-1.353zm-4.872.141c-.509 0-.922.412-.922.919 0 .506.414.918.922.918s.922-.412.922-.918c0-.507-.413-.919-.922-.919z" fill="#a6a6a1"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224c0-17.7-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32s32-14.3 32-32z"/></svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm6.066 9.645c.183 4.04-2.83 8.544-8.164 8.544-1.622 0-3.131-.476-4.402-1.291 1.524.18 3.045-.244 4.252-1.189-1.256-.023-2.317-.854-2.684-1.995.451.086.895.061 1.298-.049-1.381-.278-2.335-1.522-2.304-2.853.388.215.83.344 1.301.359-1.279-.855-1.641-2.544-.889-3.835 1.416 1.738 3.533 2.881 5.92 3.001-.419-1.796.944-3.527 2.799-3.527.825 0 1.572.349 2.096.907.654-.128 1.27-.368 1.824-.697-.215.671-.67 1.233-1.263 1.589.581-.07 1.135-.224 1.649-.453-.384.578-.87 1.084-1.433 1.489z" fill="#a6a6a1"/></svg>

After

Width:  |  Height:  |  Size: 671 B

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
/*!
* Sizzle CSS Selector Engine v2.3.6
* https://sizzlejs.com/
*
* Copyright JS Foundation and other contributors
* Released under the MIT license
* https://js.foundation/
*
* Date: 2021-02-16
*/
/*!
* jQuery JavaScript Library v3.6.1
* https://jquery.com/
*
* Includes Sizzle.js
* https://sizzlejs.com/
*
* Copyright OpenJS Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2022-08-26T17:52Z
*/
//! moment.js
//! moment.js locale configuration

View File

View File

@@ -0,0 +1 @@
(()=>{"use strict";if(window.location.hash){const e=document.createElement("iframe"),t=window.location.hash.slice(1).split("$");e.src=`https://ground.news/extension/2/${t[0]}?sourceId=${t[1]}&test=1&utm_medium=extension&utm_source=${t[2]}&utm_campaign=${t[3]}`,e.style.width="420px",e.style.height="300px",e.style.border="0px",e.style.overflow="hidden",window.onload=()=>{document.body.appendChild(e)}}})();

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,405 @@
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-800.ttf') format('ttf');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-680.ttf') format('ttf');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.woff') format('woff'),
url('moz-extension://__MSG_@@extension_id__/fonts/UniversalSans-480.ttf') format('ttf');
font-weight: 480;
}
.tw-static {
position: static !important;
}
.tw-fixed {
position: fixed !important;
}
.tw-absolute {
position: absolute !important;
}
.tw-relative {
position: relative !important;
}
.tw-left-0 {
left: 0px !important;
}
.tw-z-\[1000000000\] {
z-index: 1000000000 !important;
}
.tw-z-\[10000\] {
z-index: 10000 !important;
}
.tw-float-right {
float: right !important;
}
.tw-my-\[13px\] {
margin-top: 13px !important;
margin-bottom: 13px !important;
}
.tw-mb-0 {
margin-bottom: 0px !important;
}
.tw-mb-\[20px\] {
margin-bottom: 20px !important;
}
.tw-box-border {
box-sizing: border-box !important;
}
.tw-inline-block {
display: inline-block !important;
}
.tw-flex {
display: flex !important;
}
.tw-inline-flex {
display: inline-flex !important;
}
.tw-hidden {
display: none !important;
}
.tw-h-\[1px\] {
height: 1px !important;
}
.tw-h-\[20px\] {
height: 20px !important;
}
.tw-h-\[30px\] {
height: 30px !important;
}
.tw-h-\[35px\] {
height: 35px !important;
}
.tw-h-\[54px\] {
height: 54px !important;
}
.tw-h-full {
height: 100% !important;
}
.tw-min-h-\[30px\] {
min-height: 30px !important;
}
.tw-w-\[200px\] {
width: 200px !important;
}
.tw-w-\[20px\] {
width: 20px !important;
}
.tw-w-\[30px\] {
width: 30px !important;
}
.tw-w-\[35px\] {
width: 35px !important;
}
.tw-w-\[420px\] {
width: 420px !important;
}
.tw-w-\[450px\] {
width: 450px !important;
}
.tw-w-full {
width: 100% !important;
}
.\!tw-max-w-none {
max-width: none !important;
}
.tw-flex-initial {
flex: 0 1 auto !important;
}
.tw-flex-none {
flex: none !important;
}
.tw-flex-shrink-0 {
flex-shrink: 0 !important;
}
.tw-flex-grow-0 {
flex-grow: 0 !important;
}
.tw-cursor-pointer {
cursor: pointer !important;
}
.tw-items-center {
align-items: center !important;
}
.tw-justify-end {
justify-content: flex-end !important;
}
.tw-justify-center {
justify-content: center !important;
}
.tw-justify-between {
justify-content: space-between !important;
}
.tw-gap-x-3 {
-moz-column-gap: 0.75rem !important;
column-gap: 0.75rem !important;
}
.tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
.tw-gap-x-\[5px\] {
-moz-column-gap: 5px !important;
column-gap: 5px !important;
}
.tw-overflow-hidden {
overflow: hidden !important;
}
.tw-truncate {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.tw-whitespace-nowrap {
white-space: nowrap !important;
}
.tw-rounded {
border-radius: 0.25rem !important;
}
.tw-rounded-full {
border-radius: 9999px !important;
}
.tw-rounded-md {
border-radius: 0.375rem !important;
}
.\!tw-border {
border-width: 1px !important;
}
.tw-border {
border-width: 1px !important;
}
.tw-border-0 {
border-width: 0px !important;
}
.tw-border-solid {
border-style: solid !important;
}
.\!tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.tw-border-light {
--tw-border-opacity: 1 !important;
border-color: rgb(238 239 233 / var(--tw-border-opacity)) !important;
}
.\!tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarCenter {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(32 73 134 / var(--tw-bg-opacity)) !important;
}
.tw-bg-biasBarRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(128 39 39 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
--tw-bg-opacity: 1 !important;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)) !important;
}
.tw-bg-dark {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(77 109 158 / var(--tw-bg-opacity)) !important;
}
.tw-bg-farRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(153 82 82 / var(--tw-bg-opacity)) !important;
}
.tw-bg-focus {
--tw-bg-opacity: 1 !important;
background-color: rgb(209 189 145 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanLeft {
--tw-bg-opacity: 1 !important;
background-color: rgb(210 219 231 / var(--tw-bg-opacity)) !important;
}
.tw-bg-leanRight {
--tw-bg-opacity: 1 !important;
background-color: rgb(217 190 190 / var(--tw-bg-opacity)) !important;
}
.tw-bg-left {
--tw-bg-opacity: 1 !important;
background-color: rgb(144 164 195 / var(--tw-bg-opacity)) !important;
}
.tw-bg-light {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.tw-bg-right {
--tw-bg-opacity: 1 !important;
background-color: rgb(192 147 147 / var(--tw-bg-opacity)) !important;
}
.tw-bg-center {
background-position: center !important;
}
.tw-bg-left {
background-position: left !important;
}
.tw-bg-right {
background-position: right !important;
}
.tw-fill-dark {
fill: #262626 !important;
}
.tw-fill-light {
fill: #EEEFE9 !important;
}
.tw-p-\[20px\] {
padding: 20px !important;
}
.tw-px-2 {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
.tw-px-\[5px\] {
padding-left: 5px !important;
padding-right: 5px !important;
}
.tw-text-center {
text-align: center !important;
}
.tw-align-middle {
vertical-align: middle !important;
}
.tw-font-ground {
font-family: UniversalSans !important;
}
.tw-text-\[10px\] {
font-size: 10px !important;
}
.tw-text-\[12px\] {
font-size: 12px !important;
}
.tw-text-\[15px\] {
font-size: 15px !important;
}
.tw-text-\[18px\] {
font-size: 18px !important;
}
.tw-text-\[20px\] {
font-size: 20px !important;
}
.tw-text-\[30px\] {
font-size: 30px !important;
}
.tw-font-\[480\] {
font-weight: 480 !important;
}
.tw-font-\[600\] {
font-weight: 600 !important;
}
.tw-leading-\[20px\] {
line-height: 20px !important;
}
.tw-leading-\[25px\] {
line-height: 25px !important;
}
.tw-leading-\[30px\] {
line-height: 30px !important;
}
.tw-leading-\[54px\] {
line-height: 54px !important;
}
.tw-text-center {
--tw-text-opacity: 1 !important;
color: rgb(255 255 255 / var(--tw-text-opacity)) !important;
}
.tw-text-dark {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.tw-text-light {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
.tw-opacity-0 {
opacity: 0 !important;
}
.tw-opacity-100 {
opacity: 1 !important;
}
.tw-transition-\[width\,background-color\,color\] {
transition-property: width,background-color,color !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-all {
transition-property: all !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-transition-opacity {
transition-property: opacity !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
transition-duration: 150ms !important;
}
.tw-duration-300 {
transition-duration: 300ms !important;
}
.tw-duration-500 {
transition-duration: 500ms !important;
}
.tw-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.\[top\:-108px\] {
top: -108px !important;
}
.\[top\:0px\] {
top: 0px !important;
}
.\[transition\:top_500ms_cubic-bezier\(0\2c 0\2c 0\2c 1\)\] {
transition: top 500ms cubic-bezier(0,0,0,1) !important;
}
.hover\:tw-bg-dark:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(38 38 38 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-bg-light:hover {
--tw-bg-opacity: 1 !important;
background-color: rgb(238 239 233 / var(--tw-bg-opacity)) !important;
}
.hover\:tw-text-dark:hover {
--tw-text-opacity: 1 !important;
color: rgb(38 38 38 / var(--tw-text-opacity)) !important;
}
.hover\:tw-text-light:hover {
--tw-text-opacity: 1 !important;
color: rgb(238 239 233 / var(--tw-text-opacity)) !important;
}
@media (max-width: 1340px) {
.max-\[1340px\]\:tw-hidden {
display: none !important;
}
}
@media (min-width: 1280px) {
.xl\:tw-inline-block {
display: inline-block !important;
}
.xl\:tw-gap-x-\[10px\] {
-moz-column-gap: 10px !important;
column-gap: 10px !important;
}
}
@media (min-width: 1341px) {
.min-\[1341px\]\:tw-hidden {
display: none !important;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,153 @@
{
"name": "Ground News - Bias Checker",
"version": "3.2.1",
"description": "Explore the web and lookup articles you find on Ground News",
"icons": {
"16": "16.png",
"32": "32.png",
"48": "48.png",
"128": "128.png"
},
"permissions": [
"tabs",
"storage"
],
"host_permissions": [
"https://extension.ground.news/*",
"https://production.checkitt.news/api/*"
],
"background": {
"scripts": [
"js/background.js"
]
},
"browser_specific_settings": {
"gecko": {
"id": "{248e6a49-f636-4c81-9899-a456eb6291a8}"
}
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"exclude_matches": [
"http://*.ground.news/*",
"https://*.ground.news/*"
],
"js": [
"js/content_script.js"
],
"css": [
"js/content_script.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://*.reddit.com/*"
],
"js": [
"js/reddit.js"
],
"css": [
"js/reddit.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://*.x.com/*"
],
"js": [
"js/twitter.js"
],
"css": [
"js/twitter.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://*.facebook.com/*"
],
"js": [
"js/facebook.js"
],
"css": [
"js/facebook.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://*.linkedin.com/*"
],
"js": [
"js/linkedin.js"
],
"css": [
"js/linkedin.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://bsky.app/*"
],
"js": [
"js/bluesky.js"
],
"css": [
"js/bluesky.css"
],
"run_at": "document_start"
},
{
"matches": [
"https://*.ground.news/*",
"*://localhost/*"
],
"js": [
"js/ground.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": [
"frame.html",
"js/frame.js",
"popup.html",
"js/popup.js"
],
"matches": [
"http://*/*",
"https://*/*"
]
},
{
"resources": [
"fonts/*.woff",
"fonts/*.ttf"
],
"matches": [
"http://*/*",
"https://*/*"
]
}
],
"action": {
"default_icon": {
"16": "16.png",
"32": "32.png",
"48": "48.png",
"128": "128.png"
},
"default_title": "Ground News Explorer",
"default_popup": "menu.html"
},
"manifest_version": 3
}

View File

@@ -0,0 +1,750 @@
<style>
@font-face {
font-family: 'UniversalSans';
src: url('fonts/UniversalSans-800.woff');
font-weight: 800;
}
@font-face {
font-family: 'UniversalSans';
src: url('fonts/UniversalSans-680.woff');
font-weight: 680;
}
@font-face {
font-family: 'UniversalSans';
src: url('fonts/UniversalSans-480.woff');
font-weight: 480;
}
body {
background-color: #EEEFE9;
width: 420px;
padding: 16px;
margin: 0;
font-family: UniversalSans;
color: #262626;
font-weight: 680;
overflow: hidden;
}
p {
margin-top: 0;
margin-bottom: 0;
}
#header {
display: flex;
justify-content: space-between;
align-items: center;
}
.close-icon:hover {
cursor: pointer;
}
.divider {
height: 0.5px;
background-color: #A6A6A1;
opacity: 0.3;
margin: 16px 0;
}
#menu-bar {
display: grid;
grid-template-columns: 25% 25% 25% 25%;
font-size: 12px;
font-weight: 800;
text-align: center;
}
.menu-icon {
width: 24px;
height: 24px;
margin: auto;
display: block;
}
.active {
border-bottom: 2px solid #262626
}
.active:hover {
cursor: pointer;
}
.inactive {
border-bottom: 0.5px solid #262626;
filter: invert(59%) sepia(0%) saturate(7425%) hue-rotate(72deg) brightness(110%) contrast(91%);
}
.inactive:hover {
cursor: pointer;
}
#coverage {
font-size: 14px;
margin-top: 0;
margin-bottom: 16px;
}
#coverage-source {
font-weight: 800;
}
p {
margin: 16px 0;
}
#coverage-chart-container {
margin:8px 0;
display: flex;
column-gap: 5px;
justify-content: space-around;
}
.coverage-chart-bar-container {
background-color: #E6E8DE;
height: 240px;
width: 50px;
border-radius: 30px;
}
#coverage-chart {
width: 100%
}
.hidden {
display: none
}
.button {
width: 175px;
height: 40px;
font-size: 14px;
font-weight: 680;
background-color: #262626;
color: #EEEFE9;
margin: auto;
text-align: center;
border-radius: 4px;
line-height: 40px;
cursor: pointer;
}
.button-container {
display: flex;
justify-content: center;
align-items: center;
column-gap: 8px;
height: 40px;
}
.section {
padding: 16px 0;
overflow-y: scroll;
height: 400px
}
.save-article {
display: flex;
padding: 16px;
background-color: #E6E8DE;
}
.save-article-header {
font-size: 14px;
font-weight: 800;
margin: 0 0 8px 8px
}
.save-article-subscript {
font-size: 12px;
font-weight: 680;
margin: 0 0 0 8px
}
.save-article-image {
border-radius: 50%;
height: 24px;
width: 24px;
margin-top: 6px;
}
.save-article-button {
margin: 25px auto
}
.save-article-button-image {
width: 16px;
height: 16px;
}
.save-article-recently-saved-divider {
margin-top: 8px
}
.citations-section-default-citation {
background-color: #E6E8DE;
border-radius: 2px;
display: flex;
padding: 16px;
align-items: flex-start;
column-gap: 14px;
}
.citations-section-default-citation-header {
font-size: 14px;
line-height: 17px;
font-weight: 480;
margin-top: 0;
margin-bottom: 12px;
}
.citations-section-default-citation-subscript {
font-size: 12px;
line-height: 15px;
color: #A6A6A1;
font-weight: 680;
margin-top: 0;
margin-bottom: 0;
}
.citations-section-default-citation-subscript > span {
color: #262626
}
.citations-sections-default-citation-image {
width: 20px;
height: 20px;
}
.view-all-button {
text-align: center;
font-size: 16px;
line-height: 24px;
color: #767774;
font-weight: 800;
position: fixed;
bottom: 0;
width: 100%;
background-color: #EEEFE9;
margin: 0 auto;
padding: 16px 0;
opacity: 0.95;
cursor: pointer
}
.menu-item-paragraph {
margin-top: 8px;
margin-bottom: 12px;
}
.citation-section-create-form-header {
font-size: 12px;
margin-top: 0;
margin-bottom: 8px;
line-height: 15px;
font-weight: 680;
}
.citation-section-create-form-field {
background-color: #E6E8DE;
border-radius: 2px;
font-size: 14px;
font-weight: 680;
line-height: 17px;
padding: 12px 16px;
box-sizing: border-box;
width: 100%;
font-family: UniversalSans;
border: 0.5px solid #A6A6A1;
color: #262626;
}
.citation-section-create-form-field::placeholder {
color: #262626;
opacity: 1;
}
.factuality-data-section-default-bias-bar {
height: 22px;
font-size: 10px;
line-height: 22px;
font-weight: 800;
color: #EEEFE9;
display: flex;
column-gap: 2px;
margin-bottom: 24px;
text-align: center;
}
.factuality-data-section-default-pill-bar {
height: 48px;
border-radius: 40px;
margin-bottom:16px;
display: flex;
justify-content: flex-start;
align-items: center;
column-gap: 5px;
padding-left: 15px;
padding-right: 15px;
}
.factuality-data-section-detailed-legend {
display: flex;
align-items: center;
column-gap: 4px;
}
.factuality-data-section-detailed-legend-icon {
border-radius: 50%;
width: 16px;
height: 16px;
display: inline-block;
}
.citation-format-button {
cursor: pointer;
}
.citation-format-button-apa {
font-weight: 680;
}
.citation-format-button-mla {
font-weight: 480;
}
#dropdown-menu {
background-color: #EEEFE9;
right: 21px;
top: 26px;
z-index: 1;
width: 130px;
border: solid 1px #A6A6A1;
border-radius: 4px;
}
#dropdown-menu p {
padding: 5px;
margin: 0;
cursor: pointer;
}
#dropdown-menu p:hover {
background-color:#262626;
color: #EEEFE9;
}
#loading-image {
transform: scale(1);
animation: pulse 2s infinite;
}
.section-header-container h2 {
font-size: 16px;
font-weight: 680;
line-height: 18px;
}
#blacklist-settings-section input {
width:335px;
height:40px;
background-color:#E6E8DE;
border-radius: 4px;
font-weight: 480;
font-size: 14px;
line-height: 18px;
border: 1px solid #A6A6A1;
padding: 10px;
}
#blacklist-settings-section button {
width:70px;
height:40px;
background-color:#262626;
color: #EEEFE9;
border-radius: 4px;
font-weight: 480;
font-size: 14px;
line-height: 18px;
border: 1px solid #262626;
padding: 10px;
float: right;
cursor: pointer;
}
#blacklist-settings-section button:hover {
background-color: #D1BD91;
color: #262626;
}
.blacklisted-site-container {
display: flex;
padding: 10px;
background-color: #E6E8DE;
justify-content: space-between;
font-size: 14px;
line-height: 21px;
margin-top:2.5px;
margin-bottom: 2.5px;
}
.blacklisted-site-container p {
font-size: 14px;
font-weight: 480;
margin: 0;
}
.blacklisted-site-container i {
font-weight: 480;
font-size: 18px;
cursor: pointer;
}
.blacklisted-site-container i:hover {
font-weight: 680;
}
.section-header-container {
display: flex;
align-items: flex-start;
column-gap: 10px;
}
#blacklist-settings-input-container {
margin-top: 15px;
margin-bottom: 15px;
}
#help-section h3 {
font-size: 14px;
font-weight: 680;
}
#help-section-back-button {
font-size:24px;
cursor:pointer
}
.section-header-container i:hover {
font-weight: 680;
}
.dropdown-icon:hover {
font-weight: 680;
}
@keyframes pulse {
0% {
transform: scale(0.85);
}
70% {
transform: scale(1);
}
100% {
transform: scale(0.85);
}
}
</style>
<!DOCTYPE html>
<head>
<link
rel="stylesheet"
type="text/css"
href="https://unpkg.com/@phosphor-icons/web@2.0.3/src/regular/style.css"
/>
</head>
<body>
<div id="header">
<img src="https://groundnews.b-cdn.net/assets/logo/ground_new_logo_header.svg?width=60px" alt="Ground Logo" style="width:60px"/>
<div style="display:flex;column-gap:10px;align-items:center;position:relative">
<div style="float:right;border-radius:4px;padding:5px;font-size:16px;line-height:24px;display:flex;align-items:center;column-gap:5px;font-weight:480;">
<i id="dropdown-button" class="ph ph-dots-three-circle dropdown-icon" style="cursor:pointer"></i>
<i class="ph ph-x close-icon dropdown-icon"></i>
</div>
<div style="font-size:14px;display:none;position:absolute" id="dropdown-menu">
<!-- Removing the Help Section since it is not yet ready
<p id="help-section-button">
Help
</p>
-->
<p id="blacklist-settings-button">
Hidden Sources
</p>
<p id="log-out-button">
Log Out
</p>
</div>
</div>
</div>
<hr class="divider">
<div id="menu-bar">
<div class="active" id="bias-distribution-menu-item">
<img src="icons/bar-chart-2.svg" class="menu-icon">
<p class="menu-item-paragraph">
Bias Distribution
</p>
</div>
<div class="inactive" id="save-article-menu-item">
<img src="icons/bookmark-1-black.svg" class="menu-icon">
<p class="menu-item-paragraph">
Save Article
</p>
</div>
<div class="inactive" id="citations-menu-item">
<img src="icons/book-open-1-black.svg" class="menu-icon">
<p class="menu-item-paragraph">
Citations
</p>
</div>
<div class="inactive" id="factuality-data-menu-item">
<img src="icons/circle-dashed-black.svg" class="menu-icon">
<p class="menu-item-paragraph">
Factuality Data
</p>
</div>
</div>
<div id="loading-section" class="section">
<div style="display:flex;align-items:center;justify-content:center;height:80%;">
<img src="https://groundnews.b-cdn.net/assets/logo/ground_new_logo_header.svg" id="loading-image">
</div>
</div>
<div id="bias-distribution-section" class="section">
<div>
<p id="coverage">
Along with {sourceName}, this story has also been covered by {sourceCount} other news sources including {biasSrcCount} with bias reviews.
</p>
</div>
<hr class="divider">
<div id="coverage-chart-container">
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-farLeft">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-left">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-leanLeft">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-center">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-leanRight">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-right">
</div>
<div class="coverage-chart-bar-container" id="coverage-chart-bar-container-farRight">
</div>
</div>
<div class="button" id="coverage-link" style="margin-top: 30px;">
Full Coverage
</div>
</div>
<div id="save-article-section" class="section">
<div class="save-article" id="save-article-main">
<img class="save-article-image" id="save-article-img-main"/>
<div class="save-article-div">
<p class="save-article-header" id="save-article-header-main">
</p>
<p class="save-article-subscript" id="save-article-subscript-main">
</p>
</div>
</div>
<div class="button save-article-button">
<div class="button-container" id="save-article-button">
<img src="icons/bookmark-1-white.svg" class="save-article-button-image"/>
<span>Save Article</span>
</div>
</div>
<div id="save-article-more">
<p style="font-size:16px;line-height:24px;font-weight:680;color:#767774;margin:0;">
Recently Saved
</p>
<hr class="divider save-article-recently-saved-divider">
<div id="save-article-empty">
You have no recently saved articles.
</div>
<div class="save-article" id="save-article-1">
<img class="save-article-image" id="save-article-img-sub-1"/>
<div class="save-article-div">
<p class="save-article-header" id="save-article-header-sub-1">
</p>
<p class="save-article-subscript" id="save-article-subscript-sub-1">
</p>
</div>
</div>
<hr class="divider" id="save-article-divider-2">
<div class="save-article" id="save-article-2">
<img class="save-article-image" id="save-article-img-sub-2"/>
<div class="save-article-div">
<p class="save-article-header" id="save-article-header-sub-2">
</p>
<p class="save-article-subscript" id="save-article-subscript-sub-2">
</p>
</div>
</div>
<hr class="divider" id="save-article-divider-3">
<div class="save-article" id="save-article-3">
<img class="save-article-image" id="save-article-img-sub-3"/>
<div class="save-article-div">
<p class="save-article-header" id="save-article-header-sub-3">
</p>
<p class="save-article-subscript" id="save-article-subscript-sub-3">
</p>
</div>
</div>
<p class="view-all-button" id="save-article-view-all-button" style="cursor:pointer;">
View all >
</p>
</div>
</div>
<div id="non-event-section" class="section">
<img src="icons/access-block.png" style="margin:58px auto 16px auto;display:block">
<p style="font-weight:800;font-size:22px;line-height:26px;text-align:center;margin-top:0" id="non-event-section-header">
Whoops
</p>
<p style="width:205px;font-weight:480;font-size:14px;line-height:17px;text-align:center;margin:0 auto 16px auto" id="non-event-section-text">
This doesn't look like a recognized news article.
</p>
</div>
<div id="save-article-section-signed-out" class="section">
<img src="icons/access-block.png" style="margin:58px auto 16px auto;display:block">
<p style="font-weight:800;font-size:22px;line-height:26px;text-align:center;margin-top:0">
Hold Up!
</p>
<p style="width:205px;font-weight:480;font-size:14px;line-height:17px;text-align:center;margin:0 auto 16px auto" id="sign-in-prompt">
Sign up or log in to Ground News to save this article
</p>
<div class="button" style="line-height:40px;">
Log in
</div>
</div>
<div id="citations-section-default" class="section">
<div class="button" id="citations-section-default-create-button">
Create New Citation
</div>
<p style="font-weight:680;font-size:14px;line-height:17px;margin-bottom:0;">
My citations
</p>
<div id="citations-container">
</div>
<p class="view-all-button" id="citation-view-all-button">
View all >
</p>
</div>
<div id="citations-section-create" class="section">
<div style="display:flex;column-gap:4px;padding:12px 16px;background-color:#D1BD91;">
<img src="icons/info.svg" style="width:16px;height:16px" />
<p style="font-size:12px;line-height:16px;font-weight:680;margin-top:0;margin-bottom:0">
Review the following information to check that this is the correct source and fill out any missing/additional
information.
</p>
</div>
<div style="margin-top:24px;margin-bottom:16px">
<p class="citation-section-create-form-header">
Title
</p>
<input type="text" class="citation-section-create-form-field" placeholder="Title" id="citation-title">
</div>
<div style="margin-bottom:16px;display:flex;column-gap:24px;">
<div>
<p class="citation-section-create-form-header">
Date Published
</p>
<input type="text" class="citation-section-create-form-field" placeholder="January 1st 2022" id="citation-date-pub">
</div>
<div>
<p class="citation-section-create-form-header">
Date Accessed
</p>
<input type="text" class="citation-section-create-form-field" placeholder="January 1st 2022" id="citation-date-acc">
</div>
</div>
<div style="margin-bottom:16px;display:flex;column-gap:24px;">
<div>
<p class="citation-section-create-form-header">
Author
</p>
<input type="text" class="citation-section-create-form-field" placeholder="Author" id="citation-author">
</div>
<div>
<p class="citation-section-create-form-header">
Publisher
</p>
<input type="text" class="citation-section-create-form-field" placeholder="Publisher" id="citation-pub">
</div>
</div>
<div style="margin-bottom:16px;">
<p class="citation-section-create-form-header">
URL
</p>
<input type="text" class="citation-section-create-form-field" placeholder="URL" id="citation-url">
</div>
<div style="margin-bottom:24px;">
<p class="citation-section-create-form-header">
Notes
</p>
<textArea class="citation-section-create-form-field" rows="3" id="citation-notes"></textArea>
</div>
<div style="display:flex;column-gap:24px">
<div class="button" style="background-color:#A6A6A1;color:#EEEFE9" id="citation-save-button">
Save Changes
</div>
<div class="button" id="citations-section-create-view-button" style="background-color:#EEEFE9;color:#262626;border:1px solid #262626">
View Citations
</div>
</div>
</div>
<div id="factuality-data-section-default" class="section">
<p style="font-weight:800;font-size:18px;line-height:22px;margin-top:0;margin-bottom:16px">
Factuality Distribution
</p>
<p style="font-weight:680;font-size:14px;line-height:17px;">
Factuality reported across <span id="factuality-num-sources"></span> sources
</p>
<hr class="divider" />
<div class="factuality-data-section-default-bias-bar">
<div style="width:30%;background-color:#6e706b" id="factuality-bar-veryLow">
VERY LOW
</div>
<div style="width:15%;background-color:#A6A6A1" id="factuality-bar-low">
LOW
</div>
<div style="width:10%;background-color:#767774" id="factuality-bar-mixed">
MIXED
</div>
<div style="width:15%;background-color:#555555" id="factuality-bar-high">
HIGH
</div>
<div style="width:30%;background-color:#393938" id="factuality-bar-veryHigh">
VERY HIGH
</div>
</div>
<div class="factuality-data-section-default-pill-bar" id="factuality-data-section-pill-bar-veryHigh" style="background-color:#262626">
</div>
<div class="factuality-data-section-default-pill-bar" id="factuality-data-section-pill-bar-high" style="background-color:#262626">
</div>
<div class="factuality-data-section-default-pill-bar" id="factuality-data-section-pill-bar-mixed" style="background-color:#636361">
</div>
<div class="factuality-data-section-default-pill-bar" id="factuality-data-section-pill-bar-low" style="background-color:#9E9E9B">
</div>
<div class="factuality-data-section-default-pill-bar" id="factuality-data-section-pill-bar-veryLow" style="background-color:#9E9E9B">
</div>
<div class="button" id="factuality-data-section-default-view-more-button" style="margin-top:32px">
View Factuality Data
</div>
</div>
<div id="factuality-data-section-locked" class="section">
<img src="icons/lock.svg" style="width:48px;margin:100px auto 16px auto;display:block" />
<p style="font-weight:800;font-size:22px;line-height:27px;margin-top:0;margin-bottom:8px;text-align:center;">
Locked Feature
</p>
<p style="font-weight:480;font-size:14px;line-height:17px;margin:0 auto 16px auto;text-align:center;width:200px;">
You need a Premium subscription to view this feature.
</p>
<div class="button" id="factuality-data-subscribe-button">
Subscribe Now
</div>
</div>
<div id="blacklist-settings-section" class="section">
<div class="section-header-container">
<i class="ph ph-arrow-left" id="blacklist-settings-back-button" style="font-size:24px;cursor:pointer"></i>
<h2 style="margin:0">
Prevent the browser extension from appearing on certain websites.
</h2>
</div>
<div id="blacklist-settings-input-container">
<input type="text" placeholder="Enter a web address (ex. twitter.com)">
<button id="blacklist-settings-add-button">
Add
</button>
</div>
<h2>
The extension will not appear on the following websites.
</h2>
<div id="blacklisted-sites-container">
</div>
<div id="blacklist-placeholder">
<img style="display:block;margin:80px auto 0 auto" src="https://groundnews.b-cdn.net/extension/blacklist_null_state_2.png">
<p style="font-size: 18px; font-weight: 800; text-align: center;">
List currently empty
</p>
</div>
</div>
<div id="help-section" class="section">
<div class="section-header-container" style="align-items: center;">
<i class="ph ph-arrow-left" id="help-section-back-button"></i>
<h2 style="margin:0">
Help
</h2>
</div>
<h3>
Welcome to Ground News Help!
</h3>
<p style="font-weight: 480; font-size: 12px;">
We're here to assist you in getting the most out of our platform and ensuring you have a seamless experience while navigating the complex world of news. Below, you'll find a comprehensive guide to help answer any questions or concerns you may have. If you can't find what you're looking for, please don't hesitate to reach out to our dedicated support team.
</p>
<p style="font-weight: 480; font-size: 14px;">
If you're experiencing an issue that isn't covered in this guide, please contact our team at <a href="mailto:feedback@ground.news" style="color:inherit;">feedback@ground.news</a>.
</p>
</div>
<div id="alert" style="display:none;z-index:5;background-color:#EEEFE9;border:2px solid #D50000;font-size:16px;padding:15px;position:fixed;top:0;left:0;font-weight:680;width:90%;align-items:center">
</div>
<script src="js/menu.js"></script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Ground Explorer Options</title>
<script src="js/vendor.js"></script>
</head>
<body>
Favorite color:
<select id="color">
<option value="red">red</option>
<option value="green">green</option>
<option value="blue">blue</option>
<option value="yellow">yellow</option>
</select>
<label>
<input type="checkbox" id="like" />
I like colors.
</label>
<div id="status"></div>
<button id="save">Save</button>
<script src="js/options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,17 @@
<style>
html,
body,
iframe {
margin: 0;
border: 0;
padding: 0;
display: block;
width: 420px;
height: 270px;
background: black;
color: black;
}
</style>
<body>
<script src="js/popup.js"></script>
</body>

BIN
ground_news.db Normal file

Binary file not shown.

559
ground_news.py Normal file
View File

@@ -0,0 +1,559 @@
"""
ground_news.py — Ground News article fetcher + local SQLite store
Key design:
- RSC payload trick: send RSC: 1 header to get Next.js App Router data
- page_cache table: raw RSC payloads with TTL (don't re-fetch fresh pages)
- articles table: all extracted fields, categories merged across pages
- fetch_article(slug) — single article, rich data
- fetch_category(slug) — all stories on an interest page (~15 stories)
- fetch_all() — all known interest categories in parallel
- top_articles(n, days)— query DB for top-N by source_count
"""
import re
import json
import time
import sqlite3
import httpx
import concurrent.futures
from pathlib import Path
from db import get_conn, DBConn
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
DB_PATH = Path(__file__).parent / "ground_news.db"
BASE_URL = "https://ground.news"
CACHE_TTL = {
"interest": 30 * 60, # category pages: 30 min
"article": 6 * 60 * 60, # single articles: 6 h
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
"Accept": "text/html,application/xhtml+xml",
"RSC": "1",
"Next-Router-State-Tree": (
"%5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D"
),
}
# All known interest slugs (auto-discovered from ground.news homepage 2026-05-24)
KNOWN_INTERESTS: dict[str, str] = {
"europe": "Europe",
"europe-economy": "Europe Economy",
"european-politics": "European Politics",
"european-union": "European Union",
"european-security-and-nato": "European Security & NATO",
"uk-politics": "UK Politics",
"united-kingdom": "United Kingdom",
"international": "International",
"north-america": "North America",
"south-america": "South America",
"africa": "Africa",
"asia": "Asia",
"australia": "Australia",
"us-politics": "US Politics",
"united-states": "United States",
"donald-trump": "Donald Trump",
"trump-administration": "Trump Administration",
"israeli-palestinian-conflict": "Israeli-Palestinian Conflict",
"business-and-markets": "Business & Markets",
"premier-league": "Premier League",
"soccer": "Soccer",
"memorial-day": "Memorial Day",
# Financial / C25 relevant categories
"pharma": "Pharmaceuticals",
"energy": "Energy",
"renewable-energy": "Renewable Energy",
"denmark": "Denmark",
"finance": "Finance",
"corporate": "Corporate",
"technology": "Technology",
"climate-change": "Climate Change",
"shipping": "Shipping",
# Danish/Nordic specific
"biotech": "Biotech",
"healthcare": "Healthcare",
"pharmaceutical": "Pharmaceutical",
"nordic": "Nordic",
"scandinavia": "Scandinavia",
"denmark-economy": "Denmark Economy",
"danish-economy": "Danish Economy",
"global-economy": "Global Economy",
"global-markets": "Global Markets",
"stock-market": "Stock Market",
"investing": "Investing",
"clean-energy": "Clean Energy",
"logistics": "Logistics",
"diabetes": "Diabetes",
}
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
def get_db() -> DBConn:
"""Return a DBConn wrapper (Postgres or SQLite). Schema is managed by db.py."""
return get_conn()
# ---------------------------------------------------------------------------
# HTTP fetch with cache
# ---------------------------------------------------------------------------
def fetch_cached(db: DBConn, url: str, page_type: str = "interest") -> tuple[str, bool]:
"""Return (content, from_cache). Re-fetches if stale per CACHE_TTL."""
row = db.execute(
"SELECT content, fetched_at FROM page_cache WHERE url=?", (url,)
).fetchone()
ttl = CACHE_TTL.get(page_type, 1800)
now = int(time.time())
if row and (now - row["fetched_at"]) < ttl:
return row["content"], True
r = httpx.get(url, headers=HEADERS, follow_redirects=True, timeout=20)
r.raise_for_status()
db.upsert(
"page_cache", "url",
["url", "page_type", "fetched_at", "content"],
(url, page_type, now, r.text),
)
db.commit()
return r.text, False
# ---------------------------------------------------------------------------
# RSC payload parsers
# ---------------------------------------------------------------------------
# UUID v4 pattern
_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
# blindspotData — has coverageProfileStatement + coverageProfileType before the numbers
_BLIND = re.compile(
r'"blindspotData":\{[^}]{0,400}' # skip coverageProfileStatement, coverageProfileType
r'"leftPercent":([\d.]+),"rightPercent":([\d.]+),"centerPercent":([\d.]+),'
r'"leftSrcCount":(\d+),"rightSrcCount":(\d+),"cntrSrcCount":(\d+)'
)
# Story anchor: start + title + slug + factuality (field order confirmed from RSC)
_STORY = re.compile(
r'"start":"(20\d{2}-[^"]+)",'
r'"title":"([^"]{10,200})",'
r'"slug":"([a-z0-9][a-z0-9_-]{15,})",'
r'"factuality":\{([^}]+)\}'
)
# Escaped JSON string value
_JSON_STR = re.compile(r'"((?:[^"\\]|\\.)*)"')
def _decode(s: str) -> str:
"""Decode a JSON-escaped string value."""
try:
return json.loads(f'"{s}"')
except Exception:
return s
def parse_stories(data: str, category: str) -> list[dict]:
"""Extract all story objects from an RSC payload."""
stories = []
for m in _STORY.finditer(data):
start, title, slug, fact_raw = m.group(1), m.group(2), m.group(3), m.group(4)
before = data[max(0, m.start() - 8000): m.start()]
after = data[m.end(): m.end() + 6000]
# UUID — last v4 UUID found before the story anchor (the story's own id)
uuids = _UUID.findall(before[-4000:])
story_id = uuids[-1] if uuids else None
# blindspotData (comes before the anchor)
blind = _BLIND.search(before[-8000:])
left_pct = right_pct = ctr_pct = None
left_cnt = right_cnt = ctr_cnt = None
if blind:
left_pct = float(blind.group(1)) # already 0-100
right_pct = float(blind.group(2))
ctr_pct = float(blind.group(3))
left_cnt = int(blind.group(4))
right_cnt = int(blind.group(5))
ctr_cnt = int(blind.group(6))
# biasSourceCount
bsc = re.search(r'"biasSourceCount":(\d+)', before[-8000:])
bias_src_count = int(bsc.group(1)) if bsc else 0
# overallBias score (-1 .. +1)
ob = re.search(r'"overallBias":([-\d.]+)', before[-8000:])
overall_bias = float(ob.group(1)) if ob else None
# blindspot label ("left"/"right"/"center")
bs = re.search(r'"blindspot":"(left|right|center|none)"', before[-8000:])
blindspot = bs.group(1) if bs else None
# description — allow JSON-escaped content
desc_m = re.search(r'"description":"((?:[^"\\]|\\.){0,600})"', before[-3000:])
description = _decode(desc_m.group(1)) if desc_m else None
# sourceCount (comes after the anchor in sources:[...])
sc = re.search(r'"sourceCount":(\d+)', after)
source_count = int(sc.group(1)) if sc else 0
# factuality
factuality = {k: int(v) for k, v in re.findall(r'"(\w+)":(\d+)', fact_raw)}
# Ground News interest UUIDs this story belongs to
int_m = re.search(r'"interests":\[([^\]]*)\]', before[-2000:])
interests = _UUID.findall(int_m.group(1)) if int_m else []
stories.append({
"slug": slug,
"story_id": story_id,
"title": _decode(title),
"description": description,
"start_date": start[:10],
"source_count": source_count,
"bias_src_count": bias_src_count,
"left_pct": left_pct,
"ctr_pct": ctr_pct,
"right_pct": right_pct,
"left_src_count": left_cnt,
"ctr_src_count": ctr_cnt,
"right_src_count":right_cnt,
"overall_bias": overall_bias,
"blindspot": blindspot,
"factuality": factuality,
"interests": interests,
"category": category,
})
return stories
def parse_single_article(data: str, slug: str) -> dict:
"""Richer extraction for a single article page (has wireStoryRefs etc)."""
def get(pattern, cast=str):
m = re.search(pattern, data)
try:
return cast(m.group(1)) if m else None
except Exception:
return None
# story_id: UUID before the slug
id_m = re.search(r'"id":"([0-9a-f-]{36})"[^}]{0,200}"slug":"' + re.escape(slug), data, re.DOTALL)
story_id = id_m.group(1) if id_m else get(r'"id":"([0-9a-f-]{36})"')
# Title — must come before wireStoryRefs
title_m = re.search(r'"title":"([^"]{10,200})"[^}]{0,100}"wireStoryRefs"', data, re.DOTALL)
title = _decode(title_m.group(1)) if title_m else get(r'"title":"([^"]{10,200})"')
# blindspotData
blind = _BLIND.search(data)
# Bias side breakdown
bias_breakdown = {}
for side in ("left", "center", "right"):
bm = re.search(
rf'"id":"{side}".*?"sourceCount":(\d+).*?"percent":(\d+)',
data, re.DOTALL
)
if bm:
bias_breakdown[side] = {"sources": int(bm.group(1)), "percent": int(bm.group(2))}
# factuality
fm = re.search(r'"factuality":\{([^}]+)\}', data)
factuality = {k: int(v) for k, v in re.findall(r'"(\w+)":(\d+)', fm.group(1))} if fm else {}
desc_m = re.search(r'"description":"((?:[^"\\]|\\.){20,600})"', data)
return {
"slug": slug,
"story_id": story_id,
"title": title,
"description": _decode(desc_m.group(1)) if desc_m else None,
"start_date": get(r'"start":"(20\d{2}-[^"]+)"'),
"source_count": get(r'"sourceCount":(\d+)', int),
"bias_src_count": get(r'"biasSourceCount":(\d+)', int),
"overall_bias": get(r'"overallBias":([-\d.]+)', float),
"blindspot": get(r'"blindspot":"(left|right|center|none)"'),
"left_pct": float(blind.group(1)) if blind else None,
"right_pct": float(blind.group(2)) if blind else None,
"ctr_pct": float(blind.group(3)) if blind else None,
"left_src_count": int(blind.group(4)) if blind else None,
"right_src_count": int(blind.group(5)) if blind else None,
"ctr_src_count": int(blind.group(6)) if blind else None,
"factuality": factuality,
"bias_breakdown": bias_breakdown,
}
# ---------------------------------------------------------------------------
# DB upsert
# ---------------------------------------------------------------------------
def upsert_articles(db: DBConn, stories: list[dict]) -> int:
"""Insert new / update existing articles. Returns count of new rows."""
now = int(time.time())
new = 0
for s in stories:
row = db.execute(
"SELECT categories, first_seen FROM articles WHERE slug=?", (s["slug"],)
).fetchone()
cats = set((row["categories"] or "").split(",")) if row else set()
cats.discard("")
cats.add(s["category"])
if row:
db.execute(
"""UPDATE articles SET
story_id=COALESCE(story_id, ?),
source_count=?, bias_src_count=?,
left_pct=?, ctr_pct=?, right_pct=?,
left_src_count=?, ctr_src_count=?, right_src_count=?,
overall_bias=?, blindspot=?,
description=COALESCE(description, ?),
categories=?, last_seen=?
WHERE slug=?""",
(s["story_id"],
s["source_count"], s["bias_src_count"],
s["left_pct"], s["ctr_pct"], s["right_pct"],
s["left_src_count"], s["ctr_src_count"], s["right_src_count"],
s["overall_bias"], s["blindspot"],
s["description"],
",".join(sorted(cats)), now,
s["slug"]),
)
else:
db.execute(
"""INSERT INTO articles
(slug, story_id, title, description, start_date,
source_count, bias_src_count,
left_pct, ctr_pct, right_pct,
left_src_count, ctr_src_count, right_src_count,
overall_bias, blindspot,
factuality_json, interests_json,
categories, first_seen, last_seen)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(s["slug"], s["story_id"], s["title"], s["description"], s["start_date"],
s["source_count"], s["bias_src_count"],
s["left_pct"], s["ctr_pct"], s["right_pct"],
s["left_src_count"], s["ctr_src_count"], s["right_src_count"],
s["overall_bias"], s["blindspot"],
json.dumps(s["factuality"]), json.dumps(s["interests"]),
s["category"], now, now),
)
new += 1
db.commit()
return new
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def fetch_article_text(slug: str, db: DBConn | None = None) -> str:
"""
Fetch full article RSC payload and return a clean text blob for NLP.
Extracts: main title + description + all source article headlines.
"""
own_db = db is None
if own_db:
db = get_db()
url = f"{BASE_URL}/article/{slug}"
data, _ = fetch_cached(db, url, "article")
if own_db:
db.close()
parts: list[str] = []
seen: set[str] = set()
def add(text: str) -> None:
if text and len(text) > 20 and text not in seen:
seen.add(text)
parts.append(text)
# Main title
for m in re.finditer(r'"title":"((?:[^"\\]|\\.){10,300})"', data):
t = _decode(m.group(1))
if not re.search(r'Getty|AFP|\/AFP|PHOTO-TAG', t, re.I):
add(t)
# Descriptions / excerpts
for pattern in [
r'"description":"((?:[^"\\]|\\.){20,600})"',
r'"excerpt":"((?:[^"\\]|\\.){20,400})"',
r'"summary":"((?:[^"\\]|\\.){20,400})"',
]:
for m in re.finditer(pattern, data):
t = _decode(m.group(1))
if not re.search(r'Getty|AFP|PHOTO-TAG|Author:', t, re.I):
add(t)
# Wire story / source article headlines
for m in re.finditer(r'"headline":"((?:[^"\\]|\\.){10,300})"', data):
add(_decode(m.group(1)))
return " ".join(parts)
def fetch_article(slug: str, db: DBConn | None = None) -> dict:
"""Fetch a single article page; optionally cache + store in DB."""
own_db = db is None
if own_db:
db = get_db()
url = f"{BASE_URL}/article/{slug}"
data, _ = fetch_cached(db, url, "article")
result = parse_single_article(data, slug)
if own_db:
db.close()
return result
def _http_fetch_category(
category_slug: str,
*,
force: bool = False,
) -> tuple[str, list[dict], bool]:
"""
Fetch one category page via HTTP only.
Uses a per-thread DB connection (psycopg2 connections are not thread-safe).
Returns (slug, stories, from_cache).
"""
db = get_conn()
url = f"{BASE_URL}/interest/{category_slug}"
if force:
db.execute("DELETE FROM page_cache WHERE url=?", (url,))
db.commit()
data, from_cache = fetch_cached(db, url, "interest")
db.close()
stories = parse_stories(data, category_slug)
return category_slug, stories, from_cache
def fetch_category(
category_slug: str,
db: DBConn,
*,
force: bool = False,
) -> tuple[list[dict], bool]:
"""
Fetch an interest category page.
Returns (stories, from_cache).
"""
_, stories, from_cache = _http_fetch_category(category_slug, force=force)
upsert_articles(db, stories)
return stories, from_cache
def fetch_all(
db: DBConn,
slugs: list[str] | None = None,
*,
force: bool = False,
workers: int = 12,
) -> dict[str, list[dict]]:
"""
Fetch all (or given) interest categories in parallel (HTTP only),
then upsert results serially into DB from the calling thread.
Returns {slug: [story, ...]} mapping.
"""
targets = slugs or list(KNOWN_INTERESTS.keys())
results: dict[str, list[dict]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(_http_fetch_category, s, force=force): s for s in targets}
for f in concurrent.futures.as_completed(futs):
slug = futs[f]
try:
_, stories, cached = f.result()
upsert_articles(db, stories) # DB write in main thread
results[slug] = stories
icon = "💾" if cached else "🌐"
print(f" {icon} {slug:<38} {len(stories):2} stories")
except Exception as e:
print(f"{slug:<38} ERROR: {e}")
results[slug] = []
return results
def top_articles(
db: DBConn,
limit: int = 30,
days: int | None = 2,
min_sources: int = 0,
) -> list[sqlite3.Row]:
"""Query DB for top articles by source_count."""
where = "WHERE source_count >= ?"
params: list = [min_sources]
if days is not None:
where += " AND start_date >= date('now', ?)"
params.append(f"-{days} days")
return db.execute(
f"SELECT * FROM articles {where} ORDER BY source_count DESC LIMIT ?",
(*params, limit),
).fetchall()
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def print_top(rows: list[sqlite3.Row], header: str = "Top artikler") -> None:
print(f"\n{'='*76}")
print(f" {header} ({len(rows)} artikler)")
print(f"{'='*76}\n")
for i, a in enumerate(rows, 1):
bias = ""
if a["left_pct"] is not None:
bias = f" L{a['left_pct']:.0f}% C{a['ctr_pct']:.0f}% R{a['right_pct']:.0f}%"
cats = (a["categories"] or "").replace(",", " · ")
ob = f" bias={a['overall_bias']:+.2f}" if a["overall_bias"] is not None else ""
bs = f" blindspot={a['blindspot']}" if a["blindspot"] else ""
print(f"{i:2}. [{a['source_count']:4} src{bias}{ob}{bs}] [{a['start_date']}]")
print(f" {a['title'][:80]}")
if a["description"]:
print(f" {a['description'][:90]}")
print(f" [{cats}]")
print(f" /article/{a['slug']}")
print()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
db = get_db()
if len(sys.argv) >= 2 and sys.argv[1] == "article":
slug = sys.argv[2]
url = f"{BASE_URL}/article/{slug}"
data, cached = fetch_cached(db, url, "article")
result = parse_single_article(data, slug)
print(f"({'cached' if cached else 'fetched'})")
print(json.dumps(result, indent=2, ensure_ascii=False))
elif len(sys.argv) >= 2 and sys.argv[1] == "category":
slug = sys.argv[2]
stories, cached = fetch_category(slug, db)
print(f"({'cached' if cached else 'fetched'}) {len(stories)} stories\n")
for s in sorted(stories, key=lambda x: x["source_count"], reverse=True):
print(f" [{s['source_count']:4} src] {s['title'][:70]}")
else:
force = "--force" in sys.argv
days = 3
print(f"Fetching all {len(KNOWN_INTERESTS)} categories (force={force})…\n")
fetch_all(db, force=force)
rows = top_articles(db, limit=30, days=days)
print_top(rows, f"Top 30 seneste {days} dage")
db.close()

35
logs/dashboard.log Normal file
View File

@@ -0,0 +1,35 @@
nohup: ignorerer inddata
MoneyMaker Dashboard → http://localhost:5001
* Serving Flask app 'dashboard'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5001
* Running on http://192.168.15.40:5001
Press CTRL+C to quit
127.0.0.1 - - [26/May/2026 22:02:50] "GET /health HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:02:58] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:05:52] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:06:00] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:06:01] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [26/May/2026 22:07:01] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:08:02] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:09:02] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:10:03] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:11:04] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:12:04] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:13:05] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:14:05] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:15:06] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:16:06] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:17:07] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:18:08] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:18:32] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:18:33] "GET /favicon.ico HTTP/1.1" 404 -
127.0.0.1 - - [26/May/2026 22:19:08] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:19:34] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:20:09] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:20:36] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2026 22:21:09] "GET / HTTP/1.1" 200 -

224
logs/runner_2026-05-26.log Normal file

File diff suppressed because one or more lines are too long

369
portfolio.py Normal file
View File

@@ -0,0 +1,369 @@
"""
portfolio.py — Position tracker + ordre-forslag
Kommandoer:
python portfolio.py orders → dagens køb/sælg/hold forslag
python portfolio.py status → åbne positioner + stop/take
python portfolio.py buy TICKER N PRIS → registrer et køb
python portfolio.py sell TICKER PRIS → registrer et salg
Eksempel:
python portfolio.py buy VWS 11 195.00
python portfolio.py sell VWS 244.00
"""
import sys
import json
import time
import sqlite3
from pathlib import Path
from datetime import datetime, timezone
import yfinance as yf
from db import get_conn, DBConn
# ---------------------------------------------------------------------------
# Config — justér disse
# ---------------------------------------------------------------------------
CAPITAL = 10_000 # DKK total kapital
MAX_POSITIONS = 3 # maks åbne positioner ad gangen
STOP_LOSS_PCT = 0.08 # sælg hvis -8% fra indgang
TAKE_PROFIT_PCT = 0.20 # sælg hvis +20% fra indgang
MIN_SIGNAL = 0.35 # minimum signal_score for KØB
BUY_ANALYST = {"stærk køb", "køb"} # dansk label fra analyst_rec()
C25_PATH = Path(__file__).parent / "c25.json"
# ---------------------------------------------------------------------------
# DB setup
# ---------------------------------------------------------------------------
def get_db() -> DBConn:
"""Return a DBConn wrapper. Schema is managed by db.py."""
return get_conn()
# ---------------------------------------------------------------------------
# Hjælpere
# ---------------------------------------------------------------------------
def _c25_map() -> dict:
data = json.loads(C25_PATH.read_text())
# c25.json er en dict med ticker som nøgle (plus _meta)
return {k: v for k, v in data.items() if k != "_meta"}
def _current_price(ticker_yahoo: str) -> float | None:
try:
info = yf.Ticker(ticker_yahoo).fast_info
return round(float(info.last_price), 2)
except Exception:
return None
def _analyst_label(ticker_yahoo: str) -> tuple[str, int]:
"""Returnerer (dansk label, antal analytikere)."""
try:
info = yf.Ticker(ticker_yahoo).info
rec = (info.get("recommendationKey") or "").lower()
n = info.get("numberOfAnalystOpinions") or 0
label_map = {
"strong_buy": "stærk køb", "buy": "køb",
"hold": "hold", "sell": "sælg", "strong_sell": "stærk sælg",
}
return label_map.get(rec, rec or "ukendt"), int(n)
except Exception:
return "ukendt", 0
def _open_positions(db: DBConn) -> list:
return db.execute("SELECT * FROM positions ORDER BY entry_date").fetchall()
def _open_count(db: DBConn) -> int:
return db.execute("SELECT COUNT(*) AS cnt FROM positions").fetchone()["cnt"]
def _best_signals(db: DBConn) -> list:
"""Hent de bedste aktuelle signal-rækker (senest 7 dage)."""
cutoff = int(time.time()) - 7 * 86400
return db.execute("""
SELECT ticker, MAX(company_name) AS company_name,
MAX(signal_score) AS signal_score,
AVG(sentiment_score) AS sentiment_score,
SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END) AS pos_count,
SUM(CASE WHEN sentiment = 'negative' THEN 1 ELSE 0 END) AS neg_count,
COUNT(*) AS article_count,
MAX(momentum_dir) AS momentum_dir,
MAX(claude_magnitude) AS magnitude
FROM article_signals
WHERE analyzed_at > ?
GROUP BY ticker
ORDER BY signal_score DESC
""", (cutoff,)).fetchall()
# ---------------------------------------------------------------------------
# Ordre-logik
# ---------------------------------------------------------------------------
def _per_position(db: DBConn) -> int:
open_n = _open_count(db)
slots = MAX_POSITIONS - open_n
if slots <= 0:
return 0
# fordel restkapi tal ligeligt
used = sum(
p["shares"] * p["entry_price"]
for p in _open_positions(db)
)
remaining = max(0.0, CAPITAL - used)
return int(remaining // slots)
def evaluate_orders(db: DBConn) -> None:
c25 = _c25_map()
signals = _best_signals(db)
open_pos = {p["ticker"]: p for p in _open_positions(db)}
open_n = len(open_pos)
per_pos = _per_position(db)
used = sum(p["shares"] * p["entry_price"] for p in open_pos.values())
print()
print("" * 66)
print(f" MONEYMAKER ORDRE-FORSLAG · {datetime.now(timezone.utc).strftime('%d %b %Y %H:%M')} UTC")
print(f" Kapital: {CAPITAL:,.0f} kr │ Brugt: {used:,.0f} kr │ "
f"Åbne: {open_n}/{MAX_POSITIONS} │ Pr. pos: {per_pos:,.0f} kr")
print("" * 66)
buy_suggestions = []
hold_suggestions = []
watch_list = []
for row in signals:
ticker = row["ticker"]
meta = c25.get(ticker, {})
yahoo = meta.get("ticker_yahoo", f"{ticker}.CO")
price = _current_price(yahoo)
if price is None:
continue
analyst_lbl, analyst_n = _analyst_label(yahoo)
score = row["signal_score"] or 0.0
pos_ct = row["pos_count"] or 0
neg_ct = row["neg_count"] or 0
net_sentiment = pos_ct - neg_ct
# Er vi allerede inde?
if ticker in open_pos:
hold_suggestions.append((ticker, row, meta, price, analyst_lbl, analyst_n))
continue
# KØB-betingelser
analyst_ok = analyst_lbl in BUY_ANALYST
signal_ok = score >= MIN_SIGNAL
enige = (net_sentiment > 0 and analyst_ok) or (net_sentiment < 0 and analyst_lbl in {"sælg", "stærk sælg"})
if signal_ok and analyst_ok and enige:
buy_suggestions.append((ticker, row, meta, price, analyst_lbl, analyst_n, score))
elif signal_ok or analyst_ok:
watch_list.append((ticker, row, meta, price, analyst_lbl, analyst_n, score))
# --- KØB ---
if buy_suggestions and open_n < MAX_POSITIONS and per_pos > 0:
print()
print(" 🟢 KØB-SIGNALER")
print(" " + "" * 62)
slots_left = MAX_POSITIONS - open_n
for (ticker, row, meta, price, analyst_lbl, analyst_n, score) in buy_suggestions[:slots_left]:
shares = int(per_pos // price)
if shares == 0:
etps = meta.get("leveraged", [])
etp_buy = next((e["ticker"] for e in etps if e.get("direction") == "long"), "")
etp_txt = f" → brug ETP {etp_buy}" if etp_buy else " → pris for høj til direkte køb"
print(f" {ticker:<10} {price:>7,.0f} kr/stk{etp_txt}")
continue
total = shares * price
stop = round(price * (1 - STOP_LOSS_PCT), 2)
take = round(price * (1 + TAKE_PROFIT_PCT), 2)
max_loss = round(shares * (price - stop), 0)
max_gain = round(shares * (take - price), 0)
print(f" {ticker:<10} KØB {shares} stk à {price:,.0f} kr = {total:,.0f} kr")
print(f" Stop-loss: {stop:,.0f} kr (max tab: {max_loss:,.0f} kr)")
print(f" Take-profit: {take:,.0f} kr (max gevinst: {max_gain:,.0f} kr)")
print(f" Signal: {score:.2f} │ Analytikere ({analyst_n}): {analyst_lbl.upper()}")
print(f" Registrer: python portfolio.py buy {ticker} {shares} {price}")
print()
elif not buy_suggestions:
print()
print(" Ingen klare KØB-signaler lige nu.")
# --- ÅBNE POSITIONER (stop/take check) ---
if open_pos:
print()
print(" 📊 ÅBNE POSITIONER")
print(" " + "" * 62)
for ticker, pos in open_pos.items():
meta = c25.get(ticker, {})
yahoo = meta.get("ticker_yahoo", f"{ticker}.CO")
price = _current_price(yahoo) or pos["entry_price"]
entry = pos["entry_price"]
shares = pos["shares"]
pct_chg = (price - entry) / entry * 100
value = shares * price
pnl = shares * (price - entry)
stop_hit = price <= pos["stop_loss"]
take_hit = price >= pos["take_profit"]
action = "🔴 SÆLG (stop-loss!)" if stop_hit else ("🟡 SÆLG (take-profit!)" if take_hit else "⏳ HOLD")
print(f" {ticker:<10} {shares:.0f} stk │ ind: {entry:,.0f} kr │ nu: {price:,.0f} kr │ {pct_chg:+.1f}%")
print(f" Værdi: {value:,.0f} kr │ P&L: {pnl:+,.0f} kr │ {action}")
print(f" Stop: {pos['stop_loss']:,.0f} kr │ Take: {pos['take_profit']:,.0f} kr")
if stop_hit or take_hit:
print(f" Registrer: python portfolio.py sell {ticker} {price:.2f}")
print()
# --- HOLD/WATCH ---
if watch_list:
print()
print(" 🔍 FØLG MED (signal endnu ikke klart nok)")
print(" " + "" * 62)
for (ticker, row, meta, price, analyst_lbl, analyst_n, score) in watch_list[:4]:
why = []
if score < MIN_SIGNAL:
why.append(f"signal {score:.2f} < {MIN_SIGNAL}")
if analyst_lbl not in BUY_ANALYST:
why.append(f"analytikere: {analyst_lbl.upper()}")
print(f" {ticker:<10} {price:>7,.0f} kr │ {', '.join(why)}")
print()
print(" Forklaring:")
print(" Stop-loss = sælg automatisk hvis kurs falder 10% fra dit køb")
print(" Take-profit = sælg hvis kurs stiger 25% — tag gevinsten hjem")
print(" KØB-krav = signal≥0.25 + analytikere siger KØB + begge peger samme vej")
print()
# ---------------------------------------------------------------------------
# Portfolio CRUD
# ---------------------------------------------------------------------------
def cmd_buy(db: DBConn, args: list[str]) -> None:
if len(args) < 3:
print("Brug: python portfolio.py buy TICKER ANTAL PRIS")
print("Eks: python portfolio.py buy VWS 11 195.00")
return
ticker = args[0].upper()
shares = float(args[1])
price = float(args[2])
stop = round(price * (1 - STOP_LOSS_PCT), 2)
take = round(price * (1 + TAKE_PROFIT_PCT), 2)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
total = shares * price
db.upsert(
"positions", "ticker",
["ticker", "shares", "entry_price", "entry_date", "stop_loss", "take_profit"],
(ticker, shares, price, today, stop, take),
)
db.execute("""
INSERT INTO position_events
(ticker, action, shares, price, total_dkk, event_date)
VALUES (?, 'buy', ?, ?, ?, ?)
""", (ticker, shares, price, total, today))
db.commit()
print(f"✅ KØB registreret: {shares:.0f} stk {ticker} à {price:.2f} kr = {total:.2f} kr")
print(f" Stop-loss: {stop:.2f} kr")
print(f" Take-profit: {take:.2f} kr")
def cmd_sell(db: DBConn, args: list[str]) -> None:
if len(args) < 2:
print("Brug: python portfolio.py sell TICKER PRIS")
print("Eks: python portfolio.py sell VWS 244.00")
return
ticker = args[0].upper()
price = float(args[1])
pos = db.execute("SELECT * FROM positions WHERE ticker = ?", (ticker,)).fetchone()
if not pos:
print(f"{ticker} — ingen åben position fundet")
return
shares = pos["shares"]
entry = pos["entry_price"]
pnl = shares * (price - entry)
total = shares * price
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
signal_correct = 1 if pnl > 0 else 0
db.execute("DELETE FROM positions WHERE ticker = ?", (ticker,))
db.execute("""
INSERT INTO position_events
(ticker, action, shares, price, total_dkk, pnl_dkk, signal_correct, event_date)
VALUES (?, 'sell', ?, ?, ?, ?, ?, ?)
""", (ticker, shares, price, total, pnl, signal_correct, today))
db.commit()
emoji = "🟢" if pnl >= 0 else "🔴"
print(f"{emoji} SALG registreret: {shares:.0f} stk {ticker} à {price:.2f} kr = {total:.2f} kr")
print(f" Købt til: {entry:.2f} kr │ P&L: {pnl:+.2f} kr │ Signal: {'✅ korrekt' if signal_correct else '❌ forkert'}")
def cmd_status(db: DBConn) -> None:
positions = _open_positions(db)
history = db.execute("""
SELECT * FROM position_events ORDER BY event_date DESC LIMIT 10
""").fetchall()
if not positions:
print("\n Ingen åbne positioner.\n")
else:
print("\n ÅBNE POSITIONER:")
for p in positions:
meta = _c25_map().get(p["ticker"], {})
yahoo = meta.get("ticker_yahoo", f"{p['ticker']}.CO")
price = _current_price(yahoo) or p["entry_price"]
pnl = p["shares"] * (price - p["entry_price"])
print(f" {p['ticker']:<10} {p['shares']:.0f} stk ind {p['entry_price']:.2f} nu {price:.2f} P&L {pnl:+.2f} kr")
print(f" Stop {p['stop_loss']:.2f} Take {p['take_profit']:.2f} (købt {p['entry_date']})")
if history:
print("\n SENESTE HANDLER:")
total_pnl = 0.0
for e in history:
pnl_txt = f" P&L {e['pnl_dkk']:+.2f} kr" if e["pnl_dkk"] is not None else ""
print(f" {e['event_date']} {e['action'].upper():<5} {e['ticker']:<10} "
f"{e['shares']:.0f} stk à {e['price']:.2f} kr{pnl_txt}")
if e["pnl_dkk"] is not None:
total_pnl += e["pnl_dkk"]
sold = [e for e in history if e["action"] == "sell"]
if sold:
print(f"\n Samlet realiseret P&L: {total_pnl:+.2f} kr")
print()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
db = get_db()
args = sys.argv[1:]
cmd = args[0] if args else "orders"
if cmd == "orders":
evaluate_orders(db)
elif cmd == "status":
cmd_status(db)
elif cmd == "buy" and len(args) >= 4:
cmd_buy(db, args[1:])
elif cmd == "sell" and len(args) >= 3:
cmd_sell(db, args[1:])
else:
print(__doc__)
db.close()
if __name__ == "__main__":
main()

241
report.py Normal file
View File

@@ -0,0 +1,241 @@
"""
report.py — Daily P&L report for MoneyMaker dry run.
Usage:
python report.py # full P&L report
python report.py --fees # fee drag analysis only
python report.py --signals # signal accuracy summary only
"""
import sys
import time
import json
import argparse
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from db import get_conn, DB_TYPE
C25_PATH = Path(__file__).parent / "c25.json"
_c25_raw = json.loads(C25_PATH.read_text())
C25: dict[str, dict] = {k: v for k, v in _c25_raw.items() if not k.startswith("_")}
CAPITAL = 10_000 # DKK — must match portfolio.py
def _c25_day_return() -> float | None:
"""Fetch today's return for OMX C25 CAP index from Yahoo Finance."""
try:
info = yf.Ticker("^OMXC25").fast_info
prev = info.get("previousClose") or info.get("regularMarketPreviousClose")
last = info.get("lastPrice") or info.get("regularMarketPrice")
if prev and last and prev > 0:
return (last - prev) / prev * 100
except Exception:
pass
return None
def _unrealised_pnl(db) -> tuple[float, list[dict]]:
"""
Calculate unrealised P&L for all open positions.
Returns (total_unrealised_dkk, details_list).
"""
positions = db.execute("SELECT * FROM positions").fetchall()
details = []
total_unreal = 0.0
for p in positions:
ticker = p["ticker"]
shares = float(p["shares"])
entry = float(p["entry_price"])
entry_date = p["entry_date"]
yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
try:
last = yf.Ticker(yf_ticker).fast_info.get("lastPrice") or entry
except Exception:
last = entry
cost = shares * entry
value = shares * last
unreal = value - cost
pct = (unreal / cost * 100) if cost else 0
total_unreal += unreal
details.append({
"ticker": ticker,
"shares": shares,
"entry": entry,
"last": last,
"cost": cost,
"value": value,
"unreal": unreal,
"pct": pct,
"entry_date": entry_date,
})
return total_unreal, details
def _realised_pnl(db) -> float:
"""Sum all realised P&L from position_events."""
row = db.execute(
"SELECT COALESCE(SUM(pnl_dkk),0) AS total FROM position_events WHERE action='sell'"
).fetchone()
return float(row["total"] or 0)
def _total_fees(db) -> float:
"""Sum all fees from saxo_orders."""
try:
row = db.execute(
"SELECT COALESCE(SUM(fee_dkk),0) AS total FROM saxo_orders"
).fetchone()
return float(row["total"] or 0)
except Exception:
return 0.0
def _invested(positions_details: list[dict]) -> float:
return sum(p["cost"] for p in positions_details)
def _signal_accuracy(db) -> dict:
"""
Calculate signal accuracy from completed trades (position_events).
A signal is 'correct' if the trade had P&L > 0 (signal_correct=1).
Also returns overall signal scan stats from article_signals.
"""
# Accuracy from closed trades
try:
row = db.execute("""
SELECT
COUNT(*) AS total_trades,
SUM(CASE WHEN signal_correct=1 THEN 1 ELSE 0 END) AS correct,
SUM(CASE WHEN signal_correct=0 THEN 1 ELSE 0 END) AS wrong,
COALESCE(AVG(CASE WHEN signal_correct IS NOT NULL
THEN signal_correct END), -1) AS accuracy
FROM position_events
WHERE action = 'sell' AND signal_correct IS NOT NULL
""").fetchone()
total_trades = int(row["total_trades"] or 0)
correct = int(row["correct"] or 0)
wrong = int(row["wrong"] or 0)
accuracy_pct = (float(row["accuracy"]) * 100) if total_trades > 0 else None
except Exception:
total_trades = correct = wrong = 0
accuracy_pct = None
# NLP scan stats
try:
srow = db.execute("""
SELECT
COUNT(*) AS total_signals,
SUM(CASE WHEN alert=1 THEN 1 ELSE 0 END) AS alerts,
AVG(signal_score) AS avg_score
FROM article_signals
WHERE signal_score IS NOT NULL
""").fetchone()
return {
"total": int(srow["total_signals"] or 0),
"alerts": int(srow["alerts"] or 0),
"avg_score": round(float(srow["avg_score"] or 0), 3),
"total_trades": total_trades,
"correct": correct,
"wrong": wrong,
"accuracy_pct": accuracy_pct,
}
except Exception:
return {
"total": 0, "alerts": 0, "avg_score": 0,
"total_trades": total_trades, "correct": correct,
"wrong": wrong, "accuracy_pct": accuracy_pct,
}
def print_report() -> None:
db = get_conn()
now = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M UTC")
unreal_total, positions = _unrealised_pnl(db)
realised = _realised_pnl(db)
fees = _total_fees(db)
invested = _invested(positions)
cash = CAPITAL - invested
net_pnl = realised + unreal_total - fees
net_pct = (net_pnl / CAPITAL * 100)
c25_ret = _c25_day_return()
vs_bench = (net_pct - c25_ret) if c25_ret is not None else None
sig_stats = _signal_accuracy(db)
# ── Header ──────────────────────────────────────────────────────────────
print()
print("" * 48)
print(f" MONEYMAKER P&L · {now}")
print(f" DB: {DB_TYPE}")
print("" * 48)
print(f" Kapital: {CAPITAL:>10,.0f} DKK")
print(f" Investeret: {invested:>10,.0f} DKK ({len(positions)} pos.)")
print(f" Kontant: {cash:>10,.0f} DKK")
print()
print(f" Urealiseret: {unreal_total:>+10,.0f} DKK")
print(f" Realiseret: {realised:>+10,.0f} DKK")
print(f" Total gebyrer: {-fees:>+8,.0f} DKK")
print(f" Net P&L: {net_pnl:>+10,.0f} DKK ({net_pct:+.2f}%)")
print()
# ── Benchmark ────────────────────────────────────────────────────────────
if c25_ret is not None:
bench_icon = "" if vs_bench and vs_bench >= 0 else ""
print(f" C25 index: {c25_ret:+.2f}% (i dag)")
print(f" vs benchmark: {vs_bench:+.2f}% {bench_icon}")
else:
print(" C25 index: N/A (marked lukket?)")
print()
# ── Open positions ────────────────────────────────────────────────────────
if positions:
print(" ┌─ Åbne positioner ─────────────────────────────┐")
for p in positions:
icon = "📈" if p["unreal"] >= 0 else "📉"
print(
f"{icon} {p['ticker']:<8} {p['shares']:.0f} stk "
f"købt {p['entry']:.0f} nu {p['last']:.0f} "
f"P&L {p['unreal']:+.0f} ({p['pct']:+.1f}%)"
)
print(" └────────────────────────────────────────────────┘")
print()
# ── Signal stats ─────────────────────────────────────────────────────────
print(f" Signaler: {sig_stats['total']:>5} analyseret")
print(f" Alerts: {sig_stats['alerts']:>5} ⚡ triggers")
print(f" Gns score: {sig_stats['avg_score']:>5.3f}")
if sig_stats["total_trades"] > 0:
acc = sig_stats["accuracy_pct"]
icon = "" if acc and acc >= 50 else ""
print(f" Lukkede handler: {sig_stats['total_trades']} "
f"({sig_stats['correct']} korrekte / {sig_stats['wrong']} forkerte) "
f"{acc:.0f}% {icon}")
else:
print(" Lukkede handler: 0 (ingen salg endnu)")
if fees > 0:
drag_pct = (fees / max(abs(net_pnl + fees), 1)) * 100
print(f" Gebyr-drag: {drag_pct:.1f}% af brutto P&L")
print("" * 48)
print()
db.close()
def main():
parser = argparse.ArgumentParser(description="MoneyMaker P&L rapport")
parser.add_argument("--fees", action="store_true")
parser.add_argument("--signals", action="store_true")
args = parser.parse_args()
print_report()
if __name__ == "__main__":
main()

244
rss_feeds.py Normal file
View File

@@ -0,0 +1,244 @@
"""
rss_feeds.py — Danske finansielle RSS feeds til MoneyMaker
Feeds:
Børsen https://borsen.dk/rss
Finans.dk top https://feeds.finans.dk/topnyheder
Politiken øko https://politiken.dk/rss/oekonomi.rss
Artikler gemmes i samme `articles` tabel som Ground News.
`source_count` sættes til feedets kredibilitets-vægt (ikke antal medier,
men et indikativt tal der giver coverage_spread > 0 i pipeline).
Full text (title + description + content:encoded) gemmes i `page_cache`
med url-nøgle `rss:{slug}` så analyze.py kan hente det i Phase 3.
"""
import re
import time
import sqlite3
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
from pathlib import Path
import httpx
from db import get_conn, DBConn
# ---------------------------------------------------------------------------
# Feed-katalog — tilføj nye her
# ---------------------------------------------------------------------------
FEEDS: dict[str, dict] = {
"borsen": {
"url": "https://borsen.dk/rss",
"label": "Børsen",
"weight": 8, # Førende dansk erhvervsmedie
},
"finans-top": {
"url": "https://feeds.finans.dk/topnyheder",
"label": "Finans.dk (top)",
"weight": 7,
},
"finans-seneste": {
"url": "https://feeds.finans.dk/seneste",
"label": "Finans.dk (seneste)",
"weight": 6,
},
"politiken-oekonomi": {
"url": "https://politiken.dk/rss/oekonomi.rss",
"label": "Politiken økonomi",
"weight": 6,
},
# Berlingske: tilføj URL når den er fundet
# "berlingske-erhverv": {
# "url": "https://...",
# "label": "Berlingske erhverv",
# "weight": 7,
# },
}
CACHE_TTL = 30 * 60 # samme TTL som Ground News
NS = {
"content": "http://purl.org/rss/1.0/modules/content/",
"dc": "http://purl.org/dc/elements/1.1/",
}
# ---------------------------------------------------------------------------
# Hjælpefunktioner
# ---------------------------------------------------------------------------
def get_db() -> DBConn:
"""Return a DBConn wrapper. Schema is managed by db.py."""
return get_conn()
def _ensure_rss_cache_table(db: DBConn) -> None:
"""No-op: schema is now managed by db.py init_schema()."""
pass
def _is_cached(db: DBConn, feed_id: str) -> bool:
row = db.execute(
"SELECT fetched_at FROM rss_feed_cache WHERE feed_id = ?", (feed_id,)
).fetchone()
return bool(row and (time.time() - row["fetched_at"]) < CACHE_TTL)
def _mark_cached(db: DBConn, feed_id: str) -> None:
db.upsert(
"rss_feed_cache", "feed_id",
["feed_id", "fetched_at"],
(feed_id, int(time.time())),
)
db.commit()
def _ns(prefix: str, tag: str) -> str:
return f"{{{NS[prefix]}}}{tag}"
def _text(item: ET.Element, tag: str, ns_prefix: str | None = None) -> str:
el = item.find(_ns(ns_prefix, tag) if ns_prefix else tag)
return (el.text or "").strip() if el is not None else ""
def _strip_html(s: str) -> str:
s = re.sub(r"<[^>]+>", " ", s)
s = re.sub(r"&[a-z]+;", " ", s)
return re.sub(r"\s+", " ", s).strip()
def _make_slug(feed_id: str, url: str) -> str:
"""Lav et unikt slug fra feed-navn + URL-sti."""
path = url.split("?")[0].rstrip("/").split("/")[-1]
path = re.sub(r"^ECE\d+-", "", path) # Finans.dk ECE-id
path = re.sub(r"\.rss$|\.html$", "", path)
path = re.sub(r"[^a-z0-9\-]", "-", path.lower())[:55]
path = path.strip("-")
return f"{feed_id}-{path}" if path else f"{feed_id}-{abs(hash(url)) % 100000}"
def _parse_date(s: str) -> str:
if not s:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
try:
return parsedate_to_datetime(s).strftime("%Y-%m-%d")
except Exception:
return s[:10] if len(s) >= 10 else datetime.now(timezone.utc).strftime("%Y-%m-%d")
# ---------------------------------------------------------------------------
# Parse + upsert
# ---------------------------------------------------------------------------
def _parse_feed(feed_id: str, xml_text: str, weight: int) -> list[dict]:
root = ET.fromstring(xml_text)
articles = []
for item in root.findall(".//item"):
link = _text(item, "link") or _text(item, "guid")
if not link:
continue
title = _strip_html(_text(item, "title"))
desc = _strip_html(_text(item, "description"))[:600]
encoded = _strip_html(_text(item, "encoded", "content"))[:3000]
pub = _text(item, "pubDate") or _text(item, "date", "dc")
slug = _make_slug(feed_id, link)
# Fuld tekst til NLP: alt vi har
full_text = f"{title}. {encoded or desc}".strip()
articles.append({
"slug": slug,
"title": title,
"description": desc,
"full_text": full_text,
"start_date": _parse_date(pub),
"source_count": weight,
"categories": f"rss:{feed_id}",
"first_seen": int(time.time()),
"last_seen": int(time.time()),
})
return articles
def _upsert(db: DBConn, articles: list[dict]) -> int:
new = 0
now = int(time.time())
for a in articles:
exists = db.execute(
"SELECT 1 FROM articles WHERE slug = ?", (a["slug"],)
).fetchone()
if exists:
db.execute(
"UPDATE articles SET last_seen = ? WHERE slug = ?",
(now, a["slug"]),
)
else:
db.execute(
"""INSERT INTO articles
(slug, title, description, start_date,
source_count, categories, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(a["slug"], a["title"], a["description"], a["start_date"],
a["source_count"], a["categories"], a["first_seen"], a["last_seen"]),
)
new += 1
# Gem full text i page_cache så analyze.py kan hente det i Phase 3
db.upsert(
"page_cache", "url",
["url", "page_type", "fetched_at", "content"],
(f"rss:{a['slug']}", "rss", now, a["full_text"]),
)
db.commit()
return new
# ---------------------------------------------------------------------------
# Hoved-funktion
# ---------------------------------------------------------------------------
def fetch_all_rss(db: DBConn, force: bool = False) -> int:
"""
Hent alle RSS feeds og gem i DB.
Returnerer antal nye artikler.
"""
_ensure_rss_cache_table(db)
total_new = 0
for feed_id, cfg in FEEDS.items():
if not force and _is_cached(db, feed_id):
print(f" 💾 {cfg['label']:<30} (cache)")
continue
try:
resp = httpx.get(
cfg["url"], timeout=15, follow_redirects=True,
headers={"User-Agent": "MoneyMaker/1.0 RSS reader (+https://github.com)"},
)
resp.raise_for_status()
articles = _parse_feed(feed_id, resp.text, cfg["weight"])
new = _upsert(db, articles)
_mark_cached(db, feed_id)
total_new += new
print(f" 🌐 {cfg['label']:<30} {len(articles):2} artikler (+{new} nye)")
except Exception as e:
print(f"{cfg['label']:<30} FEJL: {e}")
return total_new
if __name__ == "__main__":
db = get_db()
print("[rss] Henter feeds …")
n = fetch_all_rss(db, force=True)
print(f"[rss] Færdig. {n} nye artikler.")
db.close()

235
runner.py Normal file
View File

@@ -0,0 +1,235 @@
"""
runner.py — MoneyMaker pipeline orchestrator.
Usage:
python runner.py # full pipeline (fetch → analyze → evaluate → execute)
python runner.py --dry-run # simulate everything, no real orders
python runner.py --analyze-only # only fetch news + run NLP, no orders
python runner.py --report # print P&L report only
Pipeline flow (each run):
1. Fetch Ground News + Danish RSS feeds
2. Run NLP+Claude analysis on new articles
3. Evaluate buy/sell signals vs open positions
4. Execute orders on Saxo SIM (skip if --dry-run)
5. Print status summary
Scheduled by cron:
06:00 CET --analyze-only (full NLP before market opens)
09:30 CET (default) trade window 1
12:00 CET (default) trade window 2
14:30 CET (default) trade window 3
16:30 CET (default) trade window 4
19:00 CET --report daily P&L summary
"""
import sys
import argparse
import traceback
from datetime import datetime, timezone
from db import get_conn, DB_TYPE, init_schema
from ground_news import fetch_all
from rss_feeds import fetch_all_rss
from analyze import analyze_articles
from portfolio import evaluate_orders, get_db as portfolio_db, _open_positions
from report import print_report
def run_pipeline(dry_run: bool = False, analyze_only: bool = False) -> None:
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"\n{''*60}")
print(f" MONEYMAKER RUNNER · {ts}")
print(f" Mode: {'DRY RUN' if dry_run else 'LIVE'} · DB: {DB_TYPE}")
print(f"{''*60}\n")
# ── 1. Ensure schema is up-to-date ──────────────────────────────────────
try:
init_schema()
except Exception as e:
print(f"[runner] WARNING: init_schema() failed: {e}")
# ── 2. Fetch news ────────────────────────────────────────────────────────
db = get_conn()
try:
print("[runner] Henter Ground News …")
n_gn = fetch_all(db)
print(f" +{n_gn} nye artikler fra Ground News")
print("[runner] Henter danske RSS feeds …")
n_rss = fetch_all_rss(db)
print(f" +{n_rss} nye artikler fra RSS feeds")
finally:
db.close()
# ── 3. NLP analysis ───────────────────────────────────────────────────────
print("\n[runner] Kører NLP analyse …")
analyze_articles(
force=False,
dry_run=dry_run,
use_claude=True,
auto_fetch=False, # already fetched above
)
if analyze_only:
print("\n[runner] --analyze-only flag sat. Stopper efter NLP.")
return
# ── 4. Evaluate orders ────────────────────────────────────────────────────
print("\n[runner] Evaluerer ordre-forslag …")
pdb = portfolio_db()
try:
evaluate_orders(pdb)
finally:
pdb.close()
# ── 5. Auto-execute (skip if dry-run) ────────────────────────────────────
if not dry_run:
_auto_execute()
else:
print("\n[runner] DRY RUN — ingen ordre placeret.")
print("\n[runner] Pipeline færdig.\n")
def _auto_execute() -> None:
"""
Auto-place orders on Saxo SIM based on portfolio recommendations.
1. AUTO-SELL: close positions that have hit stop-loss or take-profit.
2. AUTO-BUY: open new positions when signal + analyst consensus align.
Orders are placed via saxo_broker.place_order() and logged to saxo_orders.
Sells record signal_correct in position_events.
"""
from portfolio import (
CAPITAL, MAX_POSITIONS, MIN_SIGNAL, BUY_ANALYST,
_open_positions, _open_count, _best_signals, _per_position, _c25_map,
cmd_sell,
)
from saxo_broker import place_order, find_uic
import yfinance as yf
pdb = portfolio_db()
c25 = _c25_map()
# ── 1. Auto-sell: stop-loss / take-profit ─────────────────────────────
for pos in _open_positions(pdb):
ticker = pos["ticker"]
yf_ticker = c25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
try:
price = yf.Ticker(yf_ticker).fast_info.get("lastPrice")
except Exception as e:
print(f" SKIP SELL {ticker} — kurs fejl: {e}")
continue
if not price:
continue
stop_hit = price <= pos["stop_loss"]
take_hit = price >= pos["take_profit"]
if stop_hit or take_hit:
reason = "stop-loss" if stop_hit else "take-profit"
print(f" → SÆLGER {pos['shares']:.0f} stk {ticker} à {price:.2f} kr ({reason})")
try:
resp = place_order(
ticker, pos["shares"], "Sell",
price_dkk=price,
note=f"runner auto-sell {reason}",
)
print(f" Saxo svar: {resp}")
except Exception as e:
print(f" FEJL ved Saxo ordre: {e}")
# Record sell in portfolio DB (sets signal_correct)
cmd_sell(pdb, [ticker, str(price)])
# ── 2. Auto-buy: new positions ────────────────────────────────────────
signals = _best_signals(pdb)
open_pos = {p["ticker"]: p for p in _open_positions(pdb)}
for sig in signals:
ticker = sig["ticker"]
score = sig["signal_score"] or 0
momentum = sig["momentum_dir"] or "unknown"
# Only buy if score is strong + not already in position
if score < MIN_SIGNAL:
continue
if ticker in open_pos:
continue
if len(open_pos) >= MAX_POSITIONS:
break
# Check analyst recommendation
try:
from signals import analyst_rec
rec = analyst_rec(ticker)
rec_label_lower = rec["label"].lower().replace("🟢", "").strip()
if rec_label_lower not in BUY_ANALYST:
print(f" SKIP {ticker} — analyst={rec['label']}")
continue
except Exception as e:
print(f" SKIP {ticker} — analyst check failed: {e}")
continue
# Get current price via yfinance
try:
yf_ticker = c25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
price = yf.Ticker(yf_ticker).fast_info.get("lastPrice")
if not price:
print(f" SKIP {ticker} — ingen kurs fra yfinance")
continue
except Exception as e:
print(f" SKIP {ticker} — kurs fejl: {e}")
continue
per_pos = _per_position(pdb)
if per_pos < 500:
print(f" SKIP {ticker} — for lidt kapital: {per_pos} DKK")
continue
shares = int(per_pos // price)
if shares < 1:
print(f" SKIP {ticker} — 0 aktier til {price:.2f} DKK/stk")
continue
print(f" → KØBER {shares} stk {ticker} à ~{price:.2f} DKK (sig={score:.3f})")
try:
resp = place_order(
ticker, shares, "Buy",
price_dkk=price,
signal_score=score,
analyst_rec=rec["label"],
note=f"runner auto-buy sig={score:.3f} mom={momentum}",
)
print(f" Saxo svar: {resp}")
except Exception as e:
print(f" FEJL ved ordre: {e}")
open_pos[ticker] = {"ticker": ticker} # prevent double-buy in same run
pdb.close()
def main():
parser = argparse.ArgumentParser(description="MoneyMaker pipeline runner")
parser.add_argument("--dry-run", action="store_true", help="Simulate, no real orders")
parser.add_argument("--analyze-only", action="store_true", help="Only fetch + NLP, no orders")
parser.add_argument("--report", action="store_true", help="Print P&L report and exit")
args = parser.parse_args()
if args.report:
print_report()
return
try:
run_pipeline(dry_run=args.dry_run, analyze_only=args.analyze_only)
except KeyboardInterrupt:
print("\n[runner] Afbrudt.")
except Exception:
print("[runner] FEJL:")
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

220
saxo_auth.py Normal file
View File

@@ -0,0 +1,220 @@
"""
saxo_auth.py — OAuth2 token manager for Saxo SIM.
Første gang: åbner browser, du logger ind én gang.
Derefter: auto-refresher token uden brugerinteraktion.
Token gemmes i .saxo_token.json (gitignored).
Brug:
python saxo_auth.py login # første gang — åbner browser
python saxo_auth.py refresh # forny access token
python saxo_auth.py token # print current access token
I kode:
from saxo_auth import get_token
token = get_token() # returnerer gyldigt token, refresher automatisk
"""
import os
import sys
import json
import time
import webbrowser
import urllib.parse
import http.server
import threading
import requests
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
APP_KEY = os.getenv("SAXO_APP_KEY", "")
APP_SECRET = os.getenv("SAXO_APP_SECRET_1", "")
AUTH_URL = os.getenv("SAXO_AUTH_URL", "https://sim.logonvalidation.net/authorize")
TOKEN_URL = os.getenv("SAXO_TOKEN_URL", "https://sim.logonvalidation.net/token")
REDIRECT = os.getenv("SAXO_REDIRECT", "http://localhost:8765/callback")
TOKEN_FILE = Path(__file__).parent / ".saxo_token.json"
REDIRECT_PORT = 8765
# ── Token storage ────────────────────────────────────────────────────────────
def _load() -> dict:
if TOKEN_FILE.exists():
return json.loads(TOKEN_FILE.read_text())
return {}
def _save(data: dict):
TOKEN_FILE.write_text(json.dumps(data, indent=2))
TOKEN_FILE.chmod(0o600) # only owner can read
def _is_expired(data: dict, margin_sec: int = 60) -> bool:
expires_at = data.get("expires_at", 0)
return time.time() >= (expires_at - margin_sec)
# ── OAuth2 flow ───────────────────────────────────────────────────────────────
def _exchange_code(code: str) -> dict:
"""Exchange auth code for access + refresh tokens."""
r = requests.post(TOKEN_URL, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT,
"client_id": APP_KEY,
"client_secret": APP_SECRET,
}, timeout=15)
r.raise_for_status()
data = r.json()
data["expires_at"] = time.time() + data.get("expires_in", 1200)
return data
def _refresh(refresh_token: str) -> dict:
"""Use refresh token to get a new access token."""
r = requests.post(TOKEN_URL, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": APP_KEY,
"client_secret": APP_SECRET,
}, timeout=15)
r.raise_for_status()
data = r.json()
data["expires_at"] = time.time() + data.get("expires_in", 1200)
return data
def _do_browser_login() -> str:
"""
Start local callback server, open browser for OAuth login.
Returns the authorization code.
"""
code_holder = {}
done = threading.Event()
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
code_holder["code"] = params.get("code", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(b"<h1>Login lykkedes!</h1><p>Du kan lukke dette vindue.</p>")
done.set()
def log_message(self, *args):
pass # suppress access log
server = http.server.HTTPServer(("localhost", REDIRECT_PORT), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
auth_params = {
"response_type": "code",
"client_id": APP_KEY,
"redirect_uri": REDIRECT,
"state": "moneymaker",
}
url = AUTH_URL + "?" + urllib.parse.urlencode(auth_params)
print(f"\n Åbner browser til Saxo login ...")
print(f" Hvis browseren ikke åbner, gå til:\n {url}\n")
webbrowser.open(url)
done.wait(timeout=120)
server.shutdown()
code = code_holder.get("code")
if not code:
raise RuntimeError("Login timeout — ingen callback modtaget inden 120 sekunder.")
return code
# ── Public API ────────────────────────────────────────────────────────────────
def login():
"""Full browser-based login. Call once to get refresh token."""
code = _do_browser_login()
data = _exchange_code(code)
_save(data)
print(" Login OK — token gemt i .saxo_token.json")
print(f" Access token udløber om {data.get('expires_in', '?')} sekunder")
rt = data.get("refresh_token")
if rt:
print(" Refresh token gemt — ingen manuel login fremover!")
else:
print(" ADVARSEL: Ingen refresh token modtaget.")
return data["access_token"]
def refresh():
"""Refresh access token using stored refresh token."""
data = _load()
rt = data.get("refresh_token")
if not rt:
raise RuntimeError("Ingen refresh token — kør: python saxo_auth.py login")
data = _refresh(rt)
_save(data)
print(" Token refreshet OK")
return data["access_token"]
def get_token() -> str:
"""
Return a valid access token. Auto-refreshes if expired.
Falls back to 24h SAXO_TOKEN env var if no stored token.
"""
data = _load()
if not data:
# Fall back to 24h token from .env
fallback = os.getenv("SAXO_TOKEN", "")
if fallback:
return fallback
raise RuntimeError("Ingen token — kør: python saxo_auth.py login")
if _is_expired(data):
rt = data.get("refresh_token")
if rt:
data = _refresh(rt)
_save(data)
else:
raise RuntimeError("Token udløbet og ingen refresh token — kør login igen.")
return data["access_token"]
# ── CLI ───────────────────────────────────────────────────────────────────────
def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else "help"
if cmd == "login":
login()
elif cmd == "refresh":
t = refresh()
print(f" Nyt token: {t[:20]}...")
elif cmd == "token":
t = get_token()
print(t)
elif cmd == "status":
data = _load()
if not data:
print("Ingen gemt token. Kør: python saxo_auth.py login")
return
exp = data.get("expires_at", 0)
remaining = int(exp - time.time())
print(f" Token status: {'GYLDIG' if remaining > 0 else 'UDLØBET'}")
print(f" Udløber om: {remaining} sekunder ({remaining//60} min)")
print(f" Refresh token: {'JA' if data.get('refresh_token') else 'NEJ'}")
else:
print("Brug:")
print(" python saxo_auth.py login # første gang — åbner browser")
print(" python saxo_auth.py refresh # forny token manuelt")
print(" python saxo_auth.py token # vis current token")
print(" python saxo_auth.py status # vis token status")
if __name__ == "__main__":
main()

322
saxo_broker.py Normal file
View File

@@ -0,0 +1,322 @@
"""
saxo_broker.py — Saxo Bank SIM paper trading integration.
Reads SAXO_TOKEN + SAXO_BASE from .env (or environment).
Token expires after 24h — refresh at developer.saxobank.com/openapi/token
"""
import os
import sys
import json
import time
import requests
from pathlib import Path
from datetime import datetime, timezone
from dotenv import load_dotenv
from db import get_conn
load_dotenv()
BASE = os.getenv("SAXO_BASE", "https://gateway.saxobank.com/sim/openapi")
def _get_token() -> str:
"""Get valid token — prefers OAuth2 stored token, falls back to 24h env token."""
try:
from saxo_auth import get_token
return get_token()
except Exception:
return os.getenv("SAXO_TOKEN", "")
# C25 tickers: our local ticker → (search keyword, Saxo ExchangeId)
# ExchangeId "CSE" = Copenhagen Stock Exchange (Saxo's code)
SAXO_SYMBOLS = {
"NOVO-B": ("Novo Nordisk B", "CSE"),
"DANSKE": ("Danske Bank", "CSE"),
"DSV": ("DSV", "CSE"),
"DEMANT": ("Demant", "CSE"),
"ORSTED": ("Orsted", "CSE"),
"ROCK-B": ("Rockwool", "CSE"),
"VWS": ("Vestas", "CSE"),
"COLOB": ("Coloplast", "CSE"),
"GMAB": ("Genmab", "CSE"),
"MAERSK-B": ("Maersk B", "CSE"),
"NZYM-B": ("Novozymes", "CSE"),
"NKT": ("NKT", "CSE"),
"GN": ("GN Store Nord", "CSE"),
"BAVAR": ("Bavarian Nordic", "CSE"),
"TRYG": ("Tryg", "CSE"),
"PNDORA": ("Pandora", "CSE"),
"CARL-B": ("Carlsberg", "CSE"),
"CHR": ("Chr. Hansen", "CSE"),
"FLS": ("FLSmidth", "CSE"),
"RBREW": ("Royal Unibrew", "CSE"),
"SYDB": ("Sydbank", "CSE"),
}
def _headers():
return {"Authorization": f"Bearer {_get_token()}", "Content-Type": "application/json"}
def _get(path, params=None):
r = requests.get(f"{BASE}{path}", headers=_headers(), params=params, timeout=10)
r.raise_for_status()
return r.json()
def _post(path, body):
r = requests.post(f"{BASE}{path}", headers=_headers(), json=body, timeout=10)
r.raise_for_status()
return r.json()
# ── Account info ────────────────────────────────────────────────────────────
def get_account_info():
"""Return (ClientKey, AccountKey) for the SIM account."""
data = _get("/port/v1/accounts/me")
accounts = data.get("Data", [])
if not accounts:
raise RuntimeError("No accounts found — are you logged into SIM?")
acct = accounts[0]
return acct["ClientKey"], acct["AccountKey"]
def get_balances():
"""Return cash balance dict from Saxo SIM."""
return _get("/port/v1/balances/me")
# ── Instrument lookup ───────────────────────────────────────────────────────
def find_uic(ticker: str) -> int | None:
"""Resolve our local ticker to Saxo UIC (instrument ID)."""
if ticker not in SAXO_SYMBOLS:
return None
symbol, exchange = SAXO_SYMBOLS[ticker]
results = _get("/ref/v1/instruments", params={
"Keywords": symbol,
"ExchangeId": exchange,
"AssetTypes": "Stock",
"$top": 5,
})
for item in results.get("Data", []):
if item.get("ExchangeId") == exchange:
return item["Identifier"]
return None
def get_price(uic: int) -> float | None:
"""
Get last traded price for a UIC.
NOTE: SIM 24h tokens have no market data access — returns None.
Use yfinance for prices instead (see signals.py).
"""
try:
data = _get("/trade/v1/infoprices", params={
"Uic": uic,
"AssetType": "Stock",
"FieldGroups": "Quote",
})
last = data.get("Quote", {}).get("Last")
return float(last) if last else None
except Exception:
return None
# ── Orders ──────────────────────────────────────────────────────────────────
def place_order(
ticker: str,
shares: int,
direction: str = "Buy",
*,
price_dkk: float | None = None,
signal_score: float | None = None,
analyst_rec: str | None = None,
note: str | None = None,
) -> dict:
"""
Place a market order on Saxo SIM.
Args:
ticker: Our C25 ticker (e.g. "NOVO-B")
shares: Number of shares
direction: "Buy" or "Sell"
price_dkk: Estimated fill price in DKK (for fee calc + logging)
signal_score: NLP signal score that triggered this order
analyst_rec: Analyst recommendation label
note: Free-text note
Returns:
Saxo order response dict
"""
_, acct_key = get_account_info()
uic = find_uic(ticker)
if uic is None:
raise ValueError(f"Unknown ticker: {ticker}")
body = {
"Uic": uic,
"AssetType": "Stock",
"Amount": shares,
"BuySell": direction,
"OrderType": "Market",
"OrderDuration": {"DurationType": "DayOrder"},
"AccountKey": acct_key,
"ManualOrder": True,
}
resp = _post("/trade/v2/orders", body)
# Log to saxo_orders table regardless of outcome
saxo_order_id = resp.get("OrderId") or resp.get("Orders", [{}])[0].get("OrderId")
status = "filled" if saxo_order_id else "rejected"
log_order(
ticker=ticker,
direction=direction.lower(),
shares=shares,
price_dkk=price_dkk,
saxo_order_id=str(saxo_order_id) if saxo_order_id else None,
status=status,
signal_score=signal_score,
analyst_rec=analyst_rec,
note=note,
)
return resp
def log_order(
ticker: str,
direction: str,
shares: float,
price_dkk: float | None,
saxo_order_id: str | None = None,
status: str = "pending",
signal_score: float | None = None,
analyst_rec: str | None = None,
note: str | None = None,
) -> None:
"""
Write a trade record to saxo_orders table.
Fee model: max(25 DKK, total_dkk * 0.001)
"""
total_dkk = (shares * price_dkk) if price_dkk else None
fee_dkk = round(max(25.0, total_dkk * 0.001), 2) if total_dkk else None
now_ts = datetime.now(timezone.utc).isoformat()
db = get_conn()
db.execute(
"""INSERT INTO saxo_orders
(ticker, direction, shares, price_dkk, total_dkk, fee_dkk,
saxo_order_id, status, signal_score, analyst_rec, placed_at, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
(ticker, direction, shares, price_dkk, total_dkk, fee_dkk,
saxo_order_id, status, signal_score, analyst_rec, now_ts, note),
)
db.commit()
db.close()
# ── Positions ───────────────────────────────────────────────────────────────
def get_positions() -> list[dict]:
"""Return open positions from Saxo SIM."""
data = _get("/port/v1/positions/me", params={"FieldGroups": "PositionBase,PositionView"})
return data.get("Data", [])
def print_positions():
"""Pretty-print open positions."""
positions = get_positions()
if not positions:
print("Ingen åbne positioner i Saxo SIM")
return
print(f"\n{'Ticker':<12} {'Antal':>6} {'Åbn kurs':>10} {'Akt kurs':>10} {'P/L':>10}")
print("-" * 52)
for p in positions:
base = p.get("PositionBase", {})
view = p.get("PositionView", {})
symbol = base.get("Symbol", "?")
amount = base.get("Amount", 0)
open_price = base.get("OpenPrice", 0)
current = view.get("CurrentPrice", 0)
pl = view.get("ProfitLossOnTrade", 0)
pl_str = f"+{pl:.0f}" if pl >= 0 else f"{pl:.0f}"
print(f"{symbol:<12} {amount:>6} {open_price:>10.2f} {current:>10.2f} {pl_str:>10}")
def print_balances():
"""Pretty-print account balances."""
b = get_balances()
cash = b.get("CashBalance", 0)
equity = b.get("TotalValue", 0)
margin = b.get("MarginAvailableForTrading", 0)
currency = b.get("Currency", "EUR")
print(f"\n Kontant saldo: {cash:>12,.0f} {currency}")
print(f" Total portefølje: {equity:>12,.0f} {currency}")
print(f" Fri margin: {margin:>12,.0f} {currency}")
# ── CLI ──────────────────────────────────────────────────────────────────────
def main():
try:
_get_token()
except Exception as e:
print(f"FEJL: {e}")
print("Kør: python saxo_auth.py login")
sys.exit(1)
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd == "status":
print("\n=== SAXO SIM KONTO ===")
client_key, acct_key = get_account_info()
print(f" ClientKey: {client_key}")
print(f" AccountKey: {acct_key}")
print_balances()
print_positions()
elif cmd == "buy":
# saxo_broker.py buy NOVO-B 5
if len(sys.argv) < 4:
print("Brug: saxo_broker.py buy TICKER ANTAL")
sys.exit(1)
ticker = sys.argv[2].upper()
shares = int(sys.argv[3])
print(f"Placerer KØB {shares} × {ticker} på Saxo SIM ...")
result = place_order(ticker, shares, "Buy")
print(f"Ordre bekræftet: {json.dumps(result, indent=2)}")
elif cmd == "sell":
# saxo_broker.py sell NOVO-B 5
if len(sys.argv) < 4:
print("Brug: saxo_broker.py sell TICKER ANTAL")
sys.exit(1)
ticker = sys.argv[2].upper()
shares = int(sys.argv[3])
print(f"Placerer SÆLG {shares} × {ticker} på Saxo SIM ...")
result = place_order(ticker, shares, "Sell")
print(f"Ordre bekræftet: {json.dumps(result, indent=2)}")
elif cmd == "positions":
print_positions()
elif cmd == "balances":
print_balances()
elif cmd == "uic":
# saxo_broker.py uic NOVO-B
ticker = sys.argv[2].upper() if len(sys.argv) > 2 else "NOVO-B"
uic = find_uic(ticker)
price = get_price(uic) if uic else None
print(f"{ticker}: UIC={uic}, pris={price}")
else:
print(f"Ukendt kommando: {cmd}")
print("Kommandoer: status | buy TICKER N | sell TICKER N | positions | balances | uic TICKER")
sys.exit(1)
if __name__ == "__main__":
main()

354
signals.py Normal file
View File

@@ -0,0 +1,354 @@
"""
signals.py — C25 Signal Board & Reporting
Usage:
python3 signals.py # full signal board (last 7 days)
python3 signals.py top [--days 14] # top companies by mentions
python3 signals.py company NOVO-B # detail view for one company
python3 signals.py summary # quick one-liner per company
python3 signals.py buy # only budget/accessible + leveraged ETPs
"""
import sys
import json
import time
import argparse
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
from db import get_conn, DBConn
C25_PATH = Path(__file__).parent / "c25.json"
_c25_raw = json.loads(C25_PATH.read_text())
C25: dict[str, dict] = {k: v for k, v in _c25_raw.items() if not k.startswith("_")}
_analyst_cache: dict[str, dict] = {}
def get_db() -> DBConn:
"""Return a DBConn wrapper. Schema is managed by db.py."""
return get_conn()
def analyst_rec(ticker: str) -> dict:
"""Hent analytikernes konsensus fra Yahoo Finance (cachet pr. kørsel)."""
if ticker in _analyst_cache:
return _analyst_cache[ticker]
yf_ticker = C25.get(ticker, {}).get("ticker_yahoo", ticker + ".CO")
try:
info = yf.Ticker(yf_ticker).info
mean = info.get("recommendationMean")
count = info.get("numberOfAnalystOpinions", 0)
if mean is None:
result = {"label": "Ukendt", "mean": None, "count": 0}
elif mean <= 1.5: result = {"label": "STÆRK KØB 🟢", "mean": mean, "count": count}
elif mean <= 2.5: result = {"label": "KØB 🟢", "mean": mean, "count": count}
elif mean <= 3.5: result = {"label": "HOLD 🟡", "mean": mean, "count": count}
elif mean <= 4.5: result = {"label": "SÆLG 🔴", "mean": mean, "count": count}
else: result = {"label": "STÆRK SÆLG 🔴", "mean": mean, "count": count}
except Exception:
result = {"label": "Ukendt", "mean": None, "count": 0}
_analyst_cache[ticker] = result
return result
SENTIMENT_DA = {"positive": "POSITIV ↑", "negative": "NEGATIV ↓", "neutral": "NEUTRAL →"}
TIER_DA = {
"budget": "under 200 kr — kan købes direkte",
"accessible": "200-500 kr — kan købes direkte",
"expensive": "500-2000 kr — overvej ETP",
"inaccessible": "over 2000 kr — brug ETP",
}
# ---------------------------------------------------------------------------
# Queries
# ---------------------------------------------------------------------------
def company_stats(db: DBConn, days: int = 7) -> list[dict]:
"""Return per-company aggregated signal stats for the last N days."""
cutoff = int(time.time()) - days * 86400
rows = db.execute(
"""SELECT
s.ticker,
COUNT(*) AS mention_articles,
SUM(s.mention_count) AS total_mentions,
AVG(CASE WHEN s.sentiment='positive' THEN s.sentiment_score
WHEN s.sentiment='negative' THEN -s.sentiment_score
ELSE 0 END) AS avg_sentiment,
SUM(CASE WHEN s.sentiment='positive' THEN 1 ELSE 0 END) AS pos_count,
SUM(CASE WHEN s.sentiment='negative' THEN 1 ELSE 0 END) AS neg_count,
SUM(CASE WHEN s.sentiment='neutral' THEN 1 ELSE 0 END) AS neu_count,
AVG(a.source_count) AS avg_sources,
MAX(COALESCE(s.signal_score, 0)) AS max_signal,
SUM(COALESCE(s.alert, 0)) AS alert_count
FROM article_signals s
JOIN articles a ON a.slug = s.article_slug
WHERE s.analyzed_at >= ?
GROUP BY s.ticker
ORDER BY max_signal DESC, mention_articles DESC""",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
def company_articles(db: DBConn, ticker: str, days: int = 14) -> list[dict]:
"""Return individual articles mentioning a company."""
cutoff = int(time.time()) - days * 86400
rows = db.execute(
"""SELECT
a.title, a.slug, a.start_date, a.source_count,
s.sentiment, s.sentiment_score, s.mention_count,
s.signal_score, s.alert, s.claude_reasoning,
s.momentum_dir, s.momentum_pct_5d, s.coverage_spread
FROM article_signals s
JOIN articles a ON a.slug = s.article_slug
WHERE s.ticker = ? AND s.analyzed_at >= ?
ORDER BY COALESCE(s.signal_score,0) DESC, a.source_count DESC""",
(ticker, cutoff),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Display helpers
# ---------------------------------------------------------------------------
def print_signal_board(days: int = 7) -> None:
db = get_db()
rows = company_stats(db, days)
db.close()
if not rows:
print(f"Ingen signaler de seneste {days} dage. Kør: make")
return
now_str = datetime.now(timezone.utc).strftime("%d %b %Y %H:%M")
W = 66
print(f"\n{''*W}")
print(f" MONEYMAKER · Hvad sker der med dine C25 aktier? · {now_str}")
print(f"{''*W}")
print(f" (baseret på de seneste {days} dage med nyheder)\n")
for r in rows:
ticker = r["ticker"]
company = C25.get(ticker, {})
name = company.get("name", ticker)
price = company.get("price_dkk_approx", "?")
tier = company.get("tier", "?")
etps = company.get("leveraged", [])
avg_s = r["avg_sentiment"] or 0
sent_da = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"),
"NEUTRAL →"
)
n_art = r["mention_articles"]
n_src = int(r["avg_sources"] or 0)
sig = r.get("max_signal") or 0
alerts = r.get("alert_count") or 0
# Analytiker konsensus
rec = analyst_rec(ticker)
# Er nyheder og analytikere enige?
news_bull = avg_s > 0.1
news_bear = avg_s < -0.1
ana_bull = rec["mean"] is not None and rec["mean"] <= 2.5
ana_bear = rec["mean"] is not None and rec["mean"] >= 3.5
if (news_bull and ana_bull) or (news_bear and ana_bear):
agreement = "✅ Nyheder og eksperter er ENIGE"
elif (news_bull and ana_bear) or (news_bear and ana_bull):
agreement = "⚠️ Nyheder og eksperter er UENIGE"
else:
agreement = "〰️ Intet klart signal endnu"
# Kildedækning advarsel
coverage_warn = " ⚠️ Kun få mediekilder — usikkert" if n_src < 3 else ""
# Signal i ord
if sig >= 0.5: sig_txt = f"🔥 STÆRKT ({sig:.2f})"
elif sig >= 0.2: sig_txt = f"📊 Moderat ({sig:.2f})"
elif sig > 0: sig_txt = f"🔍 Svagt ({sig:.2f})"
else: sig_txt = f"⬜ Ingen ({sig:.2f})"
# ETP info
etp_info = ""
if etps and len(etps) >= 2:
long_e = next((e for e in etps if e["direction"] == "long"), etps[0])
short_e = next((e for e in etps if e["direction"] == "short"), etps[1])
etp_info = f"\n │ ETP (gearing 3x): KØB={long_e['ticker']} SÆLG={short_e['ticker']} ({long_e['exchange']})"
alerts_str = f"\n │ ⚡ {alerts} alert(s) udløst!" if alerts else ""
print(f" ┌─ {name} ({ticker}) {''*max(1, W-6-len(name)-len(ticker))}")
print(f" │ Pris: ca. {price} kr · {TIER_DA.get(tier, tier)}")
print(f" │ Eksperter ({rec['count']:>2}): {rec['label']}")
print(f" │ Nyheder ({n_art} art): {sent_da} · gnsn. {n_src} mediekilder{coverage_warn}")
print(f" │ Samlet vurdering: {agreement}")
print(f" │ Signalstyrke: {sig_txt}{etp_info}{alerts_str}")
print(f"{''*W}")
print()
print(f" Forklaring:")
print(f" Eksperter = finansanalytikere der følger aktien (Yahoo Finance)")
print(f" Nyheder = hvad medierne skriver, analyseret med AI (FinBERT + Claude)")
print(f" Signal = kombineret score: nyhedssentiment × kildedækning × kursmomentum")
print(f" ETP = børshandlet certifikat — du køber 3x gearing uden at eje aktien")
print()
def print_company_detail(ticker: str, days: int = 14) -> None:
ticker = ticker.upper()
if ticker not in C25:
print(f"Ukendt ticker: {ticker}. Tilgængelige: {', '.join(sorted(C25.keys()))}")
return
db = get_db()
company = C25[ticker]
stats = next((r for r in company_stats(db, days) if r["ticker"] == ticker), None)
arts = company_articles(db, ticker, days)
db.close()
rec = analyst_rec(ticker)
price = company.get("price_dkk_approx", "?")
tier = company.get("tier", "?")
etps = company.get("leveraged", [])
W = 66
print(f"\n{''*W}")
print(f" {company['name']} ({ticker})")
print(f" Sektor: {company['sector']} · ca. {price} kr · {TIER_DA.get(tier, tier)}")
if etps:
for e in etps:
label = "KØB (long)" if e["direction"] == "long" else "SÆLG (short)"
print(f" ETP {label}: {e['name']} ({e['ticker']}) — {e['exchange']}")
print(f"{''*W}")
print(f"\n Hvad siger eksperterne?")
print(f" {rec['label']} · {rec['count']} analytikere følger aktien")
print(f"\n Hvad siger nyhederne de seneste {days} dage?")
if not stats:
print(f" Ingen nyheder fundet endnu.")
return
avg_s = stats["avg_sentiment"] or 0
sent_da = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"), ""
)
print(f" {sent_da} · {stats['mention_articles']} artikler")
print(f" Positiv: {stats['pos_count']} · Negativ: {stats['neg_count']} · Neutral: {stats['neu_count']}")
print(f"\n Enkeltartikler:")
print(f" {''*W}")
for a in arts[:15]:
sent_da2 = SENTIMENT_DA.get(a["sentiment"], "?")
sig = a.get("signal_score") or 0
n_src = a.get("source_count") or 0
low_cov = " ⚠️ få kilder" if n_src < 3 else ""
sig_txt = f"🔥{sig:.2f}" if sig >= 0.5 else (f"📊{sig:.2f}" if sig >= 0.2 else f"{sig:.2f}")
print(f"\n {a['title'][:62]}")
print(f" Sentiment: {sent_da2:<14} Signal: {sig_txt} Kilder: {n_src}{low_cov}")
reason = a.get("claude_reasoning", "")
if reason and reason not in ("(no API key)", ""):
print(f" Claude: {reason[:80]}")
def print_buy_signals(days: int = 7) -> None:
"""Vis kun aktier med positivt signal — direkte køb og via ETP."""
db = get_db()
rows = company_stats(db, days)
db.close()
W = 66
print(f"\n{''*W}")
print(f" HVAD KAN DET BETALE SIG? · de seneste {days} dage")
print(f"{''*W}\n")
direct = [r for r in rows if C25.get(r["ticker"], {}).get("tier") in ("budget", "accessible")]
via_etp = [r for r in rows if C25.get(r["ticker"], {}).get("leveraged")]
def fmt(r: dict, note: str = "") -> None:
ticker = r["ticker"]
c = C25.get(ticker, {})
avg_s = r["avg_sentiment"] or 0
sent = SENTIMENT_DA.get(
"positive" if avg_s > 0.1 else ("negative" if avg_s < -0.1 else "neutral"), "")
rec = analyst_rec(ticker)
price = c.get("price_dkk_approx", "?")
sig = r.get("max_signal") or 0
print(f" {c.get('name',''):<30} {price:>6} kr")
print(f" Eksperter: {rec['label']}")
print(f" Nyheder: {sent} (signal: {sig:.2f})")
if note:
print(f" {note}")
print()
if direct:
print(" Du kan købe disse DIREKTE (overkommelig pris):")
print(f" {''*W}")
for r in sorted(direct, key=lambda x: abs(x["avg_sentiment"] or 0), reverse=True):
fmt(r)
if via_etp:
print(" Via WisdomTree ETP (3x gearing — højere risiko/gevinst):")
print(f" {''*W}")
for r in sorted(via_etp, key=lambda x: abs(x["avg_sentiment"] or 0), reverse=True):
ticker = r["ticker"]
avg_s = r["avg_sentiment"] or 0
etps = C25.get(ticker, {}).get("leveraged", [])
if etps:
direction = "long" if avg_s >= 0 else "short"
etp = next((e for e in etps if e["direction"] == direction), etps[0])
fmt(r, note=f"ETP: {etp['ticker']} ({direction} 3x) på {etp['exchange']}")
else:
fmt(r)
if not direct and not via_etp:
print(" Ingen signaler endnu. Kør: make")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="C25 signal board")
sub = parser.add_subparsers(dest="cmd")
p_top = sub.add_parser("top", help="Top companies by mention count")
p_top.add_argument("--days", type=int, default=7)
p_co = sub.add_parser("company", help="Detail view for one company")
p_co.add_argument("ticker")
p_co.add_argument("--days", type=int, default=14)
sub.add_parser("summary", help="One-liner per company")
p_buy = sub.add_parser("buy", help="Affordable + leveraged ETP candidates")
p_buy.add_argument("--days", type=int, default=7)
p_board = sub.add_parser("board", help="Full signal board")
p_board.add_argument("--days", type=int, default=7)
args = parser.parse_args()
if args.cmd in (None, "board"):
days = getattr(args, "days", 7)
print_signal_board(days)
elif args.cmd == "top":
print_signal_board(args.days)
elif args.cmd == "company":
print_company_detail(args.ticker, getattr(args, "days", 14))
elif args.cmd == "buy":
print_buy_signals(args.days)
elif args.cmd == "summary":
print_signal_board(7)
if __name__ == "__main__":
main()