Skip to content

Commit ecb9009

Browse files
committed
Add static report archive page
1 parent a720632 commit ecb9009

5 files changed

Lines changed: 278 additions & 11 deletions

File tree

.github/workflows/publish_advisory_site.yml

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,65 @@ jobs:
138138
"${MARKET_ARGS[@]}" \
139139
--output-json "data/output/published/advisory_report_${AS_OF}.json" \
140140
--output-md "data/output/published/advisory_report_${AS_OF}.md"
141+
142+
REPORT_ARGS=("data/output/published/advisory_report_${AS_OF}.json")
143+
HISTORY_DIR="data/output/published/history"
144+
mkdir -p "${HISTORY_DIR}"
145+
HISTORY_INDEX="${HISTORY_DIR}/reports_index.json"
146+
if curl -fsSL "${SITE_URL%/}/reports_index.json" -o "${HISTORY_INDEX}"; then
147+
python - "${SITE_URL}" "${HISTORY_INDEX}" "${HISTORY_DIR}" "${AS_OF}" <<'PY'
148+
import json
149+
import re
150+
import sys
151+
import urllib.parse
152+
import urllib.request
153+
from pathlib import Path
154+
155+
site_url, index_path, history_dir, current_as_of = sys.argv[1:]
156+
history_dir_path = Path(history_dir)
157+
index = json.loads(Path(index_path).read_text(encoding="utf-8"))
158+
paths = []
159+
seen_as_of = {current_as_of}
160+
for item in index.get("reports", []):
161+
as_of = str(item.get("as_of", "")).strip()
162+
json_name = Path(str(item.get("json", ""))).name
163+
if not as_of or as_of in seen_as_of:
164+
continue
165+
if not re.fullmatch(r"advisory_report_\d{4}-\d{2}-\d{2}\.json", json_name):
166+
continue
167+
seen_as_of.add(as_of)
168+
url = f"{site_url.rstrip('/')}/{urllib.parse.quote(json_name)}"
169+
destination = history_dir_path / json_name
170+
try:
171+
urllib.request.urlretrieve(url, destination)
172+
payload = json.loads(destination.read_text(encoding="utf-8"))
173+
except Exception as exc: # noqa: BLE001 - best-effort public archive recovery
174+
print(f"Skipped historical report {json_name}: {exc}")
175+
continue
176+
if str(payload.get("as_of", "")) != as_of:
177+
print(f"Skipped historical report {json_name}: as_of mismatch")
178+
continue
179+
paths.append(str(destination))
180+
181+
(history_dir_path / "report_paths.txt").write_text("\n".join(paths), encoding="utf-8")
182+
print(f"Recovered {len(paths)} historical reports from published site.")
183+
PY
184+
if [ -s "${HISTORY_DIR}/report_paths.txt" ]; then
185+
mapfile -t HISTORY_REPORTS < "${HISTORY_DIR}/report_paths.txt"
186+
REPORT_ARGS+=("${HISTORY_REPORTS[@]}")
187+
fi
188+
else
189+
echo "No existing reports_index.json found on published site; starting archive with current report."
190+
fi
191+
141192
python scripts/publish_advisory_site.py \
142-
--reports "data/output/published/advisory_report_${AS_OF}.json" \
193+
--reports "${REPORT_ARGS[@]}" \
143194
--output-dir site \
144195
--site-url "${SITE_URL}" \
145196
--feed-title "智慧投顾研究系统"
146-
cp "data/output/published/advisory_report_${AS_OF}.json" site/
197+
for REPORT_JSON in "${REPORT_ARGS[@]}"; do
198+
cp "${REPORT_JSON}" site/
199+
done
147200
cp "data/output/published/advisory_report_${AS_OF}.md" site/
148201
cp "data/output/published/advisory_report_${AS_OF}.json.manifest.json" site/
149202
echo "as_of=${AS_OF}" >> "$GITHUB_OUTPUT"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ python scripts/publish_advisory_site.py \
147147
--site-url https://quantstrategylab.github.io/QuantAdvisorResearch
148148
```
149149

150-
Open `site/index.html` locally or subscribe to `site/feed.xml`. The workflow
150+
Open `site/index.html` locally or subscribe to `site/feed.xml`. The static site also writes `site/archive.html` and `site/reports_index.json`: the homepage keeps the latest report plus the most recent 12 historical entries, while the archive page keeps all generated reports grouped by month. The RSS feed keeps the most recent 20 items. The workflow
151151
`.github/workflows/publish_advisory_site.yml` deploys the same output to
152152
GitHub Pages:
153153

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ python scripts/publish_advisory_site.py \
160160
--site-url https://quantstrategylab.github.io/QuantAdvisorResearch
161161
```
162162

