mirror of
https://github.com/0xMarcio/cve.git
synced 2026-10-27 16:51:03 +00:00
The two filter menus hid their state behind ALL and took two clicks per value. Each severity and source is now its own chip in the row, always visible: the first click narrows to that value, further clicks widen the set, and switching the last lit chip off clears. Active chips use the colours the result rows already use for the same words, and the selection rides in the URL as sev= and src=, so a narrowed view can be linked like a query. Standalone CVE pages carried a smaller search box beside the wordmark that read as another site's. They now open with the homepage bar itself, same height, type and placeholder, under the wordmark with the index count on the right. Enter still submits to /?q= because the pages are static, and the form has a real submit button for screen readers.
518 lines
20 KiB
Python
518 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Render one static page per CVE, at the site root.
|
|
|
|
The index held 82,000 CVEs behind a single URL. Every one of them is a query
|
|
somebody types ("CVE-2026-72898 poc"), and the pages that answered those queries
|
|
belonged to other people. These are flat files at /CVE-2026-72898, no prefix and
|
|
no extension: GitHub Pages resolves an extensionless request to the .html beside
|
|
it, verified against the live site rather than assumed.
|
|
|
|
Nothing here is committed. The payloads this reads are already gitignored and
|
|
built in CI, so the pages go straight into the Pages artifact and git never sees
|
|
them. Rendering all of them costs about fifteen seconds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
import os
|
|
import sys
|
|
from collections import defaultdict
|
|
from datetime import datetime, timezone
|
|
|
|
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir)
|
|
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
|
|
|
from brand import BRAND, FONTS, SITE, SOURCES_LINE
|
|
|
|
DOCS = os.path.join(ROOT, "docs")
|
|
RELATED = 6
|
|
GITHUB = "https://github.com/"
|
|
SEVERITIES = {"NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"}
|
|
LASTMOD = "page_lastmod.json"
|
|
|
|
SOURCES = (
|
|
("nuclei", "Nuclei templates", "projectdiscovery/nuclei-templates"),
|
|
("msf", "Metasploit modules", "rapid7/metasploit-framework"),
|
|
("edb", "ExploitDB entries", "exploit-db.com"),
|
|
("vulhub", "Vulhub environments", "vulhub/vulhub"),
|
|
("collections", "Exploit collections", ""),
|
|
)
|
|
|
|
|
|
def load(name: str) -> dict | list:
|
|
with open(os.path.join(DOCS, name), encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def esc(value) -> str:
|
|
return html.escape(str(value or ""), quote=True)
|
|
|
|
|
|
def json_ld(payload: dict) -> str:
|
|
"""Serialise for a <script> block, which JSON escaping alone does not cover.
|
|
|
|
json.dumps leaves < > and & raw, so a description carrying "</script>" ends
|
|
the element and everything after it becomes live markup. 78 descriptions in
|
|
the corpus already contain that sequence; only the 155-character trim keeps
|
|
them out of the payload today. Escaping to \\u00XX keeps the JSON identical
|
|
to a parser and inert to the HTML tokeniser.
|
|
"""
|
|
text = json.dumps(payload, separators=(",", ":"))
|
|
for char in ("<", ">", "&"):
|
|
text = text.replace(char, f"\\u{ord(char):04x}")
|
|
return text
|
|
|
|
|
|
def short(text: str, limit: int) -> str:
|
|
text = " ".join((text or "").split())
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[: limit - 1].rsplit(" ", 1)[0] + "…"
|
|
|
|
|
|
def canonical_cvss(rows: list) -> list | None:
|
|
"""The first assessment, which is NVD's own where NVD scored the record.
|
|
|
|
The search results take rows[0] and these pages took the highest score, so
|
|
18,434 CVEs showed one severity in the list and another on the page. The
|
|
primary assessment is the authority, and matching it makes the two agree.
|
|
"""
|
|
for row in rows or []:
|
|
if isinstance(row[1], (int, float)):
|
|
return row
|
|
return None
|
|
|
|
|
|
def unique_cvss(rows: list) -> list:
|
|
"""One row per distinct assessment.
|
|
|
|
NVD, a CNA and the GitHub Advisory Database frequently publish the same
|
|
vector, and the source column that told them apart was never rendered, so
|
|
13,340 pages printed the same line two or three times over.
|
|
"""
|
|
seen: set = set()
|
|
kept = []
|
|
for row in rows or []:
|
|
if not isinstance(row[1], (int, float)):
|
|
continue
|
|
key = (str(row[0]), row[1], str(row[3]))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
kept.append(row)
|
|
return kept
|
|
|
|
|
|
ASSESSORS = {
|
|
"[email protected]": "NVD",
|
|
"[email protected]": "MITRE",
|
|
"github advisory database": "GitHub",
|
|
}
|
|
|
|
|
|
def assessor(row: list) -> str:
|
|
"""Who scored it, from row[4].
|
|
|
|
row[5] is the assessment type, Primary or Secondary, and labelling with it
|
|
left 129 pages showing two rows both marked "Secondary" — the repetition
|
|
the label was added to explain. row[4] names the party, which is the thing
|
|
that actually differs. Scoring CNAs identify themselves by contact address
|
|
or by UUID, so an address becomes its domain and a UUID stays generic.
|
|
"""
|
|
raw = str(row[4]).strip() if len(row) > 4 and row[4] else ""
|
|
if not raw:
|
|
return str(row[5]) if len(row) > 5 and row[5] else ""
|
|
known = ASSESSORS.get(raw.lower())
|
|
if known:
|
|
return known
|
|
if "@" in raw:
|
|
domain = raw.rsplit("@", 1)[1].lower()
|
|
parts = [p for p in domain.split(".") if p not in ("com", "org", "net", "gov", "io")]
|
|
return parts[0].upper() if parts else domain
|
|
if re.fullmatch(r"[0-9a-f-]{32,36}", raw.lower()):
|
|
return "CNA"
|
|
return raw
|
|
|
|
|
|
def ordinal(value: int) -> str:
|
|
"""1st, 2nd, 3rd, 4th. A hard-coded "th" printed 51th on 21,100 pages."""
|
|
if 11 <= value % 100 <= 13:
|
|
return f"{value}th"
|
|
return f"{value}{ {1: 'st', 2: 'nd', 3: 'rd'}.get(value % 10, 'th') }"
|
|
|
|
|
|
def owner_of(url: str) -> str | None:
|
|
if not url.startswith(GITHUB):
|
|
return None
|
|
parts = url[len(GITHUB):].split("/")
|
|
return parts[0].lower() if parts and parts[0] else None
|
|
|
|
|
|
def repo_of(url: str) -> str | None:
|
|
"""owner/repo, the key repo_meta is stored under."""
|
|
if not url.startswith(GITHUB):
|
|
return None
|
|
parts = [p for p in url[len(GITHUB):].split("/") if p]
|
|
if len(parts) < 2:
|
|
return None
|
|
return f"{parts[0].lower()}/{re.sub(r'[.]git$', '', parts[1], flags=re.I).lower()}"
|
|
|
|
|
|
def page_lastmod(entry: dict, data: dict) -> str:
|
|
"""When this page last changed, not when the CVE record did.
|
|
|
|
A sitemap lastmod is meant to describe the page. Ours described the CVE
|
|
record, so a CVE that gained forty new proof-of-concept repositories this
|
|
week still advertised a lastmod from 2021 and crawlers had no reason to
|
|
come back. The linked repositories carry their own last-push date in
|
|
repo_meta, so the later of the two is what actually moved.
|
|
"""
|
|
stamps = [entry.get("modified") or "", entry.get("published") or ""]
|
|
for url in entry.get("poc") or []:
|
|
meta = data["repo_meta"].get(repo_of(url) or "")
|
|
if meta and len(meta) > 1 and isinstance(meta[1], str):
|
|
stamps.append(meta[1][:10])
|
|
return max((s for s in stamps if len(s) == 10), default=data["today"])
|
|
|
|
|
|
def build_related(cves: list) -> dict:
|
|
"""CVEs that share a proof-of-concept author.
|
|
|
|
A sitemap gets pages discovered; links get them crawled sooner and pass
|
|
context. That only holds when the link means something. The year-adjacent
|
|
fallback that used to fill this section had no relation behind it at all:
|
|
CVE-2024-1235 is not related to CVE-2024-1234, it was merely numbered next,
|
|
and 61,936 pages carried six such links apiece. A shared author is a real
|
|
relation, so it is the only one kept; pages without one show no section.
|
|
"""
|
|
by_owner = defaultdict(list)
|
|
for entry in cves:
|
|
for url in entry.get("poc") or []:
|
|
owner = owner_of(url)
|
|
if owner:
|
|
by_owner[owner].append(entry["cve"])
|
|
# An author with hundreds of PoCs relates nothing in particular.
|
|
by_owner = {o: v for o, v in by_owner.items() if 1 < len(v) <= 40}
|
|
|
|
related = {}
|
|
for entry in cves:
|
|
cid = entry["cve"]
|
|
seen = {cid}
|
|
picks = []
|
|
for url in entry.get("poc") or []:
|
|
owner = owner_of(url)
|
|
for other in by_owner.get(owner, ()):
|
|
if other not in seen:
|
|
seen.add(other)
|
|
picks.append(other)
|
|
if len(picks) >= RELATED:
|
|
break
|
|
if len(picks) >= RELATED:
|
|
break
|
|
if picks:
|
|
related[cid] = picks[:RELATED]
|
|
return related
|
|
|
|
|
|
def link_rows(urls: list, repo_meta: dict, *, trusted: bool = False) -> str:
|
|
"""One row per source link.
|
|
|
|
The repository list is crowd-sourced: anyone can publish a repository named
|
|
after a CVE and land in it, so those stay nofollow. Nuclei, ExploitDB,
|
|
Metasploit, Vulhub and the CVE and NVD records are curated destinations the
|
|
index deliberately vouches for, and Google's guidance is to leave a link
|
|
unqualified when the association is intended.
|
|
"""
|
|
rel = "noopener" if trusted else "nofollow noopener"
|
|
rows = []
|
|
for url in urls:
|
|
label = url[len(GITHUB):] if url.startswith(GITHUB) else url
|
|
meta = repo_meta.get(label.lower()) if url.startswith(GITHUB) else None
|
|
note = f"<span>{meta[0]}★ · {esc(meta[1])}</span>" if meta else ""
|
|
rows.append(
|
|
f'<li><a href="{esc(url)}" rel="{rel}" target="_blank">'
|
|
f"{esc(short(label, 90))}</a>{note}</li>"
|
|
)
|
|
return "".join(rows)
|
|
|
|
|
|
def header(count: int | None = None) -> str:
|
|
"""The bar every generated page opens with, so none of them can drift.
|
|
|
|
It is the homepage's own search bar, same height, type and placeholder,
|
|
under the wordmark. The pages are static, so enter submits to /?q= and the
|
|
homepage picks the query up from the URL.
|
|
"""
|
|
tally = f'<a class="cve-count" href="/">{count:,} CVEs with PoCs</a>' if count else ""
|
|
return f"""<header class="container cve-top"><a class="cve-home" href="/">{BRAND}</a>{tally}</header>
|
|
<div class="container search-bar"><form class="search" role="search" action="/" method="get" autocomplete="off">
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
<circle cx="7.6" cy="7.6" r="5.1" stroke="#8b949e" stroke-width="1.7"/>
|
|
<path d="M11.5 11.5 L15.6 15.6" stroke="#8b949e" stroke-width="1.7" stroke-linecap="round"/>
|
|
</svg>
|
|
<input type="text" name="q" spellcheck="false" placeholder="Search CVE, vendor, product or keyword · exclude with -windows" aria-label="Search CVE, vendor, product or keyword"/>
|
|
<span class="search-status">enter searches the index</span>
|
|
<button type="submit" class="sr-only">Search</button>
|
|
</form></div>"""
|
|
|
|
|
|
def footer(tail: str = '<a href="/">Back to the index</a>') -> str:
|
|
return (f'<footer class="site-footer"><div class="container">'
|
|
f"<span>{SOURCES_LINE}</span><span>{tail}</span></div></footer>")
|
|
|
|
|
|
def page(entry: dict, data: dict) -> str:
|
|
cid = entry["cve"]
|
|
desc = " ".join((entry.get("desc") or "").split())
|
|
pocs = entry.get("poc") or []
|
|
rows = (data["meta"].get(cid) or {}).get("cvss") or []
|
|
cvss = canonical_cvss(rows)
|
|
ep = data["epss"].get(cid)
|
|
flagged = data["kev"].get(cid)
|
|
tpl = data["nuclei"].get(cid)
|
|
|
|
# Every source the page goes on to list. Counting the repository column
|
|
# alone told 19,940 pages they held "0 public PoC exploits" directly above
|
|
# the ExploitDB or Metasploit entries they were listing, and put that same
|
|
# figure in the title and the meta description.
|
|
total = len(pocs) + sum(len(entry.get(key) or []) for key, _, _ in SOURCES)
|
|
count = f"{total:,} public PoC exploit" + ("" if total == 1 else "s")
|
|
title = f"{cid}: {count} | {BRAND}"
|
|
meta_desc = short(f"{cid}. {count}. {desc}", 155)
|
|
url = f"{SITE}/{cid}"
|
|
|
|
chips = []
|
|
if flagged:
|
|
ransom = " RANSOMWARE" if flagged[1] else ""
|
|
chips.append(f'<span class="chip chip-kev">KEV{ransom}</span>')
|
|
if cvss:
|
|
chips.append(
|
|
f'<span class="chip chip-sev is-{esc(str(cvss[2]).lower())}">'
|
|
f"{esc(cvss[2])} {esc(cvss[1])}</span>"
|
|
)
|
|
elif tpl and str(tpl.get("severity") or "").upper() in SEVERITIES:
|
|
# The same fallback entrySeverity applies in the browser. Without it a
|
|
# CVE rated only by a Nuclei template carried a severity chip in the
|
|
# search results and none at all on its own page.
|
|
score = tpl.get("cvss")
|
|
rating = f" {esc(score)}" if isinstance(score, (int, float)) else ""
|
|
chips.append(
|
|
f'<span class="chip chip-sev is-{esc(str(tpl["severity"]).lower())}">'
|
|
f'{esc(str(tpl["severity"]).upper())}{rating}</span>'
|
|
)
|
|
if ep:
|
|
hot = " is-hot" if ep[0] >= 0.1 else ""
|
|
chips.append(f'<span class="chip chip-epss{hot}">EPSS {ep[0] * 100:.1f}%</span>')
|
|
|
|
facts = []
|
|
for row in unique_cvss(rows):
|
|
# Who scored it, so two surviving rows read as two opinions rather than
|
|
# as the page repeating itself.
|
|
who = esc(assessor(row))
|
|
label = f"CVSS v{esc(row[0])}" + (f" <span>{who}</span>" if who else "")
|
|
facts.append(
|
|
f"<dt>{label}</dt><dd>{esc(row[1])} {esc(row[2])}"
|
|
f'<code>{esc(row[3])}</code></dd>'
|
|
)
|
|
if ep:
|
|
facts.append(
|
|
f"<dt>EPSS</dt><dd>{ep[0] * 100:.2f}% chance of exploitation in the next "
|
|
f"30 days, {ordinal(round(ep[1] * 100))} percentile</dd>"
|
|
)
|
|
if flagged:
|
|
used = ", used in ransomware campaigns" if flagged[1] else ""
|
|
facts.append(f"<dt>CISA KEV</dt><dd>added {esc(flagged[0])}{used}</dd>")
|
|
if tpl:
|
|
cwe = f" · {esc(tpl.get('cwe'))}" if tpl.get("cwe") else ""
|
|
facts.append(f"<dt>Nuclei</dt><dd>{esc(tpl.get('severity'))}{cwe}</dd>")
|
|
if entry.get("published"):
|
|
facts.append(f"<dt>Published</dt><dd>{esc(entry['published'])}</dd>")
|
|
if entry.get("modified"):
|
|
facts.append(f"<dt>Updated</dt><dd>{esc(entry['modified'])}</dd>")
|
|
|
|
blocks = []
|
|
if pocs:
|
|
blocks.append(
|
|
f"<h2>Proof-of-concept exploits ({len(pocs):,})</h2>"
|
|
f'<ul class="cve-links">{link_rows(pocs, data["repo_meta"])}</ul>'
|
|
)
|
|
for key, heading, _ in SOURCES:
|
|
urls = entry.get(key) or []
|
|
if urls:
|
|
blocks.append(
|
|
f"<h2>{heading} ({len(urls)})</h2>"
|
|
f'<ul class="cve-links">{link_rows(urls, data["repo_meta"], trusted=True)}</ul>'
|
|
)
|
|
|
|
siblings = "".join(
|
|
f'<li><a href="/{other}">{other}</a></li>' for other in data["related"].get(cid, ())
|
|
)
|
|
|
|
# WebPage, not TechArticle. TechArticle is valid schema.org but is not one
|
|
# of the types Google draws an Article rich result from, and claiming it
|
|
# bought nothing. The dates were the real problem: datePublished carried the
|
|
# date the CVE record was published, which is not when this page was written
|
|
# or last changed. The page is a listing about a vulnerability, so it says
|
|
# that, and dateModified is the page's own lastmod.
|
|
ld = json_ld(
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "WebPage",
|
|
"name": f"{cid} proof-of-concept exploits",
|
|
"url": url,
|
|
"description": meta_desc,
|
|
"dateModified": page_lastmod(entry, data),
|
|
"isPartOf": {"@type": "WebSite", "name": BRAND, "url": f"{SITE}/"},
|
|
"mainEntity": {
|
|
"@type": "Thing",
|
|
"name": cid,
|
|
"identifier": cid,
|
|
"description": short(desc, 600) or cid,
|
|
"sameAs": [
|
|
f"https://www.cve.org/CVERecord?id={cid}",
|
|
f"https://nvd.nist.gov/vuln/detail/{cid}",
|
|
],
|
|
},
|
|
}
|
|
)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{esc(title)}</title>
|
|
<meta name="description" content="{esc(meta_desc)}"/>
|
|
<meta name="robots" content="index, follow, max-image-preview:large"/>
|
|
<link rel="canonical" href="{url}"/>
|
|
<meta property="og:type" content="article"/>
|
|
<meta property="og:site_name" content="{BRAND}"/>
|
|
<meta property="og:title" content="{esc(title)}"/>
|
|
<meta property="og:description" content="{esc(meta_desc)}"/>
|
|
<meta property="og:url" content="{url}"/>
|
|
<meta property="og:image" content="{SITE}/social-card.png"/>
|
|
<meta property="og:image:width" content="1280"/>
|
|
<meta property="og:image:height" content="640"/>
|
|
<meta name="twitter:card" content="summary_large_image"/>
|
|
<meta name="twitter:title" content="{esc(title)}"/>
|
|
<meta name="twitter:description" content="{esc(meta_desc)}"/>
|
|
<meta name="twitter:image" content="{SITE}/social-card.png"/>
|
|
<link rel="icon" href="/favicon.ico"/>
|
|
{FONTS}
|
|
<link rel="stylesheet" href="/style.css"/>
|
|
<script type="application/ld+json">{ld}</script>
|
|
</head>
|
|
<body>
|
|
{header(len(data["cves"]))}
|
|
<main class="container cve-page">
|
|
<h1>{cid}</h1>
|
|
<div class="chips">{''.join(chips)}</div>
|
|
<p class="cve-desc">{esc(desc)}</p>
|
|
<dl class="cve-facts">{''.join(facts)}</dl>
|
|
{''.join(blocks)}
|
|
<h2>References</h2>
|
|
<ul class="cve-links">
|
|
<li><a href="https://www.cve.org/CVERecord?id={cid}" rel="noopener" target="_blank">CVE Record</a></li>
|
|
<li><a href="https://nvd.nist.gov/vuln/detail/{cid}" rel="noopener" target="_blank">NVD entry</a></li>
|
|
</ul>
|
|
{f'<h2>Related</h2><ul class="cve-related">{siblings}</ul>' if siblings else ''}
|
|
</main>
|
|
{footer(f'<a href="/">{len(data["cves"]):,} CVEs with PoCs</a>')}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def check_static_drift() -> None:
|
|
"""Fail the build if the hand-maintained homepage drifts from the constants.
|
|
|
|
index.html is written by hand and cannot be generated without losing what
|
|
is in it, so the next best thing is refusing to publish a homepage whose
|
|
fonts or source credit no longer match what every generated page states.
|
|
"""
|
|
with open(os.path.join(DOCS, "index.html"), encoding="utf-8") as handle:
|
|
home = handle.read()
|
|
problems = []
|
|
if "fonts.googleapis.com/css2?family=Space+Grotesk" not in home:
|
|
problems.append("index.html no longer loads the shared fonts")
|
|
if SOURCES_LINE not in home:
|
|
problems.append("index.html footer disagrees with brand.SOURCES_LINE")
|
|
if problems:
|
|
raise SystemExit("static drift: " + "; ".join(problems))
|
|
|
|
|
|
def not_found() -> str:
|
|
"""The 404, rendered from the same constants as every other page.
|
|
|
|
It used to be hand-written, and drifted: it referenced four CSS classes that
|
|
no longer existed, so it rendered unstyled, and it carried a meta refresh
|
|
that threw a reader out of a search two seconds after they arrived.
|
|
"""
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Page not found | {BRAND}</title>
|
|
<meta name="robots" content="noindex, follow"/>
|
|
<link rel="icon" href="/favicon.ico"/>
|
|
{FONTS}
|
|
<link rel="stylesheet" href="/style.css"/>
|
|
</head>
|
|
<body>
|
|
{header()}
|
|
<main class="container cve-page">
|
|
<h1>404</h1>
|
|
<p class="cve-desc">No page at this address. If you were after a CVE, search for it above, or
|
|
<a href="/">browse the whole index</a>.</p>
|
|
</main>
|
|
{footer()}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def main() -> int:
|
|
cves = load("CVE_list.json")
|
|
data = {
|
|
"cves": cves,
|
|
"meta": load("cve_metadata.json"),
|
|
"epss": load("epss.json"),
|
|
"kev": load("kev.json"),
|
|
"nuclei": load("nuclei.json"),
|
|
"repo_meta": load("repo_meta.json"),
|
|
"today": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
}
|
|
data["related"] = build_related(cves)
|
|
|
|
written = 0
|
|
lastmod = {}
|
|
for entry in cves:
|
|
cid = entry["cve"]
|
|
with open(os.path.join(DOCS, f"{cid}.html"), "w", encoding="utf-8") as handle:
|
|
handle.write(page(entry, data))
|
|
lastmod[cid] = page_lastmod(entry, data)
|
|
written += 1
|
|
|
|
# The sitemap has to quote the same date the page does, so it is computed
|
|
# once here and handed to build_seo.py rather than derived twice.
|
|
with open(os.path.join(DOCS, LASTMOD), "w", encoding="utf-8") as handle:
|
|
json.dump(lastmod, handle, separators=(",", ":"))
|
|
|
|
with open(os.path.join(DOCS, "404.html"), "w", encoding="utf-8") as handle:
|
|
handle.write(not_found())
|
|
|
|
check_static_drift()
|
|
|
|
related = sum(1 for cid in lastmod if data["related"].get(cid))
|
|
print(f"Wrote {written:,} CVE pages into {DOCS} "
|
|
f"({related:,} with a shared-author Related block)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|