2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

新增 Apple 風格重點摘要卡與精簡撰稿規則 (#72) 在首頁與新聞內頁加入可辨識的重點摘要卡,縮短既有顯示摘要,並要求未來摘要以單句交代主體、地點、時間與實際影響。

SHA
68ba63c955bf358d5e9d0256ebf8c992f46c6390
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (9)

  • pipeline/build.py modified · +34 / −0
  • pipeline/write.py modified · +8 / −3
  • prompts/rare-aircraft-news-zh.txt modified · +5 / −1
  • static/site.css modified · +48 / −0
  • templates/_summary.html added · +13 / −0
  • templates/article.html modified · +2 / −0
  • templates/home.html modified · +3 / −2
  • tests/test_fulltext.py modified · +5 / −2
  • tests/test_mobile_layout.py modified · +14 / −0

完整差異

@@ -79,6 +79,7 @@ def static_asset_version() -> str:\n         "flash": "即時快訊", "sourceStatus": "來源狀態",\n         "latest": "最新新聞", "topStory": "頭條", "mainSource": "主要來源",\n         "briefLabel": "短訊",\n+        "summaryLabel": "重點摘要",\n         "readMore": "閱讀全文 →", "back": "← 返回首頁",\n         "heroImg": "[ 頭條照片 — 由來源圖庫自動帶入,灰階顯示 ]",\n         "today": "今日全球數據", "statFlights": "目前追蹤航班", "statDelay": "全球延誤率",\n@@ -182,6 +183,7 @@ def static_asset_version() -> str:\n         "flash": "Live flash", "sourceStatus": "Source status",\n         "latest": "Latest news", "topStory": "Top story", "mainSource": "Primary source",\n         "briefLabel": "Brief",\n+        "summaryLabel": "Quick Summary",\n         "readMore": "Read more →", "back": "← Back to front page",\n         "heroImg": "[ Lead photo — pulled from source library, grayscale ]",\n         "today": "Global today", "statFlights": "Flights tracked now", "statDelay": "Global delay rate",\n@@ -999,8 +1001,40 @@ def collect_articles():\n     return sorted(by_id.values(), key=lambda a: a["dt"], reverse=True)\n \n \n+def notification_summary(value: str, lang: str) -> str:\n+    """Make legacy article summaries glanceable without changing source data."""\n+    text = re.sub(r"\s+", " ", str(value or "")).strip()\n+    if not text:\n+        return text\n+    if lang == "zh":\n+        limit, minimum = 92, 44\n+        if len(text) <= limit:\n+            return text\n+        for marks in ("。!?", ";,"):\n+            breaks = [i + 1 for i, char in enumerate(text[:limit])\n+                      if char in marks and i + 1 >= minimum]\n+            if breaks:\n+                end = breaks[-1]\n+                result = text[:end].rstrip(",;")\n+                return result if result.endswith(tuple("。!?")) else result + "…"\n+        return text[:limit].rstrip(",;、 ") + "…"\n+\n+    words = text.split()\n+    limit_words, minimum_words = 42, 20\n+    if len(words) <= limit_words:\n+        return text\n+    sentence = re.match(r"^.*?[.!?](?=\s|$)", text)\n+    if sentence:\n+        first = sentence.group(0).strip()\n+        count = len(first.split())\n+        if minimum_words <= count <= limit_words:\n+            return first\n+    return " ".join(words[:limit_words]).rstrip(",;: ") + "…"\n+\n+\n def art_view(a, lang: str):\n     v = dict(a[lang])\n+    v["display_summary"] = notification_summary(v.get("summary"), lang)\n     v.update(\n         id=a["id"], cat=a["cat"], tag_class=a["tag_class"], image=a["image"],\n         cat_label=_bi(CATS.get(a["cat"]), lang, a["cat"]),\n@@ -419,9 +419,14 @@\n   If the event and useful background fit the brief contract, publish_brief;\n   never promote it to publish merely to make the article longer.\n - zh.title: 18 to 38 Chinese/alphanumeric characters excluding punctuation\n-  and spaces. zh.summary: 80 to 140 Chinese characters. en.title: 45 to 100\n-  characters including spaces.\n-  en.summary: 70-130 English words. Do not pad unsupported content.\n+  and spaces. zh.summary: one sentence of 45 to 85 Chinese characters.\n+  en.title: 45 to 100 characters including spaces. en.summary: one sentence\n+  of 25 to 45 English words. The summary is a notification-style glanceable\n+  brief: lead with the named operator/organisation and event, then include the\n+  supported place/route, timing and concrete operational, passenger, rescue or\n+  safety impact when relevant. Omit background history, editorial process and\n+  low-value detail. Never invent a missing airline, place, time or impact and\n+  never pad unsupported content.\n - Bodies are plain text with every load-bearing claim traceable to facts.\n   publish: 4 to 7 substantive paragraphs per language; zh at least 500 Chinese/alphanumeric content characters; en at least 250 words.\n   publish_brief: 2 to 7 substantive paragraphs per language; zh 180-320 Chinese/\n@@ -39,7 +39,11 @@ ABSOLUTE EVIDENCE RULES:\n \n OUTPUT (same schema and language rules as the main pipeline):\n - Traditional Chinese (Taiwan usage) zh + English en; zh.title 18-38 chars;\n-  zh.summary 80-140 chars; en.title 45-100 chars; en.summary 70-130 words;\n+  zh.summary is one 45-85 character sentence; en.title 45-100 chars;\n+  en.summary is one 25-45 word sentence. Make each summary immediately\n+  scannable: identify the operator, aircraft/event, supported location or\n+  route, timing and operational impact when available; omit low-value\n+  background and never invent a missing detail;\n   each language body MUST contain 4-7 substantive factual paragraphs built\n   only from quoted facts. When official operator/registry/route background is\n   present, devote at least one paragraph to aircraft/operator identity and one\n@@ -245,6 +245,51 @@ a.flash-row:hover { color: inherit; }\n .hero-title { margin: 8px 0 10px; font-size: 34px; line-height: 1.15; }\n .hero-meta { display: flex; gap: 16px; align-items: baseline; margin-top: 10px; font-size: 12px; }\n \n+/* Apple-style glanceable summary: the private iOS glyph is represented by\n+   an original two-lines-and-arrow SVG rather than copied platform artwork. */\n+.summary-preview {\n+  display: grid; grid-template-columns: auto minmax(0, 1fr);\n+  align-items: start; gap: 7px 11px; padding: 10px 12px;\n+  border: 1px solid color-mix(in srgb, #7c6be8 28%, var(--color-divider));\n+  border-radius: 15px;\n+  background: color-mix(in srgb, #7c6be8 7%, var(--color-surface));\n+  box-shadow: 0 2px 9px color-mix(in srgb, #5d4dcc 9%, transparent);\n+}\n+.summary-preview__label {\n+  display: inline-flex; align-items: center; gap: 5px; padding-top: 1px;\n+  color: color-mix(in srgb, #6d5bd0 84%, var(--color-text));\n+  font-size: 10.5px; font-weight: 800; line-height: 1.45;\n+  letter-spacing: .06em; white-space: nowrap;\n+}\n+.summary-preview__icon {\n+  width: 17px; height: 17px; fill: none; stroke: currentColor;\n+  stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;\n+}\n+.summary-preview__text {\n+  min-width: 0; color: color-mix(in srgb, var(--color-text) 82%, transparent);\n+  font-size: 13.5px; font-weight: 600; line-height: 1.58;\n+  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;\n+  overflow: hidden;\n+}\n+.summary-preview--hero { margin: 0; }\n+.summary-preview--feed {\n+  margin-top: 7px; padding: 7px 9px; border-radius: 12px;\n+  box-shadow: none;\n+}\n+.summary-preview--feed .summary-preview__label { font-size: 9.5px; }\n+.summary-preview--feed .summary-preview__icon { width: 15px; height: 15px; }\n+.summary-preview--feed .summary-preview__text { font-size: 12.5px; line-height: 1.55; }\n+.summary-preview--article { margin: -8px 0 24px; padding: 13px 15px; }\n+.summary-preview--article .summary-preview__text { font-size: 15px; line-height: 1.65; }\n+\n+[data-theme="dark"] .summary-preview {\n+  border-color: color-mix(in srgb, #a99cff 38%, var(--color-divider));\n+  background: color-mix(in srgb, #8171e8 11%, var(--color-surface));\n+}\n+[data-theme="dark"] .summary-preview__label {\n+  color: color-mix(in srgb, #b8adff 88%, var(--color-text));\n+}\n+\n /* — article — */\n .article-page { max-width: 860px; margin: 0 auto; padding: 28px 20px 64px; }\n .article-title { margin: 14px 0 12px; font-size: 38px; line-height: 1.18; }\n@@ -552,6 +597,9 @@ a.flash-row:hover { color: inherit; }\n   .article-title { font-size: 29px; line-height: 1.22; }\n   .article-meta { gap: 7px 12px; }\n   .article-meta > :last-child { margin-left: 0 !important; flex-basis: 100%; }\n+  .summary-preview { grid-template-columns: minmax(0, 1fr); gap: 5px; }\n+  .summary-preview--article { margin-top: -6px; padding: 11px 12px; }\n+  .summary-preview--article .summary-preview__text { font-size: 14px; }\n   .article-sources { padding: 14px 12px 10px; }\n   .article-source-row { flex-wrap: wrap; gap: 4px 8px; }\n   .footer-grid { padding: 20px 12px 28px; gap: 24px; }\n@@ -0,0 +1,13 @@\n+{% macro notification_summary(text, label, modifier='') -%}\n+<div class="summary-preview{% if modifier %} summary-preview--{{ modifier }}{% endif %}"\n+     role="note" aria-label="{{ label }}">\n+  <span class="summary-preview__label">\n+    <svg class="summary-preview__icon" viewBox="0 0 24 24"\n+         aria-hidden="true" focusable="false">\n+      <path d="M4 5.5h11M4 9.5h8M4 16h14M14.5 12.5 18 16l-3.5 3.5"/>\n+    </svg>\n+    <span>{{ label }}</span>\n+  </span>\n+  <span class="summary-preview__text">{{ text }}</span>\n+</div>\n+{%- endmacro %}\n@@ -1,4 +1,5 @@\n {% extends "base.html" %}\n+{% from "_summary.html" import notification_summary %}\n {% block content %}\n   <div class="article-page">\n     <a href="{{ home_url }}" class="btn btn-ghost" style="margin-bottom:18px;">{{ t.back }}</a>\n@@ -14,6 +15,7 @@ <h1 class="article-title">{{ a.title }}</h1>\n         <span style="margin-left:auto;">{{ t.refreshNote }}</span>\n       </div>\n     </div>\n+    {{ notification_summary(a.display_summary, t.summaryLabel, 'article') }}\n     {% if a.image %}\n     <figure style="margin:0 0 22px;">\n       <img src="{{ a.image.url }}" alt="{{ a.image.subject or a.title }}" loading="lazy" style="width:100%;height:auto;max-height:560px;object-fit:contain;background:color-mix(in srgb,var(--color-text) 6%,var(--color-surface));border:1px solid var(--color-divider);">\n@@ -1,4 +1,5 @@\n {% extends "base.html" %}\n+{% from "_summary.html" import notification_summary %}\n {% block content %}\n   <div class="home-grid">\n     <div class="rail-left">\n@@ -48,7 +49,7 @@ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n         <div style="margin-top:16px;">\n           <span class="card-kicker" style="font-size:11px;">{{ hero.cat_label }}{% if hero.article_format == "brief" %} · {{ t.briefLabel }}{% endif %} — {{ t.topStory }}</span>\n           <h2 class="hero-title">{{ hero.title }}</h2>\n-          <p class="text-muted" style="font-size:14.5px;line-height:1.75;margin:0;">{{ hero.summary }}</p>\n+          {{ notification_summary(hero.display_summary, t.summaryLabel, 'hero') }}\n           <div class="hero-meta">\n             <span style="font-weight:800;color:var(--color-accent);">{{ hero.time }}</span>\n             <span class="text-muted">{{ t.mainSource }}: {{ hero.source }}</span>\n@@ -80,7 +81,7 @@ <h6 style="margin:0;">{{ t.latest }}</h6>\n         <span class="feed-category tag {{ item.tag_class }}">{{ item.cat_label }}{% if item.article_format == "brief" %} · {{ t.briefLabel }}{% endif %}</span>\n         <span class="feed-copy">\n           <span style="display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;">{{ item.title }}</span>\n-          <span class="text-muted" style="display:block;font-size:12.5px;line-height:1.65;margin-top:3px;">{{ item.summary }}</span>\n+          {{ notification_summary(item.display_summary, t.summaryLabel, 'feed') }}\n         </span>\n         <span class="feed-source text-muted">{{ item.source }}</span>\n       </a>\n@@ -261,8 +261,11 @@ def group_with(urls):\n check("system prompt bans newsroom-process padding and favors briefs",\n       "never narrate the editorial process" in write.SYSTEM_PROMPT\n       and "never promote it to publish merely" in write.SYSTEM_PROMPT)\n-check("zh summary spec unchanged (80-140)",\n-      "80 to 140 Chinese characters" in write.SYSTEM_PROMPT)\n+check("notification-style summaries are short and information-dense",\n+      "one sentence of 45 to 85 Chinese characters" in write.SYSTEM_PROMPT\n+      and "one sentence\n  of 25 to 45 English words" in write.SYSTEM_PROMPT\n+      and "supported place/route, timing and concrete operational" in\n+      write.SYSTEM_PROMPT)\n \n print(f"\n{CHECKS} checks passed, {FAILED} failed"\n       if not FAILED else f"\n{CHECKS - FAILED}/{CHECKS} passed, "\n@@ -27,14 +27,28 @@ def main() -> None:\n     assert 'class="ticker-window"' in home\n     assert 'class="ticker-pass"' in home\n     assert 'class="feed-copy"' in home\n+    assert 'class="summary-preview summary-preview--hero"' in home\n+    assert 'class="summary-preview summary-preview--feed"' in home\n+    assert 'aria-label="重點摘要"' in home\n     assert 'class="article-page"' in article\n+    assert 'class="summary-preview summary-preview--article"' in article\n+    assert 'class="summary-preview__icon"' in article\n     assert 'class="article-source-row"' in article\n \n+    zh_long = "中華航空8月10日宣布調整桃園至福岡航班,受機場作業限制影響,兩班延後三小時,旅客須留意報到時間。" * 2\n+    zh_short = build.notification_summary(zh_long, "zh")\n+    assert len(zh_short) <= 93\n+    assert "中華航空" in zh_short and "福岡" in zh_short\n+    en_short = build.notification_summary("word " * 60, "en")\n+    assert len(en_short.split()) <= 42\n+\n     assert "@media (max-width: 720px)" in css\n     assert ".js .site-nav.is-open { display: grid; }" in css\n     assert "grid-template-columns: repeat(2, minmax(0, 1fr))" in css\n     assert ".center-col { order: 1;" in css\n     assert ".feed-copy { grid-column: 1 / -1; }" in css\n+    assert ".summary-preview__text" in css\n+    assert "-webkit-line-clamp: 2" in css\n     assert "--ticker-duration: 130s" in css\n     assert 'font-family: "Noto Sans TC", sans-serif' in css\n     assert "font-size: 13px; font-weight: 400" in css

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。