163-
本地打开 `site/index.html`,RSS 文件是 `site/feed.xml`
163+
本地打开 `site/index.html`,RSS 文件是 `site/feed.xml`静态站点同时生成 `site/archive.html``site/reports_index.json`:首页只保留最新报告和最近 12 期历史报告,完整历史按月份保留在归档页;RSS 默认保留最近 20 条。
164164
`.github/workflows/publish_advisory_site.yml` 会部署到 GitHub Pages:
165165
<https://quantstrategylab.github.io/QuantAdvisorResearch/>
166166

src/quant_advisor_research/publisher.py

Lines changed: 171 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
("medium", "中线", "2-12周"),
2525
("short", "短线", "1-10个交易日"),
2626
)
27+
INDEX_HISTORY_LIMIT = 12
28+
RSS_ITEM_LIMIT = 20
2729

2830
SITE_ICON_FILENAME = "favicon.svg"
2931
SITE_ICON_SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
@@ -562,6 +564,7 @@ def render_report_html(report: dict[str, Any]) -> str:
562564
</a>
563565
<div class="topbar-actions">
564566
<a href="index.html">返回首页</a>
567+
<a href="archive.html">历史归档</a>
565568
<a href="feed.xml">RSS 订阅</a>
566569
</div>
567570
</nav>
@@ -661,7 +664,8 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
661664
</section>
662665
"""
663666
items = []
664-
for report in sorted_reports:
667+
recent_reports = sorted_reports[1 : INDEX_HISTORY_LIMIT + 1]
668+
for report in recent_reports:
665669
filename = report_filename(report)
666670
top_themes = format_theme_ids(report["summary"].get("top_theme_ids", []))
667671
top_symbols = [str(symbol) for symbol in report["summary"].get("top_recommended_symbols", [])]
@@ -675,7 +679,7 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
675679
</article>
676680
"""
677681
)
678-
archive = "".join(items) or '<p class="empty-archive">暂无报告。</p>'
682+
archive = "".join(items) or '<p class="empty-archive">暂无更多历史报告。</p>'
679683
return f"""<!doctype html>
680684
<html lang="zh-CN">
681685
<head>
@@ -734,7 +738,8 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
734738
h1 {{ margin: 0; max-width: 780px; font-family: var(--font-display); font-size: clamp(2.15rem, 5vw, 4.05rem); font-weight: 700; line-height: 1.06; letter-spacing: -.045em; }}
735739
.hero p {{ margin: 16px 0 0; max-width: 680px; color: var(--muted); font-size: 1rem; line-height: 1.7; }}
736740
.rss-card {{ justify-self: end; min-width: 170px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,.66); box-shadow: 0 18px 45px rgba(31,45,61,.08); }}
737-
.rss-card a {{ color: var(--ink); text-decoration: none; font-weight: 800; }}
741+
.rss-card a {{ display: block; color: var(--ink); text-decoration: none; font-weight: 800; }}
742+
.rss-card a + a {{ margin-top: 8px; }}
738743
.rss-card span {{ display: block; color: var(--muted); margin-top: 5px; font-size: .88rem; }}
739744
.latest-panel {{ position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(360px, .95fr); gap: 22px; border: 1px solid rgba(30,77,216,.16); border-radius: 30px; padding: 26px; background: linear-gradient(135deg, rgba(255,255,255,.9), rgba(255,250,240,.76)); box-shadow: 0 26px 70px rgba(31,45,61,.13); }}
740745
.latest-panel::after {{ content: ""; position: absolute; right: -80px; top: -110px; width: 260px; height: 260px; border-radius: 999px; background: rgba(0,166,200,.16); filter: blur(2px); }}
@@ -760,6 +765,7 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
760765
.section-title {{ display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 34px 0 14px; }}
761766
.section-title h2 {{ font-size: 1.28rem; }}
762767
.section-title p {{ margin: 0; color: var(--muted); }}
768+
.archive-action {{ color: var(--ink); font-weight: 850; text-decoration: none; white-space: nowrap; }}
763769
.archive-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }}
764770
.archive-card {{ border: 1px solid var(--line); border-radius: 24px; padding: 18px; background: var(--panel); box-shadow: 0 12px 34px rgba(31,45,61,.08); }}
765771
.archive-title {{ color: var(--ink); font-size: 1rem; font-weight: 850; text-decoration: none; }}
@@ -791,15 +797,19 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
791797
<p>把主题动量、市场确认和政策/新闻证据整理成普通投资者能读懂的研究结论。页面只展示推荐、周期、背景、理由和风险。</p>
792798
</div>
793799
<aside class="rss-card">
800+
<a href="archive.html">历史归档</a>
794801
<a href="feed.xml">RSS 订阅</a>
795802
<span>周度更新 · 静态页面</span>
796803
</aside>
797804
</section>
798805
{latest_block}
799806
<section class="archive">
800807
<div class="section-title">
801-
<h2>历史报告</h2>
802-
<p>按发布日期倒序</p>
808+
<div>
809+
<h2>近期历史报告</h2>
810+
<p>首页最多显示最近 {INDEX_HISTORY_LIMIT} 期,完整记录保留在归档页</p>
811+
</div>
812+
<a class="archive-action" href="archive.html">查看全部</a>
803813
</div>
804814
<div class="archive-grid">{archive}</div>
805815
</section>
@@ -809,12 +819,161 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
809819
"""
810820

811821

822+
def render_archive_card(report: dict[str, Any]) -> str:
823+
filename = report_filename(report)
824+
top_themes = format_theme_ids(report["summary"].get("top_theme_ids", []))
825+
top_symbols = [str(symbol) for symbol in report["summary"].get("top_recommended_symbols", [])]
826+
return f"""
827+
<article class="archive-card">
828+
<a class="archive-title" href="{html.escape(filename)}">{html.escape(report['as_of'])} {html.escape(cadence_label(report))}复盘</a>
829+
<p>主要信号:{html.escape(top_themes or '无')}</p>
830+
<div class="archive-symbols">{render_symbol_tags(top_symbols)}</div>
831+
{render_horizon_snapshot(report, linked=True)}
832+
</article>
833+
"""
834+
835+
836+
def render_archive_html(reports: list[dict[str, Any]]) -> str:
837+
sorted_reports = sorted(reports, key=lambda item: item["as_of"], reverse=True)
838+
groups: dict[str, list[dict[str, Any]]] = {}
839+
for report in sorted_reports:
840+
key = str(report.get("as_of", ""))[:7] or "unknown"
841+
groups.setdefault(key, []).append(report)
842+
843+
sections = []
844+
for key, group_reports in groups.items():
845+
year, _, month = key.partition("-")
846+
title = f"{year}{month} 月" if month else key
847+
cards = "".join(render_archive_card(report) for report in group_reports)
848+
sections.append(
849+
f"""
850+
<section class="month-group">
851+
<h2>{html.escape(title)}</h2>
852+
<div class="archive-grid">{cards}</div>
853+
</section>
854+
"""
855+
)
856+
archive = "".join(sections) or '<p class="empty-archive">暂无报告。</p>'
857+
return f"""<!doctype html>
858+
<html lang="zh-CN">
859+
<head>
860+
<meta charset="utf-8">
861+
<meta name="viewport" content="width=device-width, initial-scale=1">
862+
<title>历史归档 - 智慧投顾研究系统</title>
863+
<link rel="icon" type="image/svg+xml" href="{SITE_ICON_FILENAME}">
864+
<link rel="alternate" type="application/rss+xml" title="智慧投顾研究 RSS" href="feed.xml">
865+
<style>
866+
:root {{
867+
color-scheme: light;
868+
--ink: #172033;
869+
--muted: #667085;
870+
--line: #d9e2ef;
871+
--paper: #fffaf0;
872+
--panel: rgba(255, 255, 255, .78);
873+
--blue: #1e4dd8;
874+
--cyan: #00a6c8;
875+
--gold: #d99b2b;
876+
--green: #0f8b62;
877+
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Source Han Sans SC", ui-sans-serif, system-ui, sans-serif;
878+
--font-display: "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", "STSong", "SimSun", ui-serif, serif;
879+
font-family: var(--font-sans);
880+
}}
881+
* {{ box-sizing: border-box; }}
882+
body {{
883+
margin: 0;
884+
color: var(--ink);
885+
background:
886+
radial-gradient(circle at 12% 8%, rgba(0,166,200,.18), transparent 28rem),
887+
radial-gradient(circle at 88% 0%, rgba(217,155,43,.18), transparent 24rem),
888+
linear-gradient(135deg, #f8fafc 0%, #eef4fb 52%, #fff7e6 100%);
889+
min-height: 100vh;
890+
}}
891+
main {{ max-width: 1180px; margin: 0 auto; padding: 34px 20px 64px; }}
892+
.topbar {{ display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 28px; }}
893+
.brand-link {{ display: inline-flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; font-size: .98rem; font-weight: 800; }}
894+
.brand-link .site-mark {{ width: 38px; height: 38px; margin: 0; }}
895+
.topbar-actions {{ display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }}
896+
.topbar-actions a {{ min-height: 38px; display: inline-flex; align-items: center; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; background: rgba(255,255,255,.72); color: var(--ink); text-decoration: none; font-size: .92rem; font-weight: 750; }}
897+
.site-mark {{ display: inline-flex; width: 64px; height: 64px; filter: drop-shadow(0 18px 28px rgba(30,77,216,.22)); }}
898+
.site-mark svg {{ width: 100%; height: 100%; }}
899+
.site-mark rect {{ fill: #172033; }}
900+
.site-mark .mark-line {{ fill: none; stroke: var(--cyan); stroke-width: 5.5; stroke-linecap: round; stroke-linejoin: round; }}
901+
.site-mark circle {{ fill: var(--paper); }}
902+
.site-mark .mark-ring {{ fill: none; stroke: var(--paper); stroke-width: 3.2; stroke-linecap: round; opacity: .88; }}
903+
.hero {{ padding: 22px 0 30px; }}
904+
.eyebrow {{ margin: 0 0 10px; color: var(--blue); font-size: .72rem; font-weight: 850; letter-spacing: .18em; text-transform: uppercase; }}
905+
h1 {{ margin: 0; font-family: var(--font-display); font-size: clamp(2.1rem, 4.4vw, 3.7rem); font-weight: 700; line-height: 1.08; letter-spacing: -.045em; }}
906+
.hero p {{ margin: 14px 0 0; max-width: 720px; color: var(--muted); line-height: 1.7; }}
907+
.month-group {{ margin-top: 30px; }}
908+
.month-group h2 {{ margin: 0 0 14px; font-size: 1.28rem; font-weight: 850; }}
909+
.archive-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }}
910+
.archive-card {{ border: 1px solid var(--line); border-radius: 24px; padding: 18px; background: var(--panel); box-shadow: 0 12px 34px rgba(31,45,61,.08); }}
911+
.archive-title {{ color: var(--ink); font-size: 1rem; font-weight: 850; text-decoration: none; }}
912+
.archive-card p {{ margin: 10px 0; color: var(--muted); line-height: 1.55; }}
913+
.archive-symbols {{ margin: 0 0 14px; }}
914+
.symbol-strip {{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }}
915+
.symbol-tag {{ display: inline-flex; align-items: center; min-height: 30px; padding: 6px 10px; border-radius: 999px; border: 1px solid rgba(30,77,216,.22); background: #fff; color: var(--ink); font-size: .94rem; font-weight: 800; box-shadow: 0 6px 16px rgba(31,45,61,.06); }}
916+
.symbol-tag.empty {{ color: var(--muted); font-weight: 700; }}
917+
.snapshot-grid {{ display: grid; grid-template-columns: 1fr; gap: 8px; }}
918+
.snapshot-column {{ padding: 12px; border: 1px solid var(--line); border-radius: 16px; background: rgba(255,255,255,.72); }}
919+
.snapshot-label {{ margin: 0; font-size: 1rem; font-weight: 850; }}
920+
.snapshot-window {{ margin: 4px 0 8px; color: var(--muted); font-size: .88rem; }}
921+
.snapshot-long {{ border-top: 4px solid var(--green); }}
922+
.snapshot-medium {{ border-top: 4px solid var(--blue); }}
923+
.snapshot-short {{ border-top: 4px solid var(--gold); }}
924+
.horizon-link {{ color: inherit; text-decoration: none; }}
925+
.empty-archive {{ color: var(--muted); }}
926+
@media (max-width: 720px) {{
927+
.topbar {{ align-items: flex-start; flex-direction: column; }}
928+
.topbar-actions {{ justify-content: flex-start; }}
929+
}}
930+
</style>
931+
</head>
932+
<body>
933+
<main>
934+
<nav class="topbar" aria-label="站点导航">
935+
<a class="brand-link" href="index.html">
936+
{render_site_mark()}
937+
<span>QuantStrategyLab</span>
938+
</a>
939+
<div class="topbar-actions">
940+
<a href="index.html">返回首页</a>
941+
<a href="feed.xml">RSS 订阅</a>
942+
</div>
943+
</nav>
944+
<section class="hero">
945+
<p class="eyebrow">Archive</p>
946+
<h1>历史归档</h1>
947+
<p>报告文件长期保留,便于复盘系统结论、观察风格漂移和检查不同阶段的主题变化。首页只展示最新和近期记录,这里按月份列出全部报告。</p>
948+
</section>
949+
{archive}
950+
</main>
951+
</body>
952+
</html>
953+
"""
954+
955+
956+
def render_reports_index_json(reports: list[dict[str, Any]]) -> str:
957+
items = []
958+
for report in sorted(reports, key=lambda item: item["as_of"], reverse=True):
959+
as_of = str(report.get("as_of", ""))
960+
items.append(
961+
{
962+
"as_of": as_of,
963+
"cadence": str(report.get("cadence", "")),
964+
"html": report_filename(report),
965+
"json": f"advisory_report_{as_of}.json",
966+
}
967+
)
968+
return json.dumps({"schema_version": 1, "reports": items}, ensure_ascii=False, indent=2) + "\n"
969+
970+
812971
def render_feed_xml(reports: list[dict[str, Any]], *, site_url: str, feed_title: str) -> str:
813972
channel = ET.Element("channel")
814973
ET.SubElement(channel, "title").text = feed_title
815974
ET.SubElement(channel, "link").text = site_url
816975
ET.SubElement(channel, "description").text = "QuantStrategyLab 智慧投顾研究系统,包含推荐理由、周期和风险提示。"
817-
for report in sorted(reports, key=lambda item: item["as_of"], reverse=True):
976+
for report in sorted(reports, key=lambda item: item["as_of"], reverse=True)[:RSS_ITEM_LIMIT]:
818977
filename = report_filename(report)
819978
link = f"{site_url.rstrip('/')}/{quote(filename)}"
820979
item = ET.SubElement(channel, "item")
@@ -848,6 +1007,12 @@ def publish_reports(report_paths: list[str | Path], output_dir: str | Path, *, s
8481007
index_path = output / "index.html"
8491008
index_path.write_text(render_index_html(reports), encoding="utf-8")
8501009
written.append(index_path)
1010+
archive_path = output / "archive.html"
1011+
archive_path.write_text(render_archive_html(reports), encoding="utf-8")
1012+
written.append(archive_path)
1013+
reports_index_path = output / "reports_index.json"
1014+
reports_index_path.write_text(render_reports_index_json(reports), encoding="utf-8")
1015+
written.append(reports_index_path)
8511016
feed_path = output / "feed.xml"
8521017
feed_path.write_text(render_feed_xml(reports, site_url=site_url, feed_title=feed_title), encoding="utf-8")
8531018
written.append(feed_path)

0 commit comments

Comments
 (0)