Merge pull request #390 from nnneeeooo-nnneeeooo/feat/feature-category
feat:新增「專欄報導」分類(feature),A380稿改歸專欄
完整差異
diff --git a/data/articles/scheduled-airbus-a380-wing-crack-history-20261024.json b/data/articles/scheduled-airbus-a380-wing-crack-history-20261024.json\nindex 7f57466ef..dc518c2c2 100644\n--- a/data/articles/scheduled-airbus-a380-wing-crack-history-20261024.json\n+++ b/data/articles/scheduled-airbus-a380-wing-crack-history-20261024.json\n@@ -6,7 +6,7 @@\n "zh",\n "en"\n ],\n- "cat": "safety",\n+ "cat": "feature",\n "en": {\n "body": [\n "In 2012, Airbus discovered cracks in the wing rib feet of the A380. The rib feet are structural components inside the wing that connect the internal ribs to the wing skin panels. Nearly 80 A380s were in service at the time, and cracks were found on around 40 aircraft, roughly half the active fleet; according to BBC reports at the time, aircraft operated by Air France, Emirates, and Singapore Airlines were among those affected. The investigation linked the cracking to the combination of aluminum alloy rib feet and carbon-composite brackets, together with stresses introduced during manufacturing. The European Aviation Safety Agency (EASA) required inspections across the fleet and warned that the issue, if not detected and corrected, could potentially affect the structural integrity of the aircraft.",\ndiff --git a/pipeline/CONTRACTS.md b/pipeline/CONTRACTS.md\nindex 73e65eefb..23e4d1965 100644\n--- a/pipeline/CONTRACTS.md\n+++ b/pipeline/CONTRACTS.md\n@@ -139,7 +139,7 @@ absent when nothing new that hour)\n {\n "id": "a-20260726-0547-denver737", // a-YYYYMMDD-HHMM-<slug>\n "publishedUtc": "2026-07-26T05:47Z",\n- "cat": "safety", // safety | reg | biz | ops | mil\n+ "cat": "safety", // safety | reg | biz | ops | mil | feature\n "primarySource": "Reuters",\n "articleFormat": "full", // full | brief | roundup\n "image": null, // og:image URL or null\ndiff --git a/pipeline/build.py b/pipeline/build.py\nindex 3a57c8720..af9694a91 100644\n--- a/pipeline/build.py\n+++ b/pipeline/build.py\n@@ -205,7 +205,7 @@ L = {\n "footerSources": "資料來源 Data Sources",\n "footerChangelog": "網站更新紀錄",\n "nav": ["最新", "全部新聞", "快報", "航班雷達", "事故資料庫", "來源", "關於 SKYTICAL", "SKYTICAL團隊"],\n- "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n+ "cats": ["全部", "事故", "法規", "商業", "營運", "軍事", "專欄報導"],\n "searchKicker": "News Search", "searchTitle": "搜尋新聞",\n "newsKicker": "News Index", "newsTitle": "全部新聞",\n "newsSub": "依發布時間排列的本站航空新聞索引。",\n@@ -360,7 +360,7 @@ L = {\n "nav": ["Latest", "All news", "Briefings", "Flight Radar", "Incident DB", "Sources",\n "About SKYTICAL", "SKYTICAL Team"],\n "cats": ["All", "Safety", "Regulation", "Business", "Operations",\n- "Military"],\n+ "Military", "Feature"],\n "searchKicker": "News Search", "searchTitle": "Search news",\n "newsKicker": "News Index", "newsTitle": "All news",\n "newsSub": "An archive of SKYTICAL aviation news, ordered by publication time.",\n@@ -461,6 +461,7 @@ CATS = {\n "biz": {"zh": "商業", "en": "Business"},\n "ops": {"zh": "營運", "en": "Operations"},\n "mil": {"zh": "軍事", "en": "Military"},\n+ "feature": {"zh": "專欄報導", "en": "Feature"},\n }\n \n # Permanent, crawlable topic hubs. These turn the home-page filters into\n@@ -607,6 +608,7 @@ TAGCLS = {\n "biz": "tag-neutral",\n "ops": "tag-neutral",\n "mil": "tag-accent-2",\n+ "feature": "tag-accent-2",\n }\n \n \n@@ -4062,7 +4064,7 @@ def main() -> int:\n "tpe_hm": clock_12(tpe_now),\n "stamp": f"{now:%Y-%m-%d} {clock_12(now)}",\n }\n- cat_keys = ["all", "safety", "reg", "biz", "ops", "mil"]\n+ cat_keys = ["all", "safety", "reg", "biz", "ops", "mil", "feature"]\n sev_keys = ["week", "all", "acc", "ser", "inc"]\n pages = 0\n failed = 0\ndiff --git a/pipeline/common.py b/pipeline/common.py\nindex bebdad86f..5f070400d 100644\n--- a/pipeline/common.py\n+++ b/pipeline/common.py\n@@ -607,7 +607,7 @@ for _manufacturer_source in MANUFACTURER_SOURCE_CATALOG.get("feeds") or []:\n SOURCES[_manufacturer_key] = _manufacturer_source\n \n \n-CATEGORIES = ("safety", "reg", "biz", "ops", "mil")\n+CATEGORIES = ("safety", "reg", "biz", "ops", "mil", "feature")\n \n # Site-wide subject gate. A military label does not by itself make a story\n # relevant to an aviation publication: the material must still\ndiff --git a/pipeline/manual_draft.py b/pipeline/manual_draft.py\nindex b80a5ec58..d50b453b3 100644\n--- a/pipeline/manual_draft.py\n+++ b/pipeline/manual_draft.py\n@@ -89,6 +89,7 @@ ARTICLE_TYPES = {\n "fleet_order": "機隊/訂單",\n "regulation": "監管",\n "financial": "財報",\n+ "feature": "專欄報導",\n }\n LANGUAGES = {"zh": "繁中", "en": "英文", "bilingual": "雙語"}\n PUBLICATION_MODES = {"auto", "manual"}\n@@ -832,6 +833,7 @@ def _operator_draft(payload: dict, candidate: dict) -> dict:\n "regulation": "reg",\n "fleet_order": "biz",\n "financial": "biz",\n+ "feature": "feature",\n }.get(payload["articleType"], "ops")\n return {\n "status": "publish",\ndiff --git a/pipeline/scheduled_news.py b/pipeline/scheduled_news.py\nindex ee28746d5..b76152a0b 100644\n--- a/pipeline/scheduled_news.py\n+++ b/pipeline/scheduled_news.py\n@@ -56,7 +56,7 @@ def validate_article(row, now=None):\n raise ValueError("future timestamp")\n if source_time > publication or checked > publication + timedelta(minutes=5):\n raise ValueError("source/check time after publication")\n- if row.get("cat") not in {"safety", "reg", "biz", "ops", "mil"}:\n+ if row.get("cat") not in {"safety", "reg", "biz", "ops", "mil", "feature"}:\n raise ValueError("invalid category")\n if row.get("availableLanguages") != ["zh", "en"]:\n raise ValueError("both zh and en required")\ndiff --git a/pipeline/write.py b/pipeline/write.py\nindex ff42a9205..4426b4b22 100644\n--- a/pipeline/write.py\n+++ b/pipeline/write.py\n@@ -581,9 +581,13 @@ OUTPUT RULES:\n never present a secondary event as related to the lead event. Use\n publish_brief and keep the introduction plus event paragraphs within the\n brief length contract; omit unsupported filler.\n-- cat is exactly safety, reg, biz, ops or mil. Classify the main new\n- development expressed by the title and summary, not every secondary detail\n- present in the source. Military takes priority only when the main editorial\n+- cat is exactly safety, reg, biz, ops, mil or feature. Classify the main\n+ new development expressed by the title and summary, not every secondary\n+ detail present in the source. Use feature when the article's main purpose\n+ is to explain, introduce or look back at a topic (an aircraft, technology,\n+ history or how something works), even if it mentions accidents or\n+ incidents as background; use safety only when the main development is\n+ itself an accident or incident occurrence or its investigation. Military takes priority only when the main editorial\n focus is armed forces, military aircraft/vessels, exercises, defence\n ministries or military activity. A diversified manufacturer's consolidated\n earnings, commercial deliveries or company-wide financial results remain\ndiff --git a/static/manual.js b/static/manual.js\nindex 643c27a2d..d25925d98 100644\n--- a/static/manual.js\n+++ b/static/manual.js\n@@ -60,6 +60,7 @@\n fleet_order: "機隊/訂單",\n regulation: "監管",\n financial: "財報",\n+ feature: "專欄報導",\n };\n const TIME_SOURCE_LABELS = {\n manual: "手動指定(臺北時間)",\n@@ -770,6 +771,7 @@\n regulation: "reg",\n fleet_order: "biz",\n financial: "biz",\n+ feature: "feature",\n }[els["article-type"].value] || "ops";\n const attachments = imageUrls.map((url, index) => ({\n url,\ndiff --git a/templates/manual.html b/templates/manual.html\nindex 756d057d6..99d82187d 100644\n--- a/templates/manual.html\n+++ b/templates/manual.html\n@@ -129,6 +129,7 @@\n <option value="fleet_order">機隊/訂單</option>\n <option value="regulation">監管</option>\n <option value="financial">財報</option>\n+ <option value="feature">專欄報導</option>\n </select>\n </label>\n <label>產出語言\ndiff --git a/tests/test_categories.py b/tests/test_categories.py\nindex a50b4b5f5..70e09a873 100644\n--- a/tests/test_categories.py\n+++ b/tests/test_categories.py\n@@ -53,7 +53,8 @@ def valid_draft(category: str) -> dict:\n \n \n def main() -> None:\n- assert common.CATEGORIES == ("safety", "reg", "biz", "ops", "mil")\n+ assert common.CATEGORIES == ("safety", "reg", "biz", "ops", "mil",\n+ "feature")\n assert write.DRAFT_SCHEMA["properties"]["cat"]["enum"] \\n == list(common.CATEGORIES)\n assert write.validate_draft(valid_draft("mil")) is None\n@@ -64,8 +65,16 @@ def main() -> None:\n assert published["cat"] == "mil"\n assert build.CATS["mil"] == {"zh": "軍事", "en": "Military"}\n assert build.TAGCLS["mil"] == "tag-accent-2"\n- assert build.L["zh"]["cats"][-1] == "軍事"\n- assert build.L["en"]["cats"][-1] == "Military"\n+ assert build.L["zh"]["cats"][-2] == "軍事"\n+ assert build.L["en"]["cats"][-2] == "Military"\n+ assert build.CATS["feature"] == {"zh": "專欄報導", "en": "Feature"}\n+ assert "feature" in build.TAGCLS\n+ assert build.L["zh"]["cats"][-1] == "專欄報導"\n+ assert build.L["en"]["cats"][-1] == "Feature"\n+ assert write.validate_draft(valid_draft("feature")) is None\n+ feature = build.prep_article(\n+ article("A380機翼設計回顧", category="feature"))\n+ assert feature is not None and feature["cat"] == "feature"\n \n group = {\n "id": "g-military-test",