First commit
15
.env
Normal 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
@@ -0,0 +1 @@
|
|||||||
|
.saxo_token.json
|
||||||
68
Makefile
Normal 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)
|
||||||
BIN
__pycache__/analyze.cpython-314.pyc
Normal file
BIN
__pycache__/dashboard.cpython-314.pyc
Normal file
BIN
__pycache__/db.cpython-314.pyc
Normal file
BIN
__pycache__/ground_news.cpython-314.pyc
Normal file
BIN
__pycache__/portfolio.cpython-314.pyc
Normal file
BIN
__pycache__/report.cpython-314.pyc
Normal file
BIN
__pycache__/rss_feeds.cpython-314.pyc
Normal file
BIN
__pycache__/runner.cpython-314.pyc
Normal file
BIN
__pycache__/saxo_auth.cpython-314.pyc
Normal file
BIN
__pycache__/saxo_broker.cpython-314.pyc
Normal file
BIN
__pycache__/signals.cpython-314.pyc
Normal file
562
analyze.py
Normal 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 (0–1) 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: 1–10 (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
@@ -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
@@ -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 }} · Opdateret: {{ now }} · Refresh om {{ refresh }}s</div>
|
||||||
|
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Net P&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&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&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
@@ -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]")
|
||||||
BIN
ground-news-ext/extension.xpi
Normal file
BIN
ground-news-ext/src/128.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
ground-news-ext/src/16.png
Normal file
|
After Width: | Height: | Size: 305 B |
BIN
ground-news-ext/src/32.png
Normal file
|
After Width: | Height: | Size: 562 B |
BIN
ground-news-ext/src/48.png
Normal file
|
After Width: | Height: | Size: 796 B |
277
ground-news-ext/src/META-INF/cose.manifest
Normal 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=
|
||||||
|
|
||||||
BIN
ground-news-ext/src/META-INF/cose.sig
Normal file
287
ground-news-ext/src/META-INF/manifest.mf
Normal 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=
|
||||||
|
|
||||||
BIN
ground-news-ext/src/META-INF/mozilla.rsa
Normal file
4
ground-news-ext/src/META-INF/mozilla.sf
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Signature-Version: 1.0
|
||||||
|
SHA1-Digest-Manifest: aja76xPF0ppgA5xWeuljtiTIUOY=
|
||||||
|
SHA256-Digest-Manifest: fiRUfRWTKMY9g1E304egtSv6nkOpo7iqARA8//5xfeM=
|
||||||
|
|
||||||
BIN
ground-news-ext/src/fonts/UniversalSans-480.ttf
Normal file
BIN
ground-news-ext/src/fonts/UniversalSans-480.woff
Normal file
BIN
ground-news-ext/src/fonts/UniversalSans-680.ttf
Normal file
BIN
ground-news-ext/src/fonts/UniversalSans-680.woff
Normal file
BIN
ground-news-ext/src/fonts/UniversalSans-800.ttf
Normal file
BIN
ground-news-ext/src/fonts/UniversalSans-800.woff
Normal file
18
ground-news-ext/src/frame.html
Normal 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>
|
||||||
BIN
ground-news-ext/src/ground-symbol.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
17
ground-news-ext/src/ground-symbol.svg
Normal 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 |
BIN
ground-news-ext/src/icons/access-block.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
5
ground-news-ext/src/icons/bar-chart-2.svg
Normal 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 |
4
ground-news-ext/src/icons/book-open-1-black.svg
Normal 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 |
3
ground-news-ext/src/icons/bookmark-1-black.svg
Normal 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 |
3
ground-news-ext/src/icons/bookmark-1-white.svg
Normal 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 |
3
ground-news-ext/src/icons/bookmark-all-white.svg
Normal 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 |
5
ground-news-ext/src/icons/circle-dashed-black.svg
Normal 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 |
4
ground-news-ext/src/icons/close.svg
Normal 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 |
4
ground-news-ext/src/icons/copy.svg
Normal 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 |
1
ground-news-ext/src/icons/facebook.svg
Normal 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 |
17
ground-news-ext/src/icons/factuality-graph.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
5
ground-news-ext/src/icons/info.svg
Normal 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 |
1
ground-news-ext/src/icons/linkedin.svg
Normal 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 |
5
ground-news-ext/src/icons/lock.svg
Normal 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 |
1
ground-news-ext/src/icons/question-solid-white.svg
Normal 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 |
1
ground-news-ext/src/icons/question-solid.svg
Normal 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 |
1
ground-news-ext/src/icons/reddit.svg
Normal 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 |
1
ground-news-ext/src/icons/triangle-exclamation-solid.svg
Normal 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 |
1
ground-news-ext/src/icons/twitter.svg
Normal 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 |
1
ground-news-ext/src/js/background.js
Normal file
405
ground-news-ext/src/js/bluesky.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/bluesky.js
Normal file
405
ground-news-ext/src/js/content_script.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/content_script.js
Normal file
405
ground-news-ext/src/js/facebook.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/facebook.js
Normal file
1
ground-news-ext/src/js/ground.js
Normal file
0
ground-news-ext/src/js/index.js
Normal file
405
ground-news-ext/src/js/linkedin.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/linkedin.js
Normal file
2
ground-news-ext/src/js/menu.js
Normal file
28
ground-news-ext/src/js/menu.js.LICENSE.txt
Normal 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
|
||||||
0
ground-news-ext/src/js/options.js
Normal file
1
ground-news-ext/src/js/popup.js
Normal 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)}}})();
|
||||||
405
ground-news-ext/src/js/reddit.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/reddit.js
Normal file
405
ground-news-ext/src/js/twitter.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ground-news-ext/src/js/twitter.js
Normal file
153
ground-news-ext/src/manifest.json
Normal 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
|
||||||
|
}
|
||||||
750
ground-news-ext/src/menu.html
Normal 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>
|
||||||
27
ground-news-ext/src/options.html
Normal 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>
|
||||||
17
ground-news-ext/src/popup.html
Normal 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
559
ground_news.py
Normal 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
@@ -0,0 +1,35 @@
|
|||||||
|
nohup: ignorerer inddata
|
||||||
|
|
||||||
|
MoneyMaker Dashboard → http://localhost:5001
|
||||||
|
|
||||||
|
* Serving Flask app 'dashboard'
|
||||||
|
* Debug mode: off
|
||||||
|
[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||||
|
* Running on all addresses (0.0.0.0)
|
||||||
|
* Running on http://127.0.0.1:5001
|
||||||
|
* Running on http://192.168.15.40:5001
|
||||||
|
[33mPress CTRL+C to quit[0m
|
||||||
|
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] "[33mGET /favicon.ico HTTP/1.1[0m" 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] "[33mGET /favicon.ico HTTP/1.1[0m" 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
369
portfolio.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||