Merge site quality improvements
Merge the completed site quality fixes after resolving the latest main branch data conflicts.
Full diff
@@ -6,6 +6,99 @@\n "卡塔尼亞機場": "CTA",\n "Chicago O'Hare": "ORD",\n "O'Hare": "ORD",\n- "芝加哥奧黑爾": "ORD"\n+ "芝加哥奧黑爾": "ORD",\n+ "Philadelphia International Airport": "PHL",\n+ "Northeast Philadelphia Airport": "PNE",\n+ "Atlantic City Bader Field": "AIY",\n+ "Singapore Changi Airport": "SIN",\n+ "Changi Airport": "SIN",\n+ "Changi Airport, Terminal 2, Terminal 3": "SIN",\n+ "新加坡樟宜機場": "SIN",\n+ "樟宜機場": "SIN",\n+ "桃園國際機場": "TPE",\n+ "Taoyuan International Airport": "TPE",\n+ "東京成田機場": "NRT",\n+ "東京成田國際機場": "NRT",\n+ "關西機場": "KIX",\n+ "高雄機場": "KHH",\n+ "London Heathrow": "LHR",\n+ "Heathrow Airport": "LHR",\n+ "倫敦希斯洛機場": "LHR",\n+ "希斯洛機場": "LHR",\n+ "Los Angeles International Airport": "LAX",\n+ "Los Angeles International": "LAX",\n+ "洛杉磯國際機場": "LAX",\n+ "洛杉磯機場": "LAX",\n+ "Akron-Canton Airport": "CAK",\n+ "Dulles": "IAD",\n+ "Felipe Ángeles International Airport": "NLU",\n+ "Dubai International Airport": "DXB",\n+ "Dubai International": "DXB",\n+ "杜拜國際機場": "DXB",\n+ "Al Maktoum International Airport": "DWC",\n+ "Al Maktoum International": "DWC",\n+ "阿勒馬克圖姆國際機場": "DWC",\n+ "Driggs-Reed Memorial Airport": "DIJ",\n+ "Seoul Incheon International Airport": "ICN",\n+ "Washington Manassas Airport": "MNZ",\n+ "Millville Executive Airport": "MIV",\n+ "Wollongong-Shellharbour Airport": "WOL",\n+ "Sydney Airport": "SYD",\n+ "墨西哥城貝尼托·胡亞雷斯國際機場": "MEX",\n+ "丹佛國際機場": "DEN",\n+ "卡萊爾機場": "CAX",\n+ "仁川國際機場": "ICN",\n+ "泰德·史蒂文斯安克拉治國際機場": "ANC",\n+ "蘇丹哈桑努丁國際機場": "UPG",\n+ "羽田機場": "HND",\n+ "索菲亞機場": "SOF",\n+ "蒙特婁機場": "YUL",\n+ "巴黎奧利機場": "ORY",\n+ "拉傑斯機場": "TER",\n+ "Denver International Airport": "DEN",\n+ "Midland International Air & Space Port": "MAF",\n+ "米德蘭國際空天港": "MAF",\n+ "San Diego International Airport": "SAN",\n+ "聖地牙哥國際機場": "SAN",\n+ "Lynchburg Regional Airport": "LYH",\n+ "林奇堡區域機場": "LYH",\n+ "Wilmington International Airport": "ILM",\n+ "威爾明頓國際機場": "ILM",\n+ "Sacramento International Airport": "SMF",\n+ "沙加緬度國際機場": "SMF",\n+ "蓋瑞/芝加哥國際機場": "GYY",\n+ "Athens International Airport": "ATH",\n+ "Paris Charles de Gaulle Airport": "CDG",\n+ "Melbourne Airport": "MEL",\n+ "墨爾本機場": "MEL",\n+ "墨爾本國際機場": "MEL",\n+ "派耶恩機場": "VIP",\n+ "Seoul-Incheon Airport": "ICN",\n+ "Busan Gimhae International Airport": "PUS",\n+ "Sultan Hasanuddin International Airport": "UPG",\n+ "Hollywood Burbank Airport": "BUR",\n+ "Hawthorne Municipal Airport": "HHR",\n+ "John Wayne Airport": "SNA",\n+ "Buffalo Airport": "BUF",\n+ "Ben Gurion Airport": "TLV",\n+ "Moscow Vnukovo International Airport": "VKO",\n+ "Toulouse Airport": "TLS",\n+ "Montreal Airport": "YUL",\n+ "Rome Fiumicino": "FCO",\n+ "Bordeaux Mérignac": "BOD",\n+ "Orlando International": "MCO",\n+ "Minneapolis–Saint Paul International Airport": "MSP",\n+ "Nairobi Jomo Kenyatta International Airport": "NBO",\n+ "Prestwick Airport": "PIK",\n+ "Cape Newenham Long Range Radar Site Airport": "EHM",\n+ "紐恩漢角長程雷達站機場": "EHM",\n+ "Schiphol": "AMS",\n+ "阿姆斯特丹史基浦": "AMS",\n+ "史基浦": "AMS",\n+ "Corpus Christi International Airport": "CRP",\n+ "Anchorage Ted Stevens International Airport": "ANC",\n+ "Hanover Airport": "HAJ",\n+ "漢諾威機場": "HAJ",\n+ "萊比錫機場": "LEJ"\n }\n }\n@@ -162,7 +162,7 @@\n "title": "Cirrus Vision Jet Suffers Gear Collapse on Closed Runway in Idaho",\n "summary": "A Cirrus Vision Jet suffered gear collapse during a closed-runway takeoff in Idaho, injuring one passenger.",\n "body": [\n- "A Cirrus Vision Jet sustained substantial damage and suffered a landing gear collapse during a takeoff attempt from a closed runway section at Driggs-Reed Memorial Airport (DIJ (DIJ)) in Idaho. Official statements confirm that one passenger on board was injured in the collision and was subsequently transported by ambulance to a local hospital for medical evaluation.",\n+ "A Cirrus Vision Jet sustained substantial damage and suffered a landing gear collapse during a takeoff attempt from a closed runway section at Driggs-Reed Memorial Airport (DIJ) in Idaho. Official statements confirm that one passenger on board was injured in the collision and was subsequently transported by ambulance to a local hospital for medical evaluation.",\n "According to details released by the City of Driggs, the accident occurred at approximately 06:30 local time on August 14, 2026, as the Vision Jet attempted to depart from Runway 4. A section of the airport's primary 7,301-foot runway had been closed for several weeks as part of an ongoing airport construction project, leaving an available takeoff distance of 3,850 feet.",\n "City authorities reported that the aircraft entered the closed area and attempted to take off through the portion of the runway under construction. Driggs Deputy City Administrator Doug Self stated that while the pilot was able to slow the plane upon approaching the construction site, a collision with construction materials could not be averted. The Federal Aviation Administration (FAA) added that the jet struck gravel on the closed section, resulting in the collapse of its landing gear.",\n "FAA records identify the aircraft by registration N71KD, registered to Vision71 Technologies based in Chevy Chase, Maryland. Airport officials noted that Driggs-Reed Memorial Airport operates as a non-towered facility and that pilots had been advised of the construction work several weeks prior to the start of the project. A formal Notice to Airmen (NOTAM) outlining the partial runway closure was officially issued on August 5.",\n@@ -17,10 +17,10 @@\n "photoYear": 2017\n },\n "zh": {\n- "title": "大韓航空開辦首爾仁川(ICN)至墨爾本(MEL)季節性航線",\n+ "title": "大韓航空開辦首爾仁川國際機場(ICN)至墨爾本機場(MEL)季節性航線",\n "summary": "大韓航空將於2026年12月開辦首爾仁川至墨爾本季節性航線,每週營運三班。",\n "body": [\n- "大韓航空宣布,將於2026年12月18日至2027年3月14日期間,開辦韓國首爾仁川國際機場(ICN)直飛澳洲墨爾本機場(MEL)的季節性客運航線,每週營運三班。",\n+ "大韓航空宣布,將於2026年12月18日至2027年3月14日期間,開辦韓國首爾仁川國際機場直飛澳洲墨爾本機場的季節性客運航線,每週營運三班。",\n "該航線將採用波音787-9型客機,於每週三、週五及週日營運。去程航班KE409將於當地時間08:00自首爾仁川起飛,20:30抵達墨爾本;回程航班KE410則於當地時間22:00自墨爾本起飛,次日07:35返抵首爾仁川。",\n "業者表示,新航線的開闢主要受南韓與澳洲之間持續增長的旅遊需求推動,並將配合大韓航空現有的悉尼及布里斯本常規航線,擴大兩地之間的文化、商業與旅遊交流。"\n ]\n@@ -254,7 +254,7 @@\n "summary": "Electra與美州政府合作,完成連結地方與樞紐機場的油電混合展示飛行。",\n "body": [\n "美國聯邦航空管理局(FAA)宣布,航太製造商 Electra、賓夕法尼亞州交通廳與紐澤西州交通廳合作,順利完成了油電混合動力飛機的展示飛行。此次測試旨在驗證連接地方小型機場與大型樞紐機場航線的可行性。",\n- "本次展示飛行屬於 FAA 的「電動垂直起降整合試點計畫」(eIPP)的一環,航線自維吉尼亞州馬納薩斯起飛,終點為費城。飛機沿途停靠了華盛頓馬納薩斯機場(PNE)、米爾維爾執行機場、大西洋城貝德機場及東北費城機場,最後抵達費城國際機場。",\n+ "本次展示飛行屬於 FAA 的「電動垂直起降整合試點計畫」(eIPP)的一環,航線自維吉尼亞州馬納薩斯起飛,終點為費城。飛機沿途停靠了華盛頓馬納薩斯機場(MNZ)、米爾維爾執行機場(MIV)、大西洋城貝德機場(AIY)及東北費城機場(PNE),最後抵達費城國際機場(PHL)。",\n "FAA 表示,此計畫收集的資料將協助建立連接鄉村與大都會地區的飛航系統。美國交通部已於 2026 年 3 月選定 8 個計畫參與 eIPP,相關單位預計於今年底前持續進行更多測試。"\n ]\n },\n@@ -263,7 +263,7 @@\n "summary": "Electra and US transportation departments conduct hybrid-electric demonstration flights connecting small and major airports.",\n "body": [\n "The Federal Aviation Administration (FAA) announced that Electra, the Pennsylvania Department of Transportation, and the New Jersey Department of Transportation have conducted hybrid-electric demonstration flights. The tests evaluated routes connecting small local airports with major airline hubs.",\n- "As part of the FAA's eVTOL Integration Pilot Program (eIPP), the flights took place between Manassas, Virginia, and Philadelphia. The aircraft made stops at Washington Manassas Airport, Millville Executive Airport, Atlantic City Bader Field, Northeast Philadelphia Airport (PNE), and Philadelphia International Airport.",\n+ "As part of the FAA's eVTOL Integration Pilot Program (eIPP), the flights took place between Manassas, Virginia, and Philadelphia. The aircraft made stops at Washington Manassas Airport (MNZ), Millville Executive Airport (MIV), Atlantic City Bader Field (AIY), Northeast Philadelphia Airport (PNE), and Philadelphia International Airport (PHL).",\n "According to the FAA, the data gathered will help design a system to connect rural and metropolitan areas. The Department of Transportation selected eight eIPP projects in March 2026, and participants plan further test flights this year."\n ]\n },\n@@ -16,12 +16,12 @@\n ]\n },\n "en": {\n- "title": "Frontier Airlines Cuts 21 Routes From Atl (ATL)anta Network",\n+ "title": "Frontier Airlines Cuts 21 Routes From Atlanta Network",\n "summary": "Frontier Airlines cuts 21 routes from Atlanta; network pruned while preserving long-term goals.",\n "body": [\n- "According to reporting from Simple Flying, Frontier Airlines is cutting 21 routes from its network at Atlanta . The decision to prune these services follows a period of rapid network expansion by the carrier at the airport.",\n- "Frontier Airlines is adjusting its operations in Atlanta by eliminating underperforming and weak routes from its schedule. The reduction allows the airline to streamline its local route structure following the previous rapid growth phase.",\n- "Despite the decision to eliminate 21 routes from its Atlanta network, Frontier Airlines is preserving its long-term ambitions at the airport. The carrier plans to maintain its ongoing strategic presence at the hub while optimizing its flight offerings."\n+ "According to reporting from Simple Flying, Frontier Airlines is cutting 21 routes from its network at Atlanta (ATL). The decision to prune these services follows a period of rapid network expansion by the carrier at the airport.",\n+ "Frontier Airlines is adjusting its operations in Atlanta by eliminating underperforming and weak routes from its schedule. The reduction allows the airline to streamline its local route structure following the previous rapid growth phase.",\n+ "Despite the decision to eliminate 21 routes from its Atlanta network, Frontier Airlines is preserving its long-term ambitions at the airport. The carrier plans to maintain its ongoing strategic presence at the hub while optimizing its flight offerings."\n ]\n },\n "sources": [\n@@ -78,24 +78,24 @@\n "primarySource": "AeroTime",\n "zh": {\n "title": "Electra 測試 ultra-STOL 飛機 作為 feeder",\n- "summary": "Electra 於 2026 年 8 月 18 日宣布進行 ultra-STOL 混合電動飛機示範飛行,測試作為主要機場(PHL) feeder 的能力;飛行計畫在 FAA eIPP 支持下,從維吉尼亞州 Manassas 出發,最後停靠費城國際機場(PNE),途中曾停靠紐澤西州的 Millville Executive 和 Atlantic City Bader Field 以及賓夕法尼亞州的 Northeast Philadelphia Airport。",\n+ "summary": "Electra 於 2026 年 8 月 18 日宣布進行 ultra-STOL 混合電動飛機示範飛行,測試作為主要機場接駁機的能力;飛行計畫在 FAA eIPP 支持下,從維吉尼亞州 Manassas 出發,經停 Millville Executive Airport(MIV)與 Atlantic City Bader Field(AIY),最後抵達費城國際機場(PHL),並停靠 Northeast Philadelphia Airport(PNE)。",\n "body": [\n "Electra,一家開發 ultra-short takeoff and landing (STOL) 混合電動飛機的新創公司,於 2026 年 8 月 18 日宣布已完成一系列示範飛行,以驗證該新型飛機作為主要機場 feeder 的能力。",\n "飛行計畫在 eVTOL Integration Pilot Program (eIPP) 的框架下進行,該計畫得到美國聯邦航空總署 (FAA) 的支持,同時也獲得賓夕法尼亞州交通部 (PennDOT) 與新澤西州交通部 (NJDOT) 的協助。",\n- "飛行自 Electra 在維吉尼亞州 Manassas 的基地出發,最終抵達賓夕法尼亞州的 Philadelphia International Airport 。途中,Electra 的「Goldfinch」示範機分別停靠了紐澤西州的 Millville Executive Airport (MIV) 與 Atlantic City Bader Field (AIY),以及賓夕法尼亞州的 Northeast Philadelphia Airport 。",\n+ "飛行自 Electra 在維吉尼亞州 Manassas 的基地出發,最終抵達賓夕法尼亞州的 Philadelphia International Airport(PHL)。途中,Electra 的「Goldfinch」示範機分別停靠了紐澤西州的 Millville Executive Airport (MIV) 與 Atlantic City Bader Field (AIY),以及賓夕法尼亞州的 Northeast Philadelphia Airport(PNE)。",\n "該項目的關鍵目標之一是測試小型混合電動飛機(如 Electra 的機型)在美國國家空域管理系統中服務多個小型、使用率不高的機場,以提升空間網路的毛細血管功能。",\n "Electra 首席執行官 Marc Allen 在新聞稿中表示:「Electra’s Ultra Short aircraft can create direct connections between communities, regional airports, and major hubs, unlocking a faster, more accessible way to travel。」",\n "除了此項目外,Electra 尚有其他正在進行的示範飛行計畫,包括與佛羅里達州交通部及私營合作夥伴的合作、與 Signature Aviation 和 Vertiports by Atlantic 測試其 ultra-STOL 飛機在紐約/新澤西地區直升機場的適用性,以及與 Bristow Group 在路易斯安那州的另一項測試計畫。"\n ]\n },\n "en": {\n "title": "Electra tests ultra-STOL aircraft as feeder for major airports",\n- "summary": "Electra announced on August 18, 2026 that it conducted demonstration flights of its ultra-short takeoff and landing (STOL) hybrid-electric aircraft to test feasibility as a feeder for major airports; the program was run under the FAA-backed eVTOL Integration Pilot Program, with support from PennDOT and NJDOT, flying from Manassas, Virginia to Philadelphia International Airport (PHL) (PNE) with stops at Millville Executive (MIV), Atlantic City Bader Field (AIY) and Northeast Philadelphia .",\n+ "summary": "Electra tested its ultra-STOL hybrid-electric aircraft as a feeder for major airports, flying from Manassas to Philadelphia International Airport (PHL) with stops at Millville Executive Airport (MIV), Atlantic City Bader Field (AIY), and Northeast Philadelphia Airport (PNE).",\n "body": [\n "Electra, a startup developing an ultra-short takeoff and landing (STOL) hybrid-electric aircraft, announced on August 18, 2026 that it has conducted a series of demonstration flights testing the capability of this novel aircraft to operate as a feeder for major airports.",\n "The flight program took place under the aegis of the eVTOL Integration Pilot Program (eIPP), backed by the Federal Aviation Administration (FAA), and also saw support from the Department of Transportation of two states: Pennsylvania and New Jersey.",\n- "The flight program began at Electra’s base in Manassas, Virginia and made its final stop at Philadelphia International Airport , Pennsylvania.",\n- "Along the way, Electra’s “Goldfinch” demonstrator stopped at several small regional airports: Millville Executive Airport and Atlantic City Bader Field (AIY), both in New Jersey, and Northeast Philadelphia Airport , Pennsylvania.",\n+ "The flight program began at Electra’s base in Manassas, Virginia and made its final stop at Philadelphia International Airport (PHL), Pennsylvania.",\n+ "Along the way, Electra’s “Goldfinch” demonstrator stopped at several small regional airports: Millville Executive Airport (MIV) and Atlantic City Bader Field (AIY), both in New Jersey, and Northeast Philadelphia Airport (PNE), Pennsylvania.",\n "One of the key aims of the project was to test the integration of small hybrid-electric aircraft like Electra’s, which is designed to provide air network capillarity by servicing multiple small, underused airfields in the US national airspace management system.",\n "“Electra’s Ultra Short aircraft can create direct connections between communities, regional airports, and major hubs, unlocking a faster, more accessible way to travel,” said Marc Allen, Electra’s CEO, in a press release announcing the milestone.",\n "In parallel to this initiative, Electra currently has other projects underway to test its technology in real operational conditions, including a demonstration flight program in Florida with that state’s Department of Transportation and private-sector partners, a project with Signature Aviation and Vertiports by Atlantic to assess suitability of its ultra-STOL aircraft for heliport operations in the New York/New Jersey area, and another testing program in Louisiana together with Bristow Group."\n@@ -15,10 +15,10 @@\n ]\n },\n "en": {\n- "title": "Australian Man Pleads Guilty to Assaulting TSA Officer at LAX (LAX)",\n+ "title": "Australian Man Pleads Guilty to Assaulting TSA Officer at LAX",\n "summary": "Thomas Bingham pleaded guilty to assaulting a TSA officer at LAX; sentencing is scheduled for next month.",\n "body": [\n- "Australian national Thomas Bingham has pleaded guilty in court to assaulting a Transportation Security Administration (TSA) officer at Los Angeles International Airport . The guilty plea stems from an incident inside the airport terminal during which Bingham grabbed a TSA security officer in connection with a dispute over a lost bag.",\n+ "Australian national Thomas Bingham has pleaded guilty in court to assaulting a Transportation Security Administration (TSA) officer at Los Angeles International Airport (LAX). The guilty plea stems from an incident inside the airport terminal during which Bingham grabbed a TSA security officer in connection with a dispute over a lost bag.",\n "Following his admission of guilt, Bingham is scheduled to be formally sentenced next month. For the offense of assaulting an officer at the airport, he faces a maximum potential penalty of up to eight years in prison under federal sentencing guidelines."\n ]\n },\n@@ -30,7 +30,7 @@\n "title": "NetJets Citation Longitude makes nose-gear-up landing in Ohio",\n "summary": "A NetJets Citation Longitude made a nose-gear-up emergency landing in Ohio; two pilots were uninjured.",\n "body": [\n- "A NetJets Cessna Citation Longitude business jet made an emergency landing with its nose landing gear retracted at Akron-Canton Airport (CAK (CAK)) in Ohio on August 18, 2026. The flight was operating as NetJets Flight EJA803 from Washington Dulles International Airport (IAD) to Akron-Canton when its nose landing gear failed to extend during arrival.",\n+ "A NetJets Cessna Citation Longitude business jet made an emergency landing with its nose landing gear retracted at Akron-Canton Airport (CAK) in Ohio on August 18, 2026. The flight was operating as NetJets Flight EJA803 from Washington Dulles International Airport (IAD) to Akron-Canton when its nose landing gear failed to extend during arrival.",\n "According to video footage of the landing, the aircraft touched down on its main landing gear first. The pilots held the nose off the runway surface for several seconds as the aircraft decelerated before the nose settled onto the pavement. The aircraft sustained damage to its nose section and underside during the occurrence.",\n "Two pilots were on board the aircraft and exited without injury, according to the Ohio State Highway Patrol. No passengers were aboard the flight. Emergency responders from the City of Green Fire Division and Akron-Canton Airport emergency crews attended the scene after the landing occurred at approximately 12:53 local time.",\n "Federal Aviation Administration (FAA) registry records identify the aircraft, registered as N803QS, as a 2020 Textron Aviation Model 700 Citation Longitude. Flight tracking data showed it departed Washington Dulles at 10:48 and arrived at Akron-Canton at 12:54. The precise cause of the landing gear malfunction has not been determined.",\n@@ -33,7 +33,7 @@\n "body": [\n "Singapore Airlines (SIA) announced on August 4, 2026, that it is expanding its European flight network through increased service frequencies to multiple key cities and the launch of a new route to Madrid, Spain. The airline noted that the network expansion responds to growing passenger demand for air travel to Europe connecting through its hub at Singapore Changi Airport (SIN).",\n "Effective October 26, 2026, Singapore Airlines will launch a five-times-weekly service between Singapore and Madrid, operated via Barcelona, Spain. This new route makes Madrid the carrier's 15th European destination and its second destination in Spain. The airline will operate its long-haul variant Airbus A350-900 aircraft (ICAO type code A359), configured in a 253-seat layout, on the Singapore-Barcelona-Madrid service.",\n- "As part of the expansion in the United Kingdom, flights to Manchester will increase from five times weekly to daily. At London Gatwick Airport (LGW) (LHR) , SIA will step up its second daily tranche to full daily operations during the Northern winter 2026 operating season, complementing existing daily flights SQ312 and SQ309. Together with four daily flights to London Heathrow Airport , SIA's total London capacity will reach up to six daily flights.",\n+ "As part of the expansion in the United Kingdom, flights to Manchester will increase from five times weekly to daily. At London Gatwick Airport (LGW), SIA will step up its second daily tranche to full daily operations during the Northern winter 2026 operating season, complementing existing daily flights SQ312 and SQ309. Together with four daily flights to London Heathrow Airport (LHR), SIA's total London capacity will reach up to six daily flights.",\n "On continental European routes, flights to Milan, Italy, will increase from four weekly services to daily operations starting October 25, 2026. The following day, on October 26, 2026, SIA will introduce a new three-times-weekly service to Munich, Germany, boosting its overall weekly operations to Munich to 10 flights.",\n "Singapore Airlines Philippines General Manager Liwei Tai stated that Europe remains a popular destination for travelers and that these network enhancements reflect efforts to meet travel demand with expanded choice and connectivity. The additional services will provide travelers from the Asia-Pacific region with more convenient flight options to connect to Europe via Singapore."\n ]\n@@ -16,10 +16,10 @@\n "subject": "註冊號 B-58554"\n },\n "zh": {\n- "title": "星宇航空空山金與空山銀雙機首度於東京成田機場同框展示",\n+ "title": "星宇航空空山金與空山銀雙機首度於東京成田機場(NRT)同框展示",\n "summary": "星宇航空空山金藝術機20日首飛東京成田;雙機展示並預計10月投入定期航班",\n "body": [\n- "星宇航空與日本藝術家空山基合作打造的A350-1000藝術機「空山金」,於2026年8月20日由星宇航空董事長張國煒親自執飛。該航班於台北時間上午8時30分自桃園國際機場起飛,並於東京時間中午12時51分順利飛抵東京成田國際機場。",\n+ "星宇航空與日本藝術家空山基合作打造的A350-1000藝術機「空山金」,於2026年8月20日由星宇航空董事長張國煒親自執飛。該航班於台北時間上午8時30分自桃園國際機場(TPE)起飛,並於東京時間中午12時51分順利飛抵東京成田國際機場。",\n "本次航班抵達後,「空山金」與「空山銀」首度於東京成田國際機場停機坪並肩同框展示。這也是星宇航空繼「空山銀」於7月亮相後,兩架旗艦級A350-1000藝術機首度共同亮相。",\n "根據星宇航空規劃,這兩架「空山金」與「空山銀」藝術機預計將於2026年10月1日起正式投入定期航班服務,營運航線將涵蓋東京、鳳凰城及布拉格等熱門國際航線。"\n ]\n@@ -28,7 +28,7 @@\n "title": "STARLUX Airlines Displays Sorayama Gold and Silver Aircraft Together at Tokyo Narita",\n "summary": "STARLUX Airlines flew Sorayama Gold to Tokyo Narita on August 20, displaying twin art aircraft together.",\n "body": [\n- "STARLUX Airlines Chairman K.W. Chang piloted the carrier's Airbus A350-1000 art livery aircraft, named Sorayama Gold, from Taoyuan International Airport to Tokyo Narita International Airport on August 20, 2026. The flight departed Taoyuan at 08:30 Taipei time and arrived at Narita at 12:51 Tokyo time.",\n+ "STARLUX Airlines Chairman K.W. Chang piloted the carrier's Airbus A350-1000 art livery aircraft, named Sorayama Gold, from Taoyuan International Airport (TPE) to Tokyo Narita International Airport (NRT) on August 20, 2026. The flight departed Taoyuan at 08:30 Taipei time and arrived at Narita at 12:51 Tokyo time.",\n "Following its arrival, Sorayama Gold was displayed side-by-side with sister aircraft Sorayama Silver on the apron at Tokyo Narita International Airport. This marked the first time both flagship art aircraft appeared together following the unveiling of Sorayama Silver in July.",\n "According to STARLUX Airlines, both art-themed aircraft are scheduled to formally enter regular commercial passenger service starting October 1, 2026. The aircraft will operate on major international routes, including services to Tokyo, Phoenix, and Prague."\n ]\n@@ -21,7 +21,7 @@\n "summary": "星宇航空將於2027年1月1日起開闢高雄至神戶與沖繩航線,每日各一班。",\n "body": [\n "星宇航空於2026年8月21日宣布進軍高雄市場,計畫自2027年1月1日起開闢高雄往返神戶及高雄往返沖繩兩條日本新航線。這是星宇航空首度在高雄設立定期航點。",\n- "根據星宇航空規劃,高雄—神戶與高雄—沖繩兩條新航線開航初期均為每日一班,將採用空中巴士A321neo新世代客機執飛。神戶市與星宇航空已於神戶市政廳舉行發表會,由星宇航空策略長劉允富、神戶市長久元喜造及關西機場本部長西尾裕共同主持。",\n+ "根據星宇航空規劃,高雄—神戶與高雄—沖繩兩條新航線開航初期均為每日一班,將採用空中巴士A321neo新世代客機執飛。神戶市與星宇航空已於神戶市政廳舉行發表會,由星宇航空策略長劉允富、神戶市長久元喜造及關西機場(KIX)本部長西尾裕共同主持。",\n "星宇航空表示,隨著高雄航線的加入,公司將完成台灣北部、中部及南部三地的航網布局,未來旅客可自由搭配台北、台中與高雄不同地點進出。"\n ]\n },\n@@ -126,7 +126,7 @@\n "美國聯邦第十一巡迴上訴法院於2026年8月20日作出裁決,撤銷美國運輸部(DOT)先前要求達美航空(Delta Air Lines)與墨西哥航空(Aeroméxico)拆解其跨境合資企業的行政命令。這項裁決允許兩家航空公司在2016年獲得的反托拉斯豁免權架構下,繼續協調美國與墨西哥之間航班的班表、票價以及運能分配。",\n "上訴法院在判決中指出,運輸部未能合理說明為何在本案中採用了較以往航空公司聯盟審查大幅縮減的市場分析模式。在2016年最初批准該項合作時,運輸部針對整個美墨市場以及1,687個城市對進行了全面審查;但在2025年的重新審查中,運輸部卻主要關注墨西哥城貝尼托·胡亞雷斯國際機場(MEX)的營運狀況,該機場約佔美墨兩國間航班總量的21%。",\n "法院補充指出,運輸部在2025年的最終命令中,並未包含更新後的航空公司市場占有率資料,亦未分析若合資企業繼續運作,相關市占率將如何變化,同時也未對個別城市對市場進行新的評估。上訴法院表示,運輸部在未提供合理解釋的情況下,劇烈偏離過去分析國家對與城市對的統一做法,因此該行政命令屬於任意且反覆無常的處分。",\n- "這起監管爭議源於墨西哥政府推行的航空政策,包括減少墨西哥城貝尼托·胡亞雷斯國際機場(NLU)的可分配起降時間(slots),以及強制將貨運業務轉移至費利佩·安吉利斯國際機場。美方官員認為相關政策扭曲了市場競爭,並違反2015年《美墨航空運輸協定》,促使美國運輸部於2025年9月決定撤銷對達美航空與墨西哥航空合資企業的批准與反托拉斯豁免權。",\n+ "這起監管爭議源於墨西哥政府推行的航空政策,包括減少墨西哥城貝尼托·胡亞雷斯國際機場的可分配起降時間(slots),以及強制將貨運業務轉移至費利佩·安吉利斯國際機場(NLU)。美方官員認為相關政策扭曲了市場競爭,並違反2015年《美墨航空運輸協定》,促使美國運輸部於2025年9月決定撤銷對達美航空與墨西哥航空合資企業的批准與反托拉斯豁免權。",\n "由於上訴法院先前已於2025年11月暫緩執行運輸部的拆解命令,達美航空與墨西哥航空的合資企業在此期間始終維持營運。裁決公佈後,兩家航空公司對此表示歡迎,墨西哥航空亦確認其合資架構與反托拉斯豁免權繼續有效。不過,法院亦澄清該裁決僅針對運輸部程序與分析上的缺失進行撤銷,並未就該合資企業是否有助於市場競爭做出最終結論。"\n ]\n },\n@@ -137,7 +137,7 @@\n "The 11th US Circuit Court of Appeals vacated a Department of Transportation (DOT) order on August 20, 2026, that would have required Delta Air Lines and Aeroméxico to dismantle their cross-border joint venture. The appellate court's decision enables both carriers to maintain their commercial partnership and continue coordinating flight schedules, ticket pricing, and capacity under the antitrust immunity originally granted to them in 2016.",\n "In its ruling, the court determined that the DOT failed to provide a reasonable explanation for applying a far narrower competitive evaluation than it had historically utilized in previous airline alliance reviews. When the regulatory agency initially approved the partnership in 2016, it conducted a comprehensive examination of competition across the entire US-Mexico market and 1,687 individual city pairs. In contrast, the DOT's 2025 review focused primarily on slot conditions at Mexico City International Airport (MEX), which accounts for approximately 21% of total flights between the two nations.",\n "The court emphasized that the department's final order lacked updated airline market share data, omitted any prospective analysis of how market shares might evolve if the joint venture continued, and failed to execute a fresh evaluation of individual city-pair markets. Consequently, the appellate panel held that the DOT's action was arbitrary and capricious, as the agency departed drastically from its uniform practice of analyzing country-pair and city-pair markets without justifying why such comprehensive reviews were unnecessary in this instance.",\n- "The regulatory dispute originated from policies enacted by the Mexican government, including mandatory reductions in available slots at Mexico City International Airport and a decree relocating all dedicated cargo operations from MEX to Felipe Ángeles International Airport (NLU (NLU)). US authorities argued these actions distorted market competition and breached the 2015 US-Mexico Air Transport Agreement, prompting the DOT to issue its September 2025 order revoking approval and antitrust immunity for the carriers' alliance.",\n+ "The regulatory dispute originated from policies enacted by the Mexican government, including mandatory reductions in available slots at Mexico City International Airport and a decree relocating all dedicated cargo operations from MEX to Felipe Ángeles International Airport (NLU). US authorities argued these actions distorted market competition and breached the 2015 US-Mexico Air Transport Agreement, prompting the DOT to issue its September 2025 order revoking approval and antitrust immunity for the carriers' alliance.",\n "Because the appeals court had previously issued a stay on the DOT order in November 2025, Delta Air Lines and Aeroméxico were never forced to dissolve their joint venture. Both airlines welcomed the latest judicial decision, with Aeroméxico confirming that the joint venture and its antitrust immunity remain fully active. The court clarified that its ruling addresses the department's analytical and procedural shortcomings rather than making a definitive finding on whether the partnership itself is pro-competitive."\n ]\n },\n@@ -255,13 +255,13 @@\n ]\n },\n "en": {\n- "title": "United Airlines Completes Phase One Expansion at Den (DEN)ver Pilot Training Center",\n+ "title": "United Airlines Completes Phase One Expansion at Denver Pilot Training Center",\n "summary": "United Airlines completes phase one expansion at Denver training center; adds 40 CAE flight simulators.",\n "body": [\n "United Airlines has completed the first phase of an expansion at its Denver Flight Training Center following the addition of 40 new CAE flight simulators and training devices. With these new devices in place, the airline now operates 52 full-motion flight simulators and 34 fixed training devices at the facility, supporting training operations for nearly 18,000 active pilots as well as newly hired flight crews.",\n "The 22-acre campus in Denver's Central Park neighborhood features eight buildings comprising nearly 700,000 square feet of training space. According to United Airlines, the facility can accommodate up to 860 pilots per day and conduct more than 32,000 training events annually. The center operates around the clock for 362 days each year, serving as the primary facility where United pilots complete qualification and return every nine months for recurrent training.",\n "As part of the expansion project initiated in 2022, United Airlines opened a new 150,000-square-foot building at the campus in 2024. The airline invested over $145 million in the facility to accommodate additional full-motion simulators, classrooms, and support areas, bringing its total investment in the campus to $370 million since 2016. The expansion project has created more than 300 additional jobs at the training center.",\n- "United Airlines is also preparing for a subsequent expansion near Denver International Airport . In July 2026, the Denver City Council approved a rezoning request for undeveloped land purchased by the airline in 2023. United expects to begin construction on the new facility in 2027 and place it into operation by 2030 to run alongside the existing Central Park campus. Since launching its United Next growth strategy in 2021, the airline has hired more than 9,500 pilots, including approximately 700 pilots during the first part of 2026."\n+ "United Airlines is also preparing for a subsequent expansion near Denver International Airport (DEN). In July 2026, the Denver City Council approved a rezoning request for undeveloped land purchased by the airline in 2023. United expects to begin construction on the new facility in 2027 and place it into operation by 2030 to run alongside the existing Central Park campus. Since launching its United Next growth strategy in 2021, the airline has hired more than 9,500 pilots, including approximately 700 pilots during the first part of 2026."\n ]\n },\n "sources": [\n@@ -204,8 +204,8 @@\n "summary": "美國交通部撥款近6.15億美元,資助全美238項機場基礎設施改善專案。",\n "body": [\n "美國交通部部長蕭恩·達菲(Sean P. Duffy)宣布,將透過「機場改善計畫」(Airport Improvement Program,簡稱AIP)撥款近6.15億美元,資助全美42個州及兩個領地共238項補助款。該項大規模資金投入旨在升級與強化全美各地機場的關鍵基礎設施,改善跑道與滑行道狀況,並優化航廈乘客設施與飛行區安全設備。",\n- "依據美國聯邦航空管理局(FAA)發布的撥款計畫細節,重點補助項目涵蓋各類規模的機場設施需求。其中,德克薩斯州的米德蘭國際空天港(Midland International Air & Space Port)獲得2150萬美元用於跑道整修工程;阿拉斯加州諾阿塔克(Noatak)獲得1950萬美元用於興建一座新機場;加利福尼亞州的聖地牙哥國際機場(San Diego International Airport)則獲得1530萬美元用於降噪改善計畫。",\n- "其他獲得資助的代表性機場(GYY)項目還包括:印第安納州的蓋瑞/芝加哥國際機場(Gary/Chicago International Airport)獲撥830萬美元興建新合約管制塔台;維吉尼亞州的林奇堡區域機場(Lynchburg Regional Airport)獲撥800萬美元興建新航廈;北卡羅來納州的威爾明頓國際機場(Wilmington International Airport)獲撥630萬美元用於跑道延伸;加利福尼亞州的沙加緬度國際機場(Sacramento International Airport)獲撥240萬美元用於更新旅客登機橋。",\n+ "依據美國聯邦航空管理局(FAA)發布的撥款計畫細節,重點補助項目涵蓋各類規模的機場設施需求。其中,德克薩斯州的米德蘭國際空天港(MAF;Midland International Air & Space Port)獲得2150萬美元用於跑道整修工程;阿拉斯加州諾阿塔克(Noatak)獲得1950萬美元用於興建一座新機場;加利福尼亞州的聖地牙哥國際機場(SAN;San Diego International Airport)則獲得1530萬美元用於降噪改善計畫。",\n+ "其他獲得資助的代表性機場項目還包括:印第安納州的蓋瑞/芝加哥國際機場(GYY;Gary/Chicago International Airport)獲撥830萬美元興建新合約管制塔台;維吉尼亞州的林奇堡區域機場(LYH;Lynchburg Regional Airport)獲撥800萬美元興建新航廈;北卡羅來納州的威爾明頓國際機場(ILM;Wilmington International Airport)獲撥630萬美元用於跑道延伸;加利福尼亞州的沙加緬度國際機場(SMF;Sacramento International Airport)獲撥240萬美元用於更新旅客登機橋。",\n "根據交通部說明,機場可將機場改善計畫補助金運用於跑道、滑行道、標誌、照明、標線及其他與安全相關的建設。交通部長達菲指出,升級機場基礎設施是打造高效與安全交通體系的一環;聯邦航空管理局局長布萊恩·貝德福德(Bryan Bedford)亦表示,在全美航空出行需求持續增長之際優先推動這些資助,將有助於提升整體旅客出行體驗。"\n ]\n },\n@@ -214,8 +214,8 @@\n "summary": "USDOT announced nearly $615 million in grants for 238 airport infrastructure projects across the United States.",\n "body": [\n "U.S. Transportation Secretary Sean P. Duffy announced a $615 million investment to fund 238 grants through the Airport Improvement Program across 42 states and two territories. The federal funding initiative from the U.S. Department of Transportation is designed to modernize and reinforce infrastructure at airports nationwide, supporting critical airfield safety upgrades, runway and taxiway rehabilitation, and terminal facility improvements for passengers.",\n- "Under the announced grant distribution, federal funding targets specific infrastructure and operational needs at airports of varying sizes nationwide. Key major project allocations include $21.5 million awarded to Midland International Air & Space Port in Texas for runway rehabilitation, $19.5 million allocated to construct a new airport in Noatak, Alaska, and $15.3 million provided to San Diego International Airport for noise mitigation projects.",\n- "Additional high-priority grant awards detailed by the agency include $8.3 million to Gary/Chicago International Airport (GYY) in Indiana to construct a new contract tower and $8 million to Lynchburg Regional Airport in Virginia for a new terminal facility. Furthermore, Wilmington International Airport in North Carolina receives $6.3 million for runway extension, while Sacramento International Airport in California is awarded $2.4 million for new passenger boarding bridges.",\n+ "Under the announced grant distribution, federal funding targets specific infrastructure and operational needs at airports of varying sizes nationwide. Key major project allocations include $21.5 million awarded to Midland International Air & Space Port (MAF) in Texas for runway rehabilitation, $19.5 million allocated to construct a new airport in Noatak, Alaska, and $15.3 million provided to San Diego International Airport (SAN) for noise mitigation projects.",\n+ "Additional high-priority grant awards detailed by the agency include $8.3 million to Gary/Chicago International Airport (GYY) in Indiana to construct a new contract tower and $8 million to Lynchburg Regional Airport (LYH) in Virginia for a new terminal facility. Furthermore, Wilmington International Airport (ILM) in North Carolina receives $6.3 million for runway extension, while Sacramento International Airport (SMF) in California is awarded $2.4 million for new passenger boarding bridges.",\n "According to the agency, airports can utilize Airport Improvement Program funding for infrastructure projects including runways, taxiways, signage, lighting, markings, and other safety-related airfield needs. Transportation Secretary Duffy emphasized that upgrading airport infrastructure helps deliver a safer and smoother travel experience, while Federal Aviation Administration Administrator Bryan Bedford stated that prioritizing these grants supports growing travel volumes and transforms the overall passenger journey."\n ]\n },\n@@ -10,7 +10,7 @@\n "title": "塞斯納441包機於阿拉斯加軍用雷達站附近墜毀 機上8人全數罹難",\n "summary": "塞斯納包機於阿拉斯加雷達站附近墜毀;機上8人全數罹難;運安會已展開調查",\n "body": [\n- "一架載有8人的塞斯納441 Conquest II(Cessna 441 Conquest II)雙引擎渦輪螺旋槳包機,於當地時間2026年8月20日約12時15分,在美國阿拉斯加州西部紐恩漢角長程雷達站機場(EHM)(Cape Newenham Long Range Radar Site Airport)以西的陸地上墜毀。美國聯邦航空總署(FAA)證實該起事故,救援人員接報抵達現場後,確認機上8人全數罹難,無人生還。",\n+ "一架載有8人的塞斯納441 Conquest II(Cessna 441 Conquest II)雙引擎渦輪螺旋槳包機,於當地時間2026年8月20日約12時15分,在美國阿拉斯加州西部紐恩漢角長程雷達站機場(EHM;Cape Newenham Long Range Radar Site Airport)以西的陸地上墜毀。美國聯邦航空總署(FAA)證實該起事故,救援人員接報抵達現場後,確認機上8人全數罹難,無人生還。",\n "根據官方資訊,該架失事客機註冊編號為N128EZ,製造年份為1979年,由總部位於安克拉治的安克拉治安全航空(Security Aviation)負責營運。該班機當天自泰德·史蒂文斯安克拉治國際機場(ANC)起飛,機上載有2名飛行員及6名乘客。美國陸軍工兵署(US Army Corps of Engineers)隨後證實該班機為其承租,且罹難者中有2名為該署員工。",\n "事故發生的紐恩漢角長程雷達站機場位於安克拉治以西約450英里處,為美國空軍所屬的私人飛行場,由太平洋空軍區域支援中心管理,專門提供給監視阿拉斯加及周邊領空防務的雷達站使用。該機場設有一條長約3,945英尺、寬150英尺的15/33碎石跑道,跑道位於山坡之上,且兩端高差超過300英尺。",\n "國家運輸安全委員會(NTSB)阿拉斯加辦公室主管指出,該客機是在準備進場降落時發生墜毀。聯邦航空總署的機場資料警告,該跑道兩側及南端均有高聳地形,且重新重飛(go-around)成功的機率極低。據報導,事故發生當時該地區天氣伴隨濃霧,視線受到影響。",\n@@ -7,10 +7,10 @@\n "primarySource": "Simple Flying",\n "image": "https://static0.simpleflyingimages.com/wordpress/wp-content/uploads/2026/08/united-airlines-boeing-787-taking-off-at-chicago.jpg",\n "zh": {\n- "title": "United與American迫使O'Hare加速建設24個登機門候機廳",\n+ "title": "United與American迫使O'Hare(ORD)加速建設24個登機門候機廳",\n "summary": "因兩家航空公司快速增長,O'Hare加速建設24個登機門E候機廳",\n "body": [\n- "根據媒體 Simple Flying 於2026年8月21日的報導,由於 United 與 American 的快速增長正將 O'Hare 機場推向容量上限,該機場目前正在加速建設擁有24個登機門的 E 候機廳(Concourse E),以應對緊繃的機場(ORD)運能。",\n+ "根據媒體 Simple Flying 於2026年8月21日的報導,由於 United 與 American 的快速增長正將 O'Hare 機場推向容量上限,該機場目前正在加速建設擁有24個登機門的 E 候機廳(Concourse E),以應對緊繃的機場運能。",\n "報導指出,這兩家航空公司的快速擴張導致 O'Hare 機場正面臨登機門不足的限制。為了緩解日益嚴重的容量壓力,機場決定加快推進這座擁有24個登機門的 E 候機廳項目,解決因航空公司增長而帶來的空間挑戰。"\n ]\n },\n@@ -10,15 +10,15 @@\n "title": "阿拉斯加航空將於2027年開航西雅圖至雅典與巴黎直飛航線",\n "summary": "阿拉斯加航空宣佈將於2027年春季開航西雅圖至雅典與巴黎航線",\n "body": [\n- "阿拉斯加航空宣佈將於2027年春季起,開辦從西雅圖-塔科瑪國際機場(SEA)飛往雅典國際機場(CDG)(ATH)以及巴黎夏爾·戴高樂機場的直飛航線。其中,往返雅典的AS196航班預計於2027年5月12日首航,每週營運三班;往返巴黎的AS136航班則預計於2027年5月25日首航,每週營運五班。",\n+ "阿拉斯加航空宣佈將於2027年春季起,開辦從西雅圖-塔科瑪國際機場(SEA)飛往雅典國際機場(ATH)以及巴黎夏爾·戴高樂機場(CDG)的直飛航線。其中,往返雅典的AS196航班預計於2027年5月12日首航,每週營運三班;往返巴黎的AS136航班則預計於2027年5月25日首航,每週營運五班。",\n "兩條新跨大西洋航線均將採用波音787-9型客機執飛。機票已於2026年8月20日開賣。阿拉斯加航空執行長班·米尼庫奇(Ben Minicucci)表示,該公司正致力於在未來數年內,營運超過12條從西雅圖出發的國際長途航線。"\n ]\n },\n "en": {\n- "title": "Alaska Airlines to Launch Nonstop Seattle Flights to Ath (ATH)ens and Paris in 2027",\n+ "title": "Alaska Airlines to Launch Nonstop Seattle Flights to Athens and Paris in 2027",\n "summary": "Alaska Airlines will launch nonstop flights from Seattle to Athens and Paris starting in spring 2027.",\n "body": [\n- "Alaska Airlines has announced it will launch new nonstop service from Seattle-Tacoma International Airport (SEA) to both Athens International Airport and Paris Charles de Gaulle Airport (CDG) in spring 2027. Flights to Athens, operated as flight AS196, will begin on May 12, 2027, with three weekly departures. Flights to Paris, designated as flight AS136, are scheduled to start on May 25, 2027, running five times per week.",\n+ "Alaska Airlines has announced it will launch new nonstop service from Seattle-Tacoma International Airport (SEA) to both Athens International Airport (ATH) and Paris Charles de Gaulle Airport (CDG) in spring 2027. Flights to Athens, operated as flight AS196, will begin on May 12, 2027, with three weekly departures. Flights to Paris, designated as flight AS136, are scheduled to start on May 25, 2027, running five times per week.",\n "Both transatlantic routes will be operated using the airline's Boeing 787-9 Dreamliner aircraft. Tickets for the new services were placed on sale on August 20, 2026. Alaska Airlines CEO Ben Minicucci noted that the carrier aims to expand its Seattle network to operate more than 12 international long-haul routes in the coming years."\n ]\n },\n@@ -140,7 +140,7 @@\n "根據澳洲航空公佈的營運計畫,東向從倫敦返回伯斯的航班服務將在整個期間內保持全程直飛,不受本次新加坡中轉安排的影響。",\n "除了伯斯至倫敦航線外,澳洲航空亦將在同一時期調整其雪梨至巴黎航線的營運模式。自2026年10月25日至2027年1月31日,雪梨至巴黎的服務將改為經由新加坡營運,而非原先規律中轉的伯斯。至於巴黎返回雪梨的返程航班服務則保持不變。",\n "澳洲航空亦配合新機隊的引進調整多條國際與國內航線。布里斯本至馬尼拉航線將繼續使用空中巴士(Airbus)A330客機營運至2027年1月31日,並於2027年2月1日起開始改由空中巴士A321XLR機型執飛。此外,自10月25日起,澳洲航空將在連結雪梨、墨爾本與布里斯本等關鍵國內航線上,增加更多由A321XLR執飛的航班。",\n- "在機場合作與基礎設施方面,澳洲航空集團已與墨爾本機場就關鍵商業條款達成協議。這項協議為澳洲航空未來增加航班數量、升級國際航廈貴賓室、改善旅客報到程序,以及配合墨爾本機場交付第三條跑道鋪平了道路。"\n+ "在機場合作與基礎設施方面,澳洲航空集團已與墨爾本機場(MEL)就關鍵商業條款達成協議。這項協議為澳洲航空未來增加航班數量、升級國際航廈貴賓室、改善旅客報到程序,以及配合墨爾本機場交付第三條跑道鋪平了道路。"\n ]\n },\n "en": {\n@@ -151,7 +151,7 @@\n "The airline confirmed that the eastbound London-Perth flight service will remain nonstop throughout the entire period. In contrast, westbound passenger flights will continue to include the intermediate technical stopover in Singapore throughout this schedule adjustment window.",\n "Qantas will separately alter its Sydney-Paris flight service over the exact same period. From 25 October 2026 until 31 January 2027, the service will operate via Singapore rather than Perth, while the return Paris-Sydney flight service will remain completely unchanged during these network adjustments.",\n "The airline is also making several network adjustments across its international and domestic routes in connection with the introduction of its incoming Airbus A321XLR fleet. The carrier will continue operating its Brisbane-Manila service with the Airbus A330 until 31 January 2027, before introducing the A321XLR on the route from 1 February. From 25 October, Qantas will also add more A321XLR-operated flights on key domestic routes linking Sydney, Melbourne, and Brisbane.",\n- "Separately, the Qantas Group and Melbourne Airport have agreed on key commercial terms for major airport infrastructure developments. The agreement clears the way for additional flights, upgraded international lounges, improved passenger check-in procedures, and the delivery of Melbourne Airport's third runway project."\n+ "Separately, the Qantas Group and Melbourne Airport (MEL) have agreed on key commercial terms for major airport infrastructure developments. The agreement clears the way for additional flights, upgraded international lounges, improved passenger check-in procedures, and the delivery of Melbourne Airport's third runway project."\n ]\n },\n "sources": [\n@@ -372,7 +372,7 @@\n "primarySource": "Aerospace Global News",\n "image": "https://aerospaceglobalnews.com/wp-content/uploads/2026/08/1781803288502.jpg",\n "zh": {\n- "title": "瑞士CELLSIUS團隊氫燃料電池飛機移師派耶恩機場進行首飛準備",\n+ "title": "瑞士CELLSIUS團隊氫燃料電池飛機移師派耶恩機場(VIP)進行首飛準備",\n "summary": "瑞士學生團隊將H2-Sling氫能實驗機移至派耶恩機場(VIP);進行首飛前最終準備",\n "body": [\n "隸屬於瑞士蘇黎世聯聯邦理工學院(ETH Zurich)的學生工程團隊 CELLSIUS Aero,已將其研發的 H2-Sling 氫燃料電池實驗飛機從原建造地瑞士迪邦多夫(Dübendorf),移師至洛桑附近的派耶恩機場(Payerne Airport)。此項運送標誌著該項目已進入實際飛行前的最終準備階段,團隊將在該營運中機場完成各項首飛前整備作業。",\n@@ -385,7 +385,7 @@\n "title": "Swiss Student Team Prepares H2-Sling Hydrogen Aircraft for Maiden Flight",\n "summary": "Swiss team CELLSIUS transfers the H2-Sling hydrogen fuel-cell aircraft to Payerne Airport (VIP) for final maiden flight preparations.",\n "body": [\n- "A Swiss student engineering team affiliated with ETH Zurich, CELLSIUS Aero, has transferred its H2-Sling hydrogen fuel-cell demonstrator aircraft from its construction site in Dübendorf to Payerne Airport near Lausanne in Switzerland. The transfer marks the project's transition into the final stage of preparations before attempting to demonstrate the aircraft in actual flight at an active airfield.",\n+ "A Swiss student engineering team affiliated with ETH Zurich, CELLSIUS Aero, has transferred its H2-Sling hydrogen fuel-cell demonstrator aircraft from its construction site in Dübendorf to Payerne Airport (VIP) near Lausanne in Switzerland. The transfer marks the project's transition into the final stage of preparations before attempting to demonstrate the aircraft in actual flight at an active airfield.",\n "The H2-Sling is built on a Sling High Wing airframe and uses a hydrogen-electric powertrain instead of a conventional internal combustion engine. The aircraft features a fuel-cell system rated at 100 kW, with maximum propulsion power listed by CELLSIUS at 105 kW following testing. The powertrain includes a 5.6 kWh battery buffer to manage short-duration changes in power demand, such as during takeoff. The aircraft's maximum takeoff weight is listed at 1,050 kg.",\n "Hydrogen fuel is stored under a pressure of 700 bar across two high-pressure tanks located beneath the wings, providing a total storage capacity of 5.2 kg. The two-seat aircraft is designed to achieve a targeted flight range of 125 miles (200 km), approximately two hours of flight time, and a cruise speed of 100 mph (162 km/h). The technological setup mirrors concepts evaluated for larger hydrogen-electric aircraft by pairing sustained fuel-cell power with battery buffering.",\n "CELLSIUS first unveiled the fully integrated H2-Sling in late 2025 and conducted ground tests throughout 2026 while working through legal and regulatory requirements. This new development follows the team's earlier e-Sling project, a battery-electric aircraft that first flew in 2022 with a range of 112 miles (180 km). Local media reports suggest the maiden flight could occur by the end of August, though ETH Zurich has not confirmed the timing."\n@@ -28,7 +28,7 @@\n "title": "Laptop Fire Onboard American Airlines Flight Leaves at Least Four Injured",\n "summary": "American Airlines flight 2398 landed safely in Dallas after a laptop battery fire injured four people.",\n "body": [\n- "On August 21, 2026, American Airlines flight 2398 operating from Atl (ATL)anta to Dallas-Fort Worth (DFW) experienced an in-flight laptop battery fire. The flight crew reported smoke in the rear section of the cabin to air traffic control, and the cabin crew successfully extinguished the fire onboard.",\n+ "On August 21, 2026, American Airlines flight 2398 operating from Atlanta (ATL) to Dallas-Fort Worth (DFW) experienced an in-flight laptop battery fire. The flight crew reported smoke in the rear section of the cabin to air traffic control, and the cabin crew successfully extinguished the fire onboard.",\n "The aircraft, an Airbus A321 (ICAO type code A321), landed at Dallas-Fort Worth without further incident and taxied to its assigned gate, where medical personnel were waiting. At least four people suffered minor burns in the incident. At least one passenger received medical attention upon arrival in Texas, though no hospitalization was required."\n ]\n },\n@@ -20,7 +20,7 @@\n "title": "颱風沙德爾逼近 8月26日多家航空公司取消及延後沖繩航班",\n "summary": "受颱風沙德爾影響;8月26日沖繩航班取消或延後;桃機共16航班取消",\n "body": [\n- "受到颱風沙德爾影響,桃園國際機場公司表示,截至8月25日晚間6時,已知8月26日共有16班航班取消。取消航班包含樂桃航空、中華航空、長榮航空、星宇航空及泰國獅子航空等航空公司部分班機。",\n+ "受到颱風沙德爾影響,桃園國際機場(TPE)公司表示,截至8月25日晚間6時,已知8月26日共有16班航班取消。取消航班包含樂桃航空、中華航空、長榮航空、星宇航空及泰國獅子航空等航空公司部分班機。",\n "在沖繩航線方面,長榮航空宣布8月26日往返沖繩航班全天取消。桃園國際機場已知取消的班機包括長榮航空BR112與BR113、中華航空CI120與CI121、星宇航空JX890與JX891、泰國獅子航空SL380與SL381,以及樂桃航空6個航班。",\n "此外,台灣虎航與星宇航空調整8月26日飛往沖繩的航班時間。台灣虎航IT230、IT231、IT288與IT289航班改為延後起飛;星宇航空往返台北及台中至沖繩之JX870、JX871、JX302與JX303航班亦均改為延後出發。"\n ]\n@@ -29,7 +29,7 @@\n "title": "Typhoon Saudel Prompts Flight Cancellations and Delays to Okinawa for August 26",\n "summary": "Typhoon Saudel causes flight cancellations and delays on Okinawa routes for August 26.",\n "body": [\n- "Due to the impact of Typhoon Saudel, Taoyuan International Airport Corporation announced that 16 flights scheduled for August 26 have been canceled as of 6 p.m. on August 25. The affected carriers include Peach Aviation, China Airlines, EVA Air, STARLUX Airlines, and Thai Lion Air.",\n+ "Due to the impact of Typhoon Saudel, Taoyuan International Airport (TPE) Corporation announced that 16 flights scheduled for August 26 have been canceled as of 6 p.m. on August 25. The affected carriers include Peach Aviation, China Airlines, EVA Air, STARLUX Airlines, and Thai Lion Air.",\n "For Okinawa routes, EVA Air announced all flights between Taiwan and Okinawa on August 26 are canceled. Canceled flights at Taoyuan International Airport include EVA Air flights BR112 and BR113, China Airlines flights CI120 and CI121, STARLUX Airlines flights JX890 and JX891, Thai Lion Air flights SL380 and SL381, and six Peach Aviation flights.",\n "In addition, Tigerair Taiwan and STARLUX Airlines adjusted departure times for several August 26 Okinawa flights. Tigerair Taiwan postponed flights IT230, IT231, IT288, and IT289, while STARLUX Airlines rescheduled flights JX870, JX871, JX302, and JX303 to depart later."\n ]\n@@ -255,8 +255,8 @@\n "summary": "ANA starts Aug 21 remote 'newme' robot passenger support from KIX; tests show 98.4% of queries resolved remotely.",\n "body": [\n "All Nippon Airways (ANA) announced it will begin rolling out remote passenger support across multiple regional airports in Japan starting August 21, 2026, in partnership with avatarin, a Tokyo-based AI and robotics startup.",\n- "The service will use robots called \"newme\" operated centrally from Kansai International Airport (KIX) , making ANA the first Japanese airline to launch this kind of inter-airport remote support system.",\n- "ANA said the approach builds on extensive testing conducted at Haneda Airport (HND) , where researchers analyzed more than 25,000 customer interactions and found that 98.4% of passenger questions could be resolved remotely with the same quality as in-person service.",\n+ "The service will use robots called \"newme\" operated centrally from Kansai International Airport (KIX), making ANA the first Japanese airline to launch this kind of inter-airport remote support system.",\n+ "ANA said the approach builds on extensive testing conducted at Haneda Airport (HND), where researchers analyzed more than 25,000 customer interactions and found that 98.4% of passenger questions could be resolved remotely with the same quality as in-person service.",\n "The airline said it will evaluate the program's effect on customer satisfaction and operational performance before deciding whether to expand the service to additional airports."\n ]\n },\n@@ -7,15 +7,15 @@\n "primarySource": "Simple Flying",\n "image": "https://static0.simpleflyingimages.com/wordpress/wp-content/uploads/2026/08/simpleflying-aircraft-livery-1787609828711.jpg",\n "zh": {\n- "title": "Buffalo Airport油庫電路故障 航行通告顯示加油服務中斷近14小時",\n+ "title": "Buffalo Airport(BUF)油庫電路故障 航行通告顯示加油服務中斷近14小時",\n "summary": "根據航行通告,Buffalo Airport油庫電路故障後無加油服務近14小時。",\n "body": [\n "根據 Simple Flying 於2026年8月24日報導,Buffalo Airport 因油庫設施發生電路故障,致使機場燃油供應服務出現中斷。相關單位發布航行通告(NOTAM),針對機場地面無燃油服務狀況提供相關資訊。",\n "發布的航行通告明確劃定了 Buffalo Airport 無法提供燃油服務的時間窗口,時間長達近14小時。報導說明,該機場油庫(fuel farm)電路故障發生後,燃油服務暫停,使機場燃油在超過14小時內呈現「無法供應」(Not Available)狀態。"\n ]\n },\n "en": {\n- "title": "Buffalo Airport Faces 14-Hour Fuel Outage After Fuel Farm Circuit Failure",\n+ "title": "Buffalo Airport (BUF) Faces 14-Hour Fuel Outage After Fuel Farm Circuit Failure",\n "summary": "Buffalo Airport issued a NOTAM indicating fuel services were unavailable for nearly 14 hours.",\n "body": [\n "According to reporting by Simple Flying on August 24, 2026, Buffalo Airport experienced an outage of ground fuel services following a circuit failure at the airport fuel farm. An official Notice to Air Missions (NOTAM) was published to inform flight crews and aviation operations about the fuel service disruption.",\n@@ -16,20 +16,20 @@\n "subject": "註冊號 F-HREY"\n },\n "zh": {\n- "title": "蒙特婁機場停機坪發生作業人員遭飛機撞擊重傷事故",\n+ "title": "蒙特婁機場(YUL)停機坪發生作業人員遭飛機撞擊重傷事故",\n "summary": "蒙特婁特魯多國際機場一名地勤人員遭飛機撞擊重傷;警方與工安單位展開調查",\n "body": [\n- "加拿大蒙特婁皮埃爾·埃利奧特·特魯多國際機場(YUL)於2026年8月24日晚間發生一起嚴重地勤事故。一名在停機坪作業的工作人員遭飛機撞擊受重傷,隨後被緊急送往醫院接受治療。機場營運機構蒙特婁機場公司(Aéroports de Montréal,簡稱 ADM)證實,緊急應變程序於當地時間約 19:30 啟動,處理這起涉及飛機與機場工作人員的事故。",\n+ "加拿大蒙特婁皮埃爾·埃利奧特·特魯多國際機場於2026年8月24日晚間發生一起嚴重地勤事故。一名在停機坪作業的工作人員遭飛機撞擊受重傷,隨後被緊急送往醫院接受治療。機場營運機構蒙特婁機場公司(Aéroports de Montréal,簡稱 ADM)證實,緊急應變程序於當地時間約 19:30 啟動,處理這起涉及飛機與機場工作人員的事故。",\n "根據加拿大廣播公司(Radio-Canada)報導,受傷的工作人員是一名地面引導員(signaller),負責在停機坪引導飛機進出機位。蒙特婁市警局(SPVM)隨後證實該名人員傷勢嚴重。事故發生時,法國蜜蜂航空(French bee)一架註冊號碼為 F-HREY 的空中巴士 A350-900 客機正準備執行從蒙特婁飛往巴黎奧利機場(ORY)的 BF5761 班機。該航班原定於當地時間 18:00 起飛,但在事故發生後並未依計畫離港。",\n "航班紀錄顯示,涉事的 A350-900 客機(F-HREY)先前於 2026 年 8 月 22 日晚間 21:27 執飛 BF760 班機抵達蒙特婁,其原定於當晚 23:00 返回巴黎的 BF761 航班遭取消,據報係因技術問題滯留。隨後,法國蜜蜂航空另派一架註冊號碼為 F-HREU 的 A350-900 客機執行預定的 BF761 航班,並於 8 月 25 日凌晨 00:11 起飛離港。",\n "監管與調查處置方面,蒙特婁市警局表示,由於該起事件屬於職場工安事故,案件將移交魁北克省勞工健康與安全委員會(CNESST)接手調查。此外,加拿大運輸安全委員會(TSB)發言人亦證實調查人員已獲知該起事故,截至 8 月 25 日,TSB 尚未發布針對此事件的派員調查通知。機場營運商表示,整體機場營運在事故後仍持續運作,並建議旅客向航空公司確認最新航班動態。"\n ]\n },\n "en": {\n- "title": "Ground Worker Seriously Injured After Being Struck by Aircraft at Montreal Airport",\n+ "title": "Ground Worker Seriously Injured After Being Struck by Aircraft at Montreal Airport (YUL)",\n "summary": "French bee A350 incident at Montreal Trudeau Airport leaves ground signaller seriously injured; investigation underway.",\n "body": [\n- "A ground worker was seriously injured after being struck by an aircraft on the apron at Montreal-Pierre Elliott Trudeau International Airport (YUL) on the evening of August 24, 2026. Airport operator Aéroports de Montréal (ADM) confirmed that emergency services responded around 19:30 local time following the accident involving an aircraft and a member of the airport community. The injured employee was immediately transported to a hospital for medical treatment.",\n+ "A ground worker was seriously injured after being struck by an aircraft on the apron at Montreal-Pierre Elliott Trudeau International Airport on the evening of August 24, 2026. Airport operator Aéroports de Montréal (ADM) confirmed that emergency services responded around 19:30 local time following the accident involving an aircraft and a member of the airport community. The injured employee was immediately transported to a hospital for medical treatment.",\n "According to Radio-Canada, the worker was a signaller responsible for guiding aircraft on and off airport stands. The Service de police de la Ville de Montréal (SPVM) confirmed the individual sustained serious injuries. The incident occurred while French bee Airbus A350-900 (registration F-HREY) was preparing to operate flight BF5761 to Paris Orly Airport (ORY). The flight had been scheduled to depart at 18:00 local time but did not leave following the occurrence.",\n "Flight records indicate that F-HREY had previously arrived in Montreal from Orly as flight BF760 on August 22 at 21:27. Its scheduled return leg, flight BF761, was canceled that evening due to a reported technical issue. French bee subsequently dispatched another Airbus A350-900, registered as F-HREU, to fulfill the scheduled BF761 service, which departed Montreal at 00:11 on August 25.",\n "Because the event is classified as an industrial workplace accident, the SPVM stated the investigation will be transferred to Quebec’s workplace safety regulator, the Commission des normes, de l’équité, de la santé et de la sécurité du travail (CNESST). A spokesperson for the Transportation Safety Board of Canada (TSB) confirmed its investigators were notified of the incident, though no official deployment notice had been issued as of August 25. ADM stated that general airport operations continued normally and advised passengers to verify flight schedules with their respective airlines."\n@@ -21,16 +21,16 @@\n "summary": "德國當局於萊比錫機場附近發現第3架無人機與炸藥;檢警已展開調查",\n "body": [\n "德國執法當局於萊比錫/哈雷機場西側的卡貝爾斯克塔爾(Kabelsketal)農田發現第3架無人機,並於次日於同一地點發現約50公克的黑索金(hexogen)炸藥。相關設備與炸藥已由鑑識人員進行分析。",\n- "此項發現距首起事件約10天。8月4日,萊比錫/哈雷機場(HAJ)附近曾發現一架載有炸藥的無人機,位置接近停放的安托諾夫航空(Antonov Airlines)An-124貨機。隨後在機場關閉期間,第2架無人機於轉降過程中撞擊一架DHL貨機,造成該貨機尾翼受損並降落於漢諾威機場。",\n+ "此項發現距首起事件約10天。8月4日,萊比錫/哈雷機場附近曾發現一架載有炸藥的無人機,位置接近停放的安托諾夫航空(Antonov Airlines)An-124貨機。隨後在機場關閉期間,第2架無人機於轉降過程中撞擊一架DHL貨機,造成該貨機尾翼受損並降落於漢諾威機場(HAJ)。",\n "此外,執法人員亦於8月11日在機場周邊發現馬達與旋翼等無人機零組件。德國聯邦刑事警察局(BKA)已受聯邦總檢察長羅梅爾(Jens Rommel)委託對全案展開調查。"\n ]\n },\n "en": {\n- "title": "Third Drone and Explosives Discovered Near Leipzig Airport (LEJ)",\n+ "title": "Third Drone and Explosives Discovered Near Leipzig Airport",\n "summary": "German authorities found a third drone and explosives near Leipzig Airport; federal police are investigating the incident.",\n "body": [\n- "German authorities discovered a third unmanned aerial vehicle in a field in Kabelsketal, west of Leipzig/Halle Airport , on August 14, 2026. Forensic investigators subsequently located approximately 50 grams of hexogen explosive at the same site the following day.",\n- "The discovery follows earlier incidents near the airport. On August 4, 2026, an explosive-laden drone was found near a parked Antonov Airlines An-124. Shortly after, a second drone struck a DHL cargo aircraft following the temporary closure of the airport, causing tail section damage prior to its diversion landing at Hanover Airport (HAJ) .",\n+ "German authorities discovered a third unmanned aerial vehicle in a field in Kabelsketal, west of Leipzig/Halle Airport (LEJ), on August 14, 2026. Forensic investigators subsequently located approximately 50 grams of hexogen explosive at the same site the following day.",\n+ "The discovery follows earlier incidents near the airport. On August 4, 2026, an explosive-laden drone was found near a parked Antonov Airlines An-124. Shortly after, a second drone struck a DHL cargo aircraft following the temporary closure of the airport, causing tail section damage prior to its diversion landing at Hanover Airport (HAJ).",\n "Investigators also recovered additional drone components, including a motor and rotor, near the airport on August 11, 2026. The Federal Criminal Police Office is conducting an investigation into the incidents on behalf of Federal Prosecutor General Jens Rommel."\n ]\n },\n@@ -259,7 +259,7 @@\n "summary": "Aircalin's first Airbus A350-900 has been spotted in Toulouse ahead of late 2026 delivery.",\n "body": [\n "According to AeroTime, Aircalin’s first Airbus A350-900 has been spotted for the first time in Toulouse, France, fresh out of the paint shop in its iconic colorful livery. This is the first of two A350-900s that the New Caledonia-based carrier has on order from Airbus, with the second aircraft expected to follow in 2027 or 2028.",\n- "The first A350-900 is expected to be delivered to Aircalin towards the end of 2026. Once delivered, the aircraft will be deployed on the carrier's long-haul route connecting Nou (NOU)mea to Paris Charles de Gaulle (CDG (CDG)) via Bangkok (BKK (BKK)). The aircraft will feature a three-class cabin layout configured with 325 seats, including 32 business class, 24 premium economy, and 269 economy seats."\n+ "The first A350-900 is expected to be delivered to Aircalin towards the end of 2026. Once delivered, the aircraft will be deployed on the carrier's long-haul route connecting Noumea (NOU) to Paris Charles de Gaulle (CDG) via Bangkok (BKK). The aircraft will feature a three-class cabin layout configured with 325 seats, including 32 business class, 24 premium economy, and 269 economy seats."\n ]\n },\n "sources": [\n@@ -16,7 +16,7 @@\n ]\n },\n "en": {\n- "title": "LAX (LAX) Caterer Serving 15 Airlines Faces Hygiene Allegations",\n+ "title": "Los Angeles International Airport (LAX) Caterer Serving 15 Airlines Faces Hygiene Allegations",\n "summary": "LAX airline caterer serving 15 carriers faces scrutiny over allegations of mold, maggots, and poor conditions.",\n "body": [\n "An airline catering company supplying in-flight meal services to at least 15 airlines at Los Angeles International Airport is under scrutiny following allegations concerning its hygiene practices and working conditions.",\n@@ -93,7 +93,7 @@\n "title": "聯合航空公布A321XLR首批國際航線",\n "summary": "聯合航空宣布12月起以A321XLR飛航阿姆斯特丹與都柏林",\n "body": [\n- "聯合航空(United Airlines)於8月25日在紐瓦克自由國際機場(EWR)宣布,其空中巴士A321XLR(ICAO機型代碼A21N)的首批國際航班將於2026年12月1日起營運。這些航線將從華盛頓杜勒斯國際機場(IAD)(IHD)出發,飛往荷蘭阿姆斯特丹和愛爾蘭都柏林。",\n+ "聯合航空(United Airlines)於8月25日在紐瓦克自由國際機場(EWR)宣布,其空中巴士A321XLR(ICAO機型代碼A21N)的首批國際航班將於2026年12月1日起營運。這些航線將從華盛頓杜勒斯國際機場(IAD)出發,飛往荷蘭阿姆斯特丹和愛爾蘭都柏林。",\n "該航空公司表示,A321XLR航程約4,700海浬,並計畫於2026年9月起先開通國內航線。此外,聯合航空也公布2027年夏季新增10個目的地,其中5個將由A321XLR營運,包括從紐瓦克飛往盧森堡市(LUX)、伊維薩(IBZ)、瓦倫西亞(VLC)、馬賽(MRS),以及從杜勒斯飛往土魯斯(TLS)。",\n "根據ch-aviation資料,聯合航空目前僅接收一架A321XLR,尚未投入商業營運;公司預計到2026年底再接收5架。"\n ]\n@@ -102,7 +102,7 @@\n "title": "United confirms first international destinations for ‘Born to Explore’ A321XLR",\n "summary": "United Airlines will start A321XLR flights to Amsterdam and Dublin from December 1, 2026.",\n "body": [\n- "United Airlines announced on August 25, 2026, at Newark Liberty International Airport (EWR) that its first international Airbus A321XLR flights will start on December 1, 2026. The new aircraft will operate from Washington Dulles International Airport (IAD) (IHD) to Amsterdam and Dublin, following the opening of domestic routes in September 2026.",\n+ "United Airlines announced on August 25, 2026, at Newark Liberty International Airport (EWR) that its first international Airbus A321XLR flights will start on December 1, 2026. The new aircraft will operate from Washington Dulles International Airport (IAD) to Amsterdam and Dublin, following the opening of domestic routes in September 2026.",\n "The Airbus A321XLR can fly up to around 4,700 nautical miles. United also introduced 10 new destinations for summer 2027, five of which will be operated by the A321XLR. These include services from Newark to Luxembourg City (LUX), Ibiza (IBZ), Valencia (VLC), and Marseille (MRS), and from IHD to Toulouse Airport (TLS).",\n "According to ch-aviation, United has so far taken delivery of only one Airbus A321XLR, which has not yet entered commercial service. The airline expects to receive five more by the end of 2026."\n ]\n@@ -7,15 +7,15 @@\n "primarySource": "Simple Flying",\n "image": "https://static0.simpleflyingimages.com/wordpress/wp-content/uploads/2024/01/shutterstock_2418111189.jpg",\n "zh": {\n- "title": "蒙特婁機場員工遭French Bee客機撞擊身亡",\n+ "title": "蒙特婁機場(YUL)員工遭French Bee客機撞擊身亡",\n "summary": "蒙特婁機場一名Samsic員工遭後退的客機撞擊不幸身亡",\n "body": [\n "根據外媒 Simple Flying 報導,蒙特婁機場(Montreal Airport)發生一起嚴重的地面安全事故。一名任職於 Samsic 的機場員工,在遭遇到一架正在進行後退作業的 French Bee 航空公司客機碰撞後不幸宣告身亡。",\n "在今日稍早的相關報導中,航空媒體 AeroTime 曾指出該名蒙特婁機場員工在被飛機撞擊後受到重傷。然而隨後的消息證實,該名 Samsic 員工已不幸因傷勢過重而去世。目前這起事故的具體發生原因與後續安全調查細節仍有待相關部門進一步釐清。"\n ]\n },\n "en": {\n- "title": "Montreal Airport Employee Struck and Killed by Backing French Bee Aircraft",\n+ "title": "Montreal Airport (YUL) Employee Struck and Killed by Backing French Bee Aircraft",\n "summary": "French Bee aircraft strikes and kills Samsic employee while backing up at Montreal Airport",\n "body": [\n "An airport worker has tragically died after being struck by a commercial aircraft at Montreal Airport. According to reports from Simple Flying, the victim was an employee of Samsic who was hit by a French Bee aircraft while the airplane was backing up on the ground.",\n@@ -16,7 +16,7 @@\n ]\n },\n "en": {\n- "title": "Air France La Premiere First Class Slated for San Francisco (SFO) Debut, Now Serves Seven US Cities",\n+ "title": "Air France La Premiere First Class Slated for San Francisco Debut, Now Serves Seven US Cities",\n "summary": "Air France La Premiere to debut in San Francisco; now serves seven US cities",\n "body": [\n "According to aviation news outlet Simple Flying, which published its article on August 25, 2026, Air France's flagship first class product known as La Premiere is slated to make its debut in San Francisco, and this expansion will result in the airline offering its ultra-premium first class service in a total of seven cities across the United States.",\n@@ -20,7 +20,7 @@\n "title": "杜拜國際機場(DXB)2026上半年旅客量跌逾三成",\n "summary": "杜拜國際機場2026年上半年因衝突導致旅客量下降超過三成",\n "body": [\n- "杜拜國際機場(DWC)在2026年上半年錄得旅客數量大幅減少,機場營運商將此下滑歸因於破壞區域航空旅行的軍事衝突。營運杜拜國際機場與阿勒馬克圖姆國際機場的杜拜機場公司(Dubai Airports)於2026年8月26日表示,2026年上半年對該公司而言是一段充滿挑戰的時期。",\n+ "杜拜國際機場在2026年上半年錄得旅客數量大幅減少,機場營運商將此下滑歸因於破壞區域航空旅行的軍事衝突。營運杜拜國際機場與阿勒馬克圖姆國際機場(DWC)的杜拜機場公司(Dubai Airports)於2026年8月26日表示,2026年上半年對該公司而言是一段充滿挑戰的時期。",\n "在2026年1月至6月期間,杜拜國際機場共接待了3,150萬人次的旅客,與2025年同期相比下降了31.3%。該機場的起降架次在今年上半年總計為150,600架次,年減32.1%;與此同時,貨運量在上半年達到751,340噸,同比下降了28.7%。",\n "此次營運下滑的主因為美國、以色列與伊朗之間於2026年2月28日爆發的軍事衝突,該衝突使中東及灣區陷入動盪。由於伊朗向該地區國家(包括杜拜)發射了飛彈與無人機,出於安全考量,許多航空公司選擇繞道避開該區域。",\n "儘管上半年面臨挑戰,杜拜機場公司執行長保羅·格里菲斯(Paul Griffiths)強調,隨著旅客信心增強和人數回升,情況正在改善。格里菲斯指出,上半年的形勢考驗了航空系統的各個環節,但隨著運能穩步恢復,需求隨即做出反應。在第二季度中,旅客量逐月增長,從2026年4月的350萬人次增加到5月的450萬人次,並在6月達到500萬人次。",\n@@ -31,11 +31,11 @@\n "title": "Dubai International Airport (DXB) Passenger Traffic Falls Over 31% in First Half of 2026",\n "summary": "Dubai International Airport passenger traffic dropped 31.3% in first half of 2026 amid regional conflict.",\n "body": [\n- "Dubai International Airport recorded a significant decrease in its passenger numbers during the first half of 2026, with airport operators attributing the decline to ongoing regional military conflicts that have disrupted air travel. According to Dubai Airports, which manages both Dubai International and Al Maktoum International (DWC (DWC)), the first six months of 2026 represented a challenging period for aviation operations in the region.",\n- "From January to June 2026, Dubai International welcomed 31.5 million passengers, marking a 31.3% decline compared to the corresponding period in 2025. Flight movements at the airport also experienced a downturn, totaling 150,600 aircraft movements during the six-month period, which is a 32.1% reduction year-on-year. Furthermore, cargo volumes handled at the hub decreased by 28.7% year-on-year to 751,340 tons.",\n+ "Dubai International Airport recorded a significant decrease in its passenger numbers during the first half of 2026, with airport operators attributing the decline to ongoing regional military conflicts that have disrupted air travel. According to Dubai Airports, which manages both Dubai International and Al Maktoum International (DWC), the first six months of 2026 represented a challenging period for aviation operations in the region.",\n+ "From January to June 2026, Dubai International welcomed 31.5 million passengers, marking a 31.3% decline compared to the corresponding period in 2025. Flight movements at the airport also experienced a downturn, totaling 150,600 aircraft movements during the six-month period, which is a 32.1% reduction year-on-year. Furthermore, cargo volumes handled at the hub decreased by 28.7% year-on-year to 751,340 tons.",\n "The operational decline followed the outbreak of a military conflict involving the United States, Israel, and Iran on February 28, 2026, which impacted the Middle East and Gulf regions. Iran launched missiles and unmanned aerial vehicles against regional targets, including Dubai. Consequently, multiple international airlines opted to bypass the airspace in the area to minimize risks.",\n "Despite the challenging start to the year, Dubai Airports CEO Paul Griffiths emphasized that the situation has begun to stabilize. Griffiths stated that the first half of the year tested the entire aviation ecosystem, but demand has responded quickly as capacity returns. Passenger numbers showed a steady month-on-month increase throughout the second quarter, rising from 3.5 million in April 2026 to 4.5 million in May, and reaching 5 million in June.",\n- "Looking ahead, Dubai Airports expressed optimism for the remaining half of 2026. A spokesperson noted that momentum is expected to build due to the recovery of airline networks, an increase in international transit traffic, the winter flight schedule, and Dubai's schedule of business, leisure, and sporting events. In 2025, Dubai International handled a record-breaking 95.2 million guests, which was the highest annual international passenger volume recorded by any airport."\n+ "Looking ahead, Dubai Airports expressed optimism for the remaining half of 2026. A spokesperson noted that momentum is expected to build due to the recovery of airline networks, an increase in international transit traffic, the winter flight schedule, and Dubai's schedule of business, leisure, and sporting events. In 2025, Dubai International handled a record-breaking 95.2 million guests, which was the highest annual international passenger volume recorded by any airport."\n ]\n },\n "sources": [\n@@ -42,7 +42,7 @@ it has a stable historical commit.\n "items": [\n {\n "title": "FAA Statement on ...", // plain text, no HTML\n- "url": "https://www.faa.gov/...", // original link (Google News links resolved when possible)\n+ "url": "https://www.faa.gov/...", // direct original link (unresolved Google News links are dropped)\n "publishedUtc": "2026-07-26T04:41Z", // best-effort; fetchedUtc when unknown\n "summary": "First ~500 chars of plain-text summary/description",\n "image": null // og:image / enclosure URL when cheaply available\n@@ -300,6 +300,9 @@ Briefings select only from data/articles/ (already verified/published);\n failed runs never modify briefing files and never downgrade a\n published/partial index row. build.py renders /briefings/ and\n /briefings/<id>/ for published|partial only.\n+Briefing item source links must be direct original `http(s)` URLs; unsafe\n+schemes and Google News aggregator links are discovery-only and are dropped\n+before persistence or reader-facing rendering.\n \n ## data/images.json + article "image" (written by images.py)\n \n@@ -48,6 +48,25 @@\n "當地機場", "主要機場", "相關機場", "國際機場", "一座機場", "多座機場",\n "該座機場", "這座機場",\n }\n+_MILITARY_BASE_RE = re.compile(\n+ r"\b(?:air force|air national guard|air base|air station|"\n+ r"military base|radar site|afb)\b|空軍基地|軍事基地|雷達站",\n+ re.I,\n+)\n+_AIRPORT_IDENTITY_RE = re.compile(\n+ r"\b(?:airport|airports)\b|機場",\n+ re.I,\n+)\n+\n+\n+def _console_safe(value: object) -> str:\n+ """Render diagnostics even when Windows stdout uses a narrow code page."""\n+ text = str(value)\n+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"\n+ try:\n+ return text.encode(encoding, errors="backslashreplace").decode(encoding)\n+ except (LookupError, UnicodeError):\n+ return text.encode("ascii", errors="backslashreplace").decode("ascii")\n \n \n def _norm(value: str) -> str:\n@@ -126,57 +145,116 @@ def _reviewed_aliases() -> dict[str, str]:\n \n \n @lru_cache(maxsize=32)\n-def _reviewed_alias_names(code: str) -> tuple[str, ...]:\n- """Return reviewed Traditional Chinese aliases for one IATA code."""\n+def _reviewed_alias_values(code: str) -> tuple[str, ...]:\n+ """Return every reviewed display alias for one IATA code."""\n cfg = load_json(ROOT / "config" / "airport_iata_aliases.json", {})\n target = str(code or "").strip().upper()\n names = []\n for alias, alias_code in (cfg.get("aliases") or {}).items():\n if str(alias_code or "").strip().upper() != target:\n continue\n value = str(alias or "").strip()\n- if value and re.search(r"[\u3400-\u9fff]", value):\n+ if value:\n names.append(value)\n return tuple(names)\n \n \n+@lru_cache(maxsize=32)\n+def _reviewed_alias_names(code: str) -> tuple[str, ...]:\n+ """Return reviewed Traditional Chinese aliases for one IATA code."""\n+ return tuple(value for value in _reviewed_alias_values(code)\n+ if re.search(r"[\u3400-\u9fff]", value))\n+\n+\n+def _strip_iata_annotation(value: str) -> str:\n+ """Remove a trailing parenthesized or bare IATA token from an entity."""\n+ text = str(value or "").strip()\n+ text = re.sub(r"\s*[((]\s*[A-Z]{3}\s*[))]\s*$", "", text)\n+ text = re.sub(r"\s+\b[A-Z]{3}\b\s*$", "", text)\n+ return text.strip()\n+\n+\n+def _row_for_code(code: str, rows: dict[str, dict], tw_rows: dict[str, dict]):\n+ code = str(code or "").strip().upper()\n+ if code not in rows and code not in tw_rows:\n+ return None\n+ row = dict(rows.get(code) or {})\n+ row.update(tw_rows.get(code) or {})\n+ row["code"] = code\n+ return row\n+\n+\n def resolve_airport(entity: str) -> dict | None:\n """Resolve a verified airport entity without fuzzy guessing."""\n rows, aliases, tw_rows = _registry()\n raw = str(entity or "").strip()\n if not raw:\n return None\n \n- for token in re.findall(r"\b[A-Z]{3}\b", raw):\n- if token in rows or token in tw_rows:\n- row = dict(rows.get(token) or {})\n- row.update(tw_rows.get(token) or {})\n- row["code"] = token\n+ # Resolve the textual identity before trusting an embedded code. Source\n+ # metadata has previously paired a correct airport name with another\n+ # airport's code (for example Philadelphia International Airport (PNE)).\n+ # The name is the authoritative part when it resolves uniquely.\n+ identity = _strip_iata_annotation(raw)\n+ reviewed_code = _reviewed_aliases().get(_norm(identity))\n+ if reviewed_code:\n+ row = _row_for_code(reviewed_code, rows, tw_rows)\n+ if row:\n+ return row\n+ # Reviewed aliases may intentionally cover a retired or newly named\n+ # airport that is not present in the bundled airportsdata release.\n+ return {"code": reviewed_code, "name": identity,\n+ "name_en": identity}\n+\n+ tokens = re.findall(r"\b[A-Z]{3}\b", raw)\n+ if _norm(identity) == _norm(raw) and len(tokens) == 1:\n+ return _row_for_code(tokens[0], rows, tw_rows)\n+ # The registry's name-core index intentionally supports full names such\n+ # as "San Francisco International Airport", but that core must not turn\n+ # a city-only entity into an airport identity.\n+ if not (_AIRPORT_IDENTITY_RE.search(identity)\n+ or re.search(r"[機場]", identity)):\n+ return None\n+\n+ candidates = aliases.get(_norm(identity), set())\n+ if len(candidates) == 1:\n+ return _row_for_code(next(iter(candidates)), rows, tw_rows)\n+\n+ # An exact code, or an otherwise unresolved entity carrying an explicit\n+ # code, remains usable as a last resort. This branch is deliberately\n+ # after name resolution so a bad annotation cannot override a known name.\n+ for token in tokens:\n+ row = _row_for_code(token, rows, tw_rows)\n+ if row:\n return row\n+ return None\n \n- reviewed_code = _reviewed_aliases().get(_norm(raw))\n- if reviewed_code and (reviewed_code in rows or reviewed_code in tw_rows):\n- row = dict(rows.get(reviewed_code) or {})\n- row.update(tw_rows.get(reviewed_code) or {})\n- row["code"] = reviewed_code\n- return row\n \n- candidates = aliases.get(_norm(raw), set())\n- if len(candidates) != 1:\n- return None\n- code = next(iter(candidates))\n- row = dict(rows.get(code) or {})\n- row.update(tw_rows.get(code) or {})\n- row["code"] = code\n- return row\n+def _looks_like_airport_entity(name: str) -> bool:\n+ """Decide whether an unresolved entity merits an editorial warning.\n+\n+ City-only mentions and named military installations are common entity\n+ extraction noise. They are not treated as missing civilian IATA codes\n+ unless the value explicitly identifies an airport or carries a code.\n+ """\n+ text = str(name or "").strip()\n+ if not text:\n+ return False\n+ if re.search(r"\b[A-Z]{3}\b", text):\n+ return True\n+ if _MILITARY_BASE_RE.search(text):\n+ return bool(_AIRPORT_IDENTITY_RE.search(text))\n+ return bool(_AIRPORT_IDENTITY_RE.search(text)\n+ or re.search(r"[機場]", text))\n \n \n def _slots(article: dict, lang: str):\n block = article.get(lang)\n if not isinstance(block, dict):\n return []\n- out = [("title", None, str(block.get("title") or "")),\n- ("summary", None, str(block.get("summary") or ""))]\n+ # The article page renders title + body. Summaries are index/search\n+ # metadata and must not consume the first-mention code invisibly.\n+ out = [("title", None, str(block.get("title") or ""))]\n for index, paragraph in enumerate(block.get("body") or []):\n out.append(("body", index, str(paragraph or "")))\n return out\n@@ -192,18 +270,28 @@ def _set_slot(article: dict, lang: str, key: str, index: int | None,\n \n def _airport_patterns(entity: str, row: dict) -> list[re.Pattern]:\n values = []\n- for value in (entity, row.get("name_en"), row.get("name")):\n- value = str(value or "").strip()\n+ entity_value = _strip_iata_annotation(str(entity or "").strip())\n+ for value in (entity_value, row.get("name_en"), row.get("name"),\n+ *_reviewed_alias_values(str(row.get("code") or ""))):\n+ value = _strip_iata_annotation(str(value or "").strip())\n+ # An ICAO/IATA-only entity is metadata, not the visible airport\n+ # identity. Matching it first would place TPE after "ICAO code\n+ # RCTP" instead of after the airport name.\n+ if value == entity_value and re.fullmatch(r"[A-Z]{3,4}", value):\n+ continue\n if value and value not in values:\n values.append(value)\n- city = str(row.get("city") or "").strip()\n- patterns = [re.compile(re.escape(value), re.I) for value in values]\n- if city:\n- city_re = re.escape(city)\n- patterns.append(re.compile(\n- rf"\b{city_re}\b(?:['’]s)?(?:\s+[A-Za-z0-9'’.-]+){{0,4}}\s+airport\b",\n- re.I,\n- ))\n+ patterns = []\n+ for value in values:\n+ variants = {value}\n+ # Accept normal source punctuation variants such as\n+ # London-Gatwick/London Gatwick and Leipzig/Halle/Leipzig-Halle,\n+ # without broadening a named airport into every airport in that city.\n+ spaced = re.sub(r"\s*[-–—/]\s*", " ", value)\n+ if spaced:\n+ variants.add(spaced)\n+ for variant in variants:\n+ patterns.append(re.compile(re.escape(variant), re.I))\n return patterns\n \n \n@@ -230,14 +318,14 @@ def _zh_candidates(text: str):\n \n \n def _find_zh_identity_match(text: str, entity: str, row: dict):\n- """Find a reviewed Chinese airport name, including common short forms."""\n+ """Find a reviewed airport identity in a translated visible slot."""\n values = []\n- for value in (entity, row.get("name_zh")):\n- value = str(value or "").strip()\n- if value and re.search(r"[\u3400-\u9fff]", value) and value not in values:\n- values.append(value)\n- for value in _reviewed_alias_names(str(row.get("code") or "")):\n- if value not in values:\n+ for value in (entity, row.get("name_zh"), row.get("name_en"),\n+ row.get("name"), *_reviewed_alias_values(\n+ str(row.get("code") or ""))):\n+ value = _strip_iata_annotation(str(value or "").strip())\n+ if (value and not re.fullmatch(r"[A-Z]{3,4}", value)\n+ and value not in values):\n values.append(value)\n \n patterns = []\n@@ -257,28 +345,142 @@ def _find_zh_identity_match(text: str, entity: str, row: dict):\n \n def _insert_code(text: str, start: int, end: int, code: str, lang: str) -> str:\n tail = text[end:]\n- immediate = re.match(r"\s*[((]\s*([A-Z]{3})\s*[))]", tail)\n- if immediate and immediate.group(1) == code:\n- return text\n+ if lang == "zh":\n+ # Chinese copy often places the English airport name in a second\n+ # parenthetical immediately after the Chinese name. Merge the IATA\n+ # code into that existing bilingual parenthetical instead of creating\n+ # adjacent or nested parentheses.\n+ bilingual_existing = re.match(\n+ r"(?P<space>\s*)[((]\s*(?P<old>[A-Z]{3})\s*[;;]\s*"\n+ r"(?P<content>[^()()]*[A-Za-z][^()()]*)[))]",\n+ tail,\n+ )\n+ if bilingual_existing:\n+ content = bilingual_existing.group("content").strip()\n+ leading = bilingual_existing.group("space")\n+ if bilingual_existing.group("old") == code:\n+ return text\n+ return (text[:end] + leading + f"({code};{content})"\n+ + tail[bilingual_existing.end():])\n+ bilingual_after_code = re.match(\n+ r"(?P<space>\s*)[((]\s*(?P<old>[A-Z]{3})\s*[))]"\n+ r"\s*[((](?P<content>[^()()]*[A-Za-z][^()()]*)[))]",\n+ tail,\n+ )\n+ if bilingual_after_code:\n+ content = bilingual_after_code.group("content").strip()\n+ if not re.fullmatch(r"[A-Z]{3}", content):\n+ return (text[:end] + f"({code};{content})"\n+ + tail[bilingual_after_code.end():])\n+ bilingual_nested = re.match(\n+ r"(?P<space>\s*)[((](?P<content>[^()()]*[A-Za-z][^()()]*)"\n+ r"[((]\s*(?P<old>[A-Z]{3})\s*[))]\s*[))]",\n+ tail,\n+ )\n+ if bilingual_nested:\n+ content = bilingual_nested.group("content").strip()\n+ if not re.fullmatch(r"[A-Z]{3}", content):\n+ return (text[:end] + f"({code};{content})"\n+ + tail[bilingual_nested.end():])\n+ bilingual_plain = re.match(\n+ r"(?P<space>\s*)[((](?P<content>[^()()]*[A-Za-z][^()()]*)[))]",\n+ tail,\n+ )\n+ if bilingual_plain:\n+ content = bilingual_plain.group("content").strip()\n+ if (not re.fullmatch(r"[A-Z]{3}", content)\n+ and not re.search(r"\b(?:IATA|ICAO)\b", content, re.I)):\n+ return (text[:end] + f"({code};{content})"\n+ + tail[bilingual_plain.end():])\n+ nested = re.match(\n+ r"(?P<space>\s*)[((]\s*(?P<outer>[A-Z]{3})\s*"\n+ r"[((]\s*(?P=outer)\s*[))]\s*[))]",\n+ tail,\n+ )\n+ if nested:\n+ leading = nested.group("space")\n+ replacement = (leading + f"({code})" if lang == "zh"\n+ else (leading or " ") + f"({code})")\n+ return text[:end] + replacement + tail[nested.end():]\n+ immediate = re.match(\n+ r"(?P<space>\s*)(?:[((]\s*[A-Z]{3}\s*[))])"\n+ r"(?:\s*[((]\s*[A-Z]{3}\s*[))])*",\n+ tail,\n+ )\n+ if immediate:\n+ codes = re.findall(r"[((]\s*([A-Z]{3})\s*[))]",\n+ immediate.group(0))\n+ if len(codes) == 1 and codes[0] == code:\n+ return text\n+ leading = immediate.group("space")\n+ replacement = (leading + f"({code})" if lang == "zh"\n+ else (leading or " ") + f"({code})")\n+ return text[:end] + replacement + tail[immediate.end():]\n token = f"({code})" if lang == "zh" else f" ({code})"\n return text[:end] + token + text[end:]\n \n \n-def _dedupe_code(article: dict, lang: str, code: str) -> None:\n- seen = False\n- pattern = re.compile(rf"[((]\s*{re.escape(code)}\s*[))]")\n+def _code_annotation_pattern(code: str) -> re.Pattern:\n+ return re.compile(\n+ rf"(?P<leading>\s*)[((]\s*{re.escape(code)}\s*"\n+ r"(?:(?:[;;])\s*(?P<content>[^()()]*))?[))]"\n+ )\n+\n+\n+def _first_identity_anchor(article: dict, lang: str, entity: str,\n+ row: dict):\n+ if lang == "en":\n+ return _find_en_match(article, entity, row)\n+ for key, index, text in _slots(article, "zh"):\n+ match = _find_zh_identity_match(text, entity, row)\n+ if match:\n+ return key, index, match.start(), match.end()\n+ return None\n+\n+\n+def _dedupe_code(article: dict, lang: str, code: str, entity: str,\n+ row: dict) -> None:\n+ """Keep a code only when it annotates the first resolved identity."""\n+ pattern = _code_annotation_pattern(code)\n+ anchor = _first_identity_anchor(article, lang, entity, row)\n+ if not anchor:\n+ # Code-only entities such as route endpoints may already be correctly\n+ # annotated in the copy even though no full airport name is present.\n+ # Without an identity anchor there is no safe basis for deleting any\n+ # existing annotation.\n+ return\n+ target = None\n+ key, index, _start, end = anchor\n+ text = _slot_text(article, lang, key, index)\n+ immediate = pattern.match(text[end:])\n+ if immediate:\n+ target = (key, index, end + immediate.start())\n+\n for key, index, text in _slots(article, lang):\n def repl(match):\n- nonlocal seen\n- if not seen:\n- seen = True\n+ if target == (key, index, match.start()):\n return match.group(0)\n+ content = match.group("content")\n+ if content is not None:\n+ return f"({content.strip()})"\n return ""\n+\n cleaned = pattern.sub(repl, text)\n if cleaned != text:\n _set_slot(article, lang, key, index, cleaned)\n \n \n+def _clean_punctuation_spacing(text: str) -> str:\n+ """Remove source-copy spaces left immediately before punctuation."""\n+ return re.sub(r"[ \t]+([,.;:!?,。;:!?])", r"\1", text)\n+\n+\n+def _visible_has_code(article: dict, lang: str, code: str) -> bool:\n+ pattern = _code_annotation_pattern(code)\n+ return any(pattern.search(text) for _key, _index, text in\n+ _slots(article, lang))\n+\n+\n def enforce_article(article: dict) -> tuple[bool, list[str]]:\n """Add first-mention IATA codes; return (changed, unresolved entities)."""\n entities = article.get("entities") or {}\n@@ -293,7 +495,7 @@ def enforce_article(article: dict) -> tuple[bool, list[str]]:\n name = str(entity or "").strip()\n row = resolve_airport(name)\n if not row:\n- if name:\n+ if name and _looks_like_airport_entity(name):\n unresolved.append(name)\n continue\n code = str(row["code"])\n@@ -323,46 +525,48 @@ def enforce_article(article: dict) -> tuple[bool, list[str]]:\n new_text = _insert_code(text, start, end, code, "en")\n changed |= new_text != text\n text = new_text\n+ cleaned = _clean_punctuation_spacing(text)\n+ changed |= cleaned != text\n+ text = cleaned\n _set_slot(article, "en", key, index, text)\n \n # Chinese: pair airport mentions with resolved English mentions in the\n # equivalent title/summary/paragraph. This avoids asking a transliteration\n # heuristic to invent an airport identity.\n zh_done = set()\n- resolved_by_code = {item["code"]: item for item in resolved}\n+\n+ # First honor a reviewed Chinese identity wherever it appears. Doing\n+ # this before English-slot pairing is important when the Chinese title\n+ # mentions an airport before the corresponding English body paragraph.\n+ for item in resolved:\n+ code = item["code"]\n+ for key, index, text in _slots(article, "zh"):\n+ match = _find_zh_identity_match(\n+ text, item["entity"], item["row"])\n+ if not match:\n+ continue\n+ new_text = _insert_code(text, match.start(), match.end(), code, "zh")\n+ cleaned = _clean_punctuation_spacing(new_text)\n+ changed |= cleaned != text\n+ _set_slot(article, "zh", key, index, cleaned)\n+ zh_done.add(code)\n+ break\n+\n for slot, matches in by_slot.items():\n key, index = slot\n zh_slot = next((value for k, i, value in _slots(article, "zh")\n if k == key and i == index), None)\n if zh_slot is None:\n continue\n candidates = _zh_candidates(zh_slot)\n- ordered = sorted(matches, key=lambda row: row[0])\n-\n- # Prefer the reviewed identity in the equivalent Chinese slot. This\n- # handles common short forms such as 芝加哥奧黑爾 that do not include\n- # the literal suffix 機場.\n- direct_pairs = []\n- for _start, _end, code in ordered:\n- item = resolved_by_code[code]\n- match = _find_zh_identity_match(\n- zh_slot, item["entity"], item["row"])\n- if match is None:\n- direct_pairs = []\n- break\n- direct_pairs.append((match, code))\n- direct_spans = {\n- (match.start(), match.end()) for match, _code in direct_pairs\n- }\n- if len(direct_pairs) == len(ordered) \\n- and len(direct_spans) == len(direct_pairs):\n- pairs = direct_pairs\n- elif len(candidates) >= len(ordered):\n- pairs = [\n- (match, code)\n- for match, (_start, _end, code) in zip(\n- candidates[:len(ordered)], ordered)\n- ]\n+ ordered = sorted(\n+ (row for row in matches if row[2] not in zh_done),\n+ key=lambda row: row[0],\n+ )\n+ if not ordered:\n+ continue\n+ if len(ordered) == 1 and len(candidates) == 1:\n+ pairs = [(candidates[0], ordered[0][2])]\n else:\n continue\n \n@@ -373,22 +577,25 @@ def enforce_article(article: dict) -> tuple[bool, list[str]]:\n changed |= new_text != text\n text = new_text\n zh_done.add(code)\n+ cleaned = _clean_punctuation_spacing(text)\n+ changed |= cleaned != text\n+ text = cleaned\n _set_slot(article, "zh", key, index, text)\n \n # Single-airport fallback for bilingual wording that is not structurally\n # aligned. A lone resolved airport cannot be confused with another code.\n if len(resolved) == 1:\n item = resolved[0]\n code = item["code"]\n- if code not in en_locations:\n+ if code not in en_locations and not _visible_has_code(article, "en", code):\n for key, index, text in _slots(article, "en"):\n match = _EN_GENERIC_AIRPORT_RE.search(text)\n if match:\n new_text = _insert_code(text, match.start(), match.end(), code, "en")\n changed |= new_text != text\n _set_slot(article, "en", key, index, new_text)\n break\n- if code not in zh_done:\n+ if code not in zh_done and not _visible_has_code(article, "zh", code):\n zh_name = str(item["row"].get("name_zh") or "").strip()\n placed = False\n if zh_name:\n@@ -415,7 +622,7 @@ def enforce_article(article: dict) -> tuple[bool, list[str]]:\n if not placed:\n for key, index, text in _slots(article, "zh"):\n candidates = _zh_candidates(text)\n- if candidates:\n+ if len(candidates) == 1:\n match = candidates[0]\n new_text = _insert_code(text, match.start(), match.end(), code, "zh")\n changed |= new_text != text\n@@ -425,13 +632,144 @@ def enforce_article(article: dict) -> tuple[bool, list[str]]:\n for item in resolved:\n for lang in ("zh", "en"):\n before = "\n".join(value for _k, _i, value in _slots(article, lang))\n- _dedupe_code(article, lang, item["code"])\n+ _dedupe_code(article, lang, item["code"], item["entity"],\n+ item["row"])\n after = "\n".join(value for _k, _i, value in _slots(article, lang))\n changed |= before != after\n \n return changed, unresolved\n \n \n+def _slot_text(article: dict, lang: str, key: str, index: int | None) -> str:\n+ return next(\n+ (value for k, i, value in _slots(article, lang)\n+ if k == key and i == index),\n+ "",\n+ )\n+\n+\n+def _has_immediate_code(text: str, end: int, code: str) -> bool:\n+ match = re.match(\n+ r"\s*[((]\s*([A-Z]{3})\s*(?:[;;][^()()]*)?[))]",\n+ text[end:],\n+ )\n+ return bool(match and match.group(1) == code)\n+\n+\n+def _code_precedes(article: dict, lang: str, key: str,\n+ index: int | None, start: int, code: str,\n+ entity: str, row: dict) -> bool:\n+ """Return whether an earlier visible identity already carries the code."""\n+ slots = _slots(article, lang)\n+ current = next(\n+ (position for position, (slot_key, slot_index, _text)\n+ in enumerate(slots) if slot_key == key and slot_index == index),\n+ None,\n+ )\n+ if current is None:\n+ return False\n+ annotation = _code_annotation_pattern(code)\n+ for position, slot_key, slot_index, text in (\n+ (position, *slot) for position, slot in enumerate(slots)):\n+ if position > current:\n+ break\n+ limit = start if position == current else len(text)\n+ if lang == "en":\n+ identity_patterns = _airport_patterns(entity, row)\n+ matches = [match for pattern in identity_patterns\n+ for match in pattern.finditer(text[:limit])]\n+ if any(_has_immediate_code(text, match.end(), code)\n+ for match in matches):\n+ return True\n+ else:\n+ match = _find_zh_identity_match(text[:limit], entity, row)\n+ if match and annotation.match(text[match.end():]):\n+ return True\n+ return False\n+\n+\n+def validate_article(article: dict) -> list[str]:\n+ """Return resolved airport mentions still missing their visible code.\n+\n+ Only title/body slots rendered by ``templates/article.html`` are checked.\n+ An entity that is not actually present in a visible translation is not a\n+ violation; the extractor may have retained background metadata.\n+ """\n+ entities = article.get("entities") or {}\n+ airports = entities.get("airports") if isinstance(entities, dict) else []\n+ if not isinstance(airports, list):\n+ return []\n+\n+ resolved = []\n+ seen_codes = set()\n+ for entity in airports:\n+ name = str(entity or "").strip()\n+ row = resolve_airport(name)\n+ if not row or row["code"] in seen_codes:\n+ continue\n+ seen_codes.add(row["code"])\n+ resolved.append({"entity": name, "row": row, "code": row["code"]})\n+\n+ violations = []\n+ reported = set()\n+\n+ def report(item, lang: str, key: str, index: int | None) -> None:\n+ marker = (item["code"], lang, key, index)\n+ if marker in reported:\n+ return\n+ reported.add(marker)\n+ violations.append(\n+ f"{item['entity']} ({item['code']}) in {lang}:{key}"\n+ )\n+\n+ en_by_slot: dict[tuple[str, int | None], list[tuple[int, int, dict]]] = defaultdict(list)\n+ direct_zh_codes = set()\n+ for item in resolved:\n+ en_loc = _find_en_match(article, item["entity"], item["row"])\n+ if en_loc:\n+ key, index, start, end = en_loc\n+ en_by_slot[(key, index)].append((start, end, item))\n+ text = _slot_text(article, "en", key, index)\n+ if (not _has_immediate_code(text, end, item["code"])\n+ and not _code_precedes(\n+ article, "en", key, index, start, item["code"],\n+ item["entity"], item["row"])):\n+ report(item, "en", key, index)\n+\n+ for key, index, text in _slots(article, "zh"):\n+ match = _find_zh_identity_match(\n+ text, item["entity"], item["row"])\n+ if not match:\n+ continue\n+ direct_zh_codes.add(item["code"])\n+ if (not _has_immediate_code(text, match.end(), item["code"])\n+ and not _code_precedes(\n+ article, "zh", key, index, match.start(),\n+ item["code"], item["entity"], item["row"])):\n+ report(item, "zh", key, index)\n+ break\n+\n+ # For translated airport names without a reviewed Chinese alias, pair the\n+ # Chinese airport mentions with the English mentions in the same visible\n+ # slot, using their document order.\n+ for (key, index), matches in en_by_slot.items():\n+ zh_text = _slot_text(article, "zh", key, index)\n+ candidates = _zh_candidates(zh_text)\n+ if len(matches) > 1 or len(candidates) != len(matches):\n+ continue\n+ for candidate, (_start, _end, item) in zip(\n+ candidates, sorted(matches, key=lambda row: row[0])):\n+ if item["code"] in direct_zh_codes:\n+ continue\n+ if (not _has_immediate_code(zh_text, candidate.end(), item["code"])\n+ and not _code_precedes(\n+ article, "zh", key, index, candidate.start(),\n+ item["code"], item["entity"], item["row"])):\n+ report(item, "zh", key, index)\n+\n+ return violations\n+\n+\n def main() -> int:\n if not ARTICLES_DIR.is_dir():\n print("airport_codes: no articles yet")\n@@ -440,6 +778,7 @@ def main() -> int:\n cutoff = now_utc() - timedelta(days=MAX_ARTICLE_AGE_DAYS)\n updated = 0\n unresolved_total = 0\n+ violation_total = 0\n for path in sorted(ARTICLES_DIR.glob("*.json"), reverse=True):\n batch = load_json(path, None)\n if not isinstance(batch, dict):\n@@ -457,9 +796,21 @@ def main() -> int:\n if unresolved:\n unresolved_total += len(unresolved)\n print(\n- "airport_codes: no unique IATA match for "\n- + ", ".join(unresolved)\n- + f" in {article.get('id') or path.name}"\n+ _console_safe(\n+ "airport_codes: no unique IATA match for "\n+ + ", ".join(unresolved)\n+ + f" in {article.get('id') or path.name}"\n+ )\n+ )\n+ violations = validate_article(article)\n+ if violations:\n+ violation_total += len(violations)\n+ print(\n+ _console_safe(\n+ "airport_codes: visible first-mention code violation: "\n+ + "; ".join(violations)\n+ + f" in {article.get('id') or path.name}"\n+ )\n )\n if article_changed:\n changed = True\n@@ -469,9 +820,10 @@ def main() -> int:\n \n print(\n f"airport_codes: {updated} article(s) updated; "\n- f"{unresolved_total} unresolved airport entit{'y' if unresolved_total == 1 else 'ies'}"\n+ f"{unresolved_total} unresolved airport entit{'y' if unresolved_total == 1 else 'ies'}; "\n+ f"{violation_total} visible placement violation(s)"\n )\n- return 0\n+ return 1 if violation_total else 0\n \n \n if __name__ == "__main__":\n@@ -31,12 +31,14 @@\n from dataclasses import dataclass\n from datetime import date, datetime, time, timedelta, timezone\n from pathlib import Path\n+from urllib.parse import urlsplit\n \n sys.path.insert(0, str(Path(__file__).resolve().parent))\n from common import ( # noqa: E402\n ARTICLES_DIR,\n DATA_DIR,\n iso_minute,\n+ is_google_news_url,\n load_json,\n norm_url,\n now_utc,\n@@ -71,6 +73,18 @@\n SECTIONS = ("aviation_incidents", "taiwan_aviation",\n "international_aviation", "ground_and_maritime")\n \n+\n+def _direct_source_url(value) -> str | None:\n+ url = str(value or "").strip()\n+ try:\n+ scheme = urlsplit(url).scheme.lower()\n+ except ValueError:\n+ return None\n+ if not url or scheme not in ("http", "https") \\n+ or is_google_news_url(url):\n+ return None\n+ return url\n+\n # Event-index retention: incidents/investigations/policy keep 30 days for\n # update matching; everything else 72 hours (spec minimum).\n RETAIN_HOURS_DEFAULT = 72\n@@ -310,9 +324,9 @@ def article_source_time(article: dict) -> datetime | None:\n def _canonical_urls(article: dict) -> list[str]:\n urls = []\n for src in article.get("sources") or []:\n- url = (src or {}).get("url")\n+ url = _direct_source_url((src or {}).get("url"))\n if url:\n- urls.append(norm_url(str(url)))\n+ urls.append(norm_url(url))\n return sorted(set(urls))\n \n \n@@ -450,9 +464,10 @@ def _build_item(article: dict, section: str, event_id: str,\n "taiwan_priority": _has_marker(blob, _TAIWAN_MARKERS),\n "military": _has_marker(blob, _MILITARY_MARKERS),\n "sources": [{"name": str((s or {}).get("name") or ""),\n- "url": str((s or {}).get("url") or "")}\n+ "url": url}\n for s in article.get("sources") or []\n- if (s or {}).get("url")],\n+ for url in [_direct_source_url((s or {}).get("url"))]\n+ if url],\n "risk_flags": list(article.get("riskFlags") or []),\n "article_id": article.get("id"),\n }\n@@ -34,13 +34,15 @@\n BASE_PATH,\n DATA_DIR,\n HOME_FEED_LIMIT,\n+ NEWS_PAGE_SIZE,\n MAX_FLASHES,\n RAW_DIR,\n SITE_DIR,\n SITE_ORIGIN,\n SOURCES,\n STATIC_DIR,\n TEMPLATES_DIR,\n+ is_google_news_url,\n load_json,\n norm_url,\n now_utc,\n@@ -91,7 +93,8 @@ def static_asset_version() -> str:\n "zh": {\n "homeLabel": "返回 SKYTICAL 首頁", "live": "LIVE 快訊", "updateBadge": "每小時自動更新",\n "flash": "即時快訊", "sourceStatus": "來源狀態",\n- "latest": "最新新聞", "topStory": "頭條", "mainSource": "主要來源",\n+ "latest": "最新新聞", "topStory": "頭條", "priorityLabel": "頭條",\n+ "mainSource": "主要來源",\n "briefLabel": "短訊", "roundupLabel": "事件彙整",\n "summaryLabel": "重點摘要",\n "readMore": "閱讀全文 →", "back": "← 返回首頁",\n@@ -134,14 +137,16 @@ def static_asset_version() -> str:\n "feature": "功能", "fix": "修正", "system": "系統",\n "editorial": "編輯", "design": "介面",\n },\n- "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n+ "footerAbout": "SKYTICAL 是以可追溯來源為基礎的自動化航空新聞彙整服務;每篇報導附原始來源,原始報導與更正公告優先。",\n "footerSources": "資料來源 Data Sources",\n "footerChangelog": "網站更新紀錄",\n "nav": ["最新", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n "searchKicker": "News Search", "searchTitle": "搜尋新聞",\n "newsKicker": "News Index", "newsTitle": "全部新聞",\n "newsSub": "依發布時間排列的本站航空與運輸新聞索引。",\n+ "paginationLabel": "新聞分頁", "previousPage": "上一頁",\n+ "nextPage": "下一頁", "pageLabel": "第 {page} / {pages} 頁",\n "headerSearchPlaceholder": "搜尋班號,例如 CI100",\n "headerSearchPlaceholders": [\n "搜尋班號,例如 CI100",\n@@ -212,7 +217,8 @@ def static_asset_version() -> str:\n "en": {\n "homeLabel": "Back to the SKYTICAL home page", "live": "LIVE WIRE", "updateBadge": "Auto-updates hourly",\n "flash": "Live flash", "sourceStatus": "Source status",\n- "latest": "Latest news", "topStory": "Top story", "mainSource": "Primary source",\n+ "latest": "Latest news", "topStory": "Top story", "priorityLabel": "TOP",\n+ "mainSource": "Primary source",\n "briefLabel": "Brief", "roundupLabel": "Event roundup",\n "summaryLabel": "Quick Summary",\n "readMore": "Read more →", "back": "← Back to front page",\n@@ -256,7 +262,7 @@ def static_asset_version() -> str:\n "feature": "Feature", "fix": "Fix", "system": "System",\n "editorial": "Editorial", "design": "Design",\n },\n- "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n+ "footerAbout": "SKYTICAL is an automated aviation-news compilation service built on traceable sources. Every story links to its sources; original reports and corrections prevail.",\n "footerSources": "Data Sources",\n "footerChangelog": "Site changelog",\n "nav": ["Latest", "Briefings", "Flight Radar", "Incident DB", "Sources",\n@@ -266,6 +272,8 @@ def static_asset_version() -> str:\n "searchKicker": "News Search", "searchTitle": "Search news",\n "newsKicker": "News Index", "newsTitle": "All news",\n "newsSub": "An archive of SKYTICAL aviation and transport news, ordered by publication time.",\n+ "paginationLabel": "News pagination", "previousPage": "Previous",\n+ "nextPage": "Next", "pageLabel": "Page {page} of {pages}",\n "headerSearchPlaceholder": "Search a flight number, e.g. CI100",\n "headerSearchPlaceholders": [\n "Search a flight number, e.g. CI100",\n@@ -951,6 +959,12 @@ def _safe_web_url(url: str) -> bool:\n return False\n \n \n+def _public_direct_url(url: str) -> bool:\n+ """Only safe, direct original URLs may be shown as public sources."""\n+ value = str(url or "").strip()\n+ return bool(value) and _safe_web_url(value) and not is_google_news_url(value)\n+\n+\n def normalize_image(raw_img):\n """Article image -> {url, link, credit, license, provider, kind} or None.\n \n@@ -1106,7 +1120,9 @@ def side(own, other):\n for s in raw.get("sources") or []:\n if isinstance(s, dict) and s.get("url"):\n url = str(s["url"])\n- if not _safe_web_url(url): # feed-derived: block javascript: etc.\n+ if (not _safe_web_url(url) or is_google_news_url(url)):\n+ # Google News is a discovery aggregator, not an original\n+ # source URL that should be exposed or credited publicly.\n continue\n try:\n host = urlsplit(url).netloc\n@@ -1637,6 +1653,7 @@ def agg_flashes(views):\n if len(text) > 68:\n text = text[:68].rstrip() + "…"\n out.append({"time": v["time"], "hot": False, "pinned": False,\n+ "priority": False,\n "text": text,\n "url": v["url"], "external": True})\n return out\n@@ -1682,9 +1699,11 @@ def prep_flashes(raw, known_ids):\n return out\n \n \n-def flash_view(flashes, lang: str):\n+def flash_view(flashes, lang: str, priority_ids=None):\n+ priority_ids = set(priority_ids or ())\n return [{\n "time": f["time"], "hot": f["hot"], "pinned": f["pinned"],\n+ "priority": f.get("articleId") in priority_ids,\n "text": f[lang],\n "url": page_url(lang, f"news/{f['articleId']}/") if f["articleId"] else None,\n "external": False,\n@@ -1753,12 +1772,17 @@ def merged_sources(raw):\n # public Sources page; Airbus and Boeing opt in via public=true.\n if not bool(s.get("public", reg.get("public", True))):\n continue\n+ url = str(s.get("url") or reg["url"] or "").strip()\n+ if not _public_direct_url(url):\n+ # Discovery aggregators may remain pipeline inputs, but never\n+ # appear in the reader-facing source inventory.\n+ continue\n out.append({\n "key": key,\n "name": str(s.get("name") or reg["name"]),\n "kind": str(s.get("kind") or reg["kind"]),\n "fmt": str(s.get("fmt") or reg["fmt"]),\n- "url": str(s.get("url") or reg["url"]),\n+ "url": url,\n "cover": s.get("cover") or reg["cover"],\n "lastFetchUtc": s.get("lastFetchUtc"),\n "ok": bool(s.get("ok", False)),\n@@ -1906,8 +1930,12 @@ def brief_item_view(item, lang: str, published_ids):\n "sev_cls": _BRIEF_SEV_CLS.get(item.get("severity"), "tag-neutral"),\n "update": item.get("item_type") == "update",\n "url": url,\n- "sources": [s for s in item.get("sources") or []\n- if isinstance(s, dict) and s.get("url")],\n+ "sources": [\n+ {"name": str(s.get("name") or ""),\n+ "url": str(s.get("url") or "").strip()}\n+ for s in item.get("sources") or []\n+ if isinstance(s, dict) and _public_direct_url(s.get("url"))\n+ ],\n }\n \n \n@@ -2991,7 +3019,10 @@ def _write_rss_feed(path: Path, lang: str, articles, now) -> None:\n article[lang]["summary"] or article[lang]["title"])\n ET.SubElement(item, "pubDate").text = format_datetime(\n article["published_dt"].astimezone(timezone.utc), usegmt=True)\n- ET.SubElement(item, "source").text = article["source"]\n+ source_attrs = {}\n+ if article.get("sources") and article["sources"][0].get("url"):\n+ source_attrs["url"] = article["sources"][0]["url"]\n+ ET.SubElement(item, "source", source_attrs).text = article["source"]\n _write_xml(path, root)\n \n \n@@ -3018,6 +3049,12 @@ def add_url(lang: str, sub: str, modified=None) -> None:\n add_url(lang, sub)\n for slug in TOPICS:\n add_url(lang, f"topics/{slug}/")\n+ localized_count = sum(\n+ lang in article["available_languages"] for article in articles\n+ )\n+ page_count = max(1, math.ceil(localized_count / NEWS_PAGE_SIZE))\n+ for page_number in range(2, page_count + 1):\n+ add_url(lang, f"news/page/{page_number}/")\n for briefing in briefings:\n modified = _tpe_dt(briefing.get("generated_at")\n or briefing.get("cutoff_time"))\n@@ -3139,10 +3176,11 @@ def main() -> int:\n ]\n pinned_views = [art_view(article, lang)\n for article in pinned_articles]\n+ priority_ids = {article["id"] for article in pinned_articles}\n hero_candidates = [hero_rotation_view(view, lang)\n for view in pinned_views]\n if views:\n- fl = flash_view(flashes, lang)\n+ fl = flash_view(flashes, lang, priority_ids)\n hero = pinned_views[0] if pinned_views else views[0]\n pinned_ids = ({view["id"] for view in pinned_views}\n if pinned_views else {hero["id"]})\n@@ -3156,7 +3194,7 @@ def main() -> int:\n hero_candidates = []\n feed = av[1:1 + HOME_FEED_LIMIT]\n else:\n- fl = flash_view(flashes, lang)\n+ fl = flash_view(flashes, lang, priority_ids)\n hero = None\n hero_candidates = []\n feed = []\n@@ -3165,6 +3203,7 @@ def main() -> int:\n ticker = [{"time": f.get("time"), "text": f.get("text"),\n "url": f.get("url"), "hot": bool(f.get("hot")),\n "pinned": bool(f.get("pinned")),\n+ "priority": bool(f.get("priority")),\n "external": bool(f.get("external"))} for f in fl]\n sv = stats_views(stats_raw, lang)\n \n@@ -3191,14 +3230,37 @@ def main() -> int:\n render(env, "home.html", rel_path(lang, "index.html"), ctx)\n pages += 1\n \n- news_ctx = base_ctx(\n- lang, "news", "news/",\n- title=f"{t['newsTitle']}|{t['siteName']}",\n- description=t["newsSub"], ticker=ticker, build=build)\n- news_ctx.update(articles=views[:100], count=len(views))\n- render(env, "news_index.html", rel_path(lang, "news/index.html"),\n- news_ctx)\n- pages += 1\n+ page_count = max(1, math.ceil(len(views) / NEWS_PAGE_SIZE))\n+ for page_number in range(1, page_count + 1):\n+ start = (page_number - 1) * NEWS_PAGE_SIZE\n+ end = start + NEWS_PAGE_SIZE\n+ sub = ("news/" if page_number == 1\n+ else f"news/page/{page_number}/")\n+ previous_sub = ("news/" if page_number == 2\n+ else f"news/page/{page_number - 1}/")\n+ next_sub = f"news/page/{page_number + 1}/"\n+ news_ctx = base_ctx(\n+ lang, "news", sub,\n+ title=(f"{t['newsTitle']}|{t['siteName']}"\n+ if page_number == 1 else\n+ f"{t['newsTitle']} · {t['pageLabel'].format(page=page_number, pages=page_count)}|{t['siteName']}"),\n+ description=t["newsSub"], ticker=ticker, build=build)\n+ news_ctx.update(\n+ articles=views[start:end], count=len(views),\n+ page_number=page_number, page_count=page_count,\n+ previous_url=(page_url(lang, previous_sub)\n+ if page_number > 1 else None),\n+ next_url=(page_url(lang, next_sub)\n+ if page_number < page_count else None),\n+ previous_abs_url=(absolute_page_url(lang, previous_sub)\n+ if page_number > 1 else None),\n+ next_abs_url=(absolute_page_url(lang, next_sub)\n+ if page_number < page_count else None),\n+ )\n+ out_rel = ("news/index.html" if page_number == 1\n+ else f"news/page/{page_number}/index.html")\n+ render(env, "news_index.html", rel_path(lang, out_rel), news_ctx)\n+ pages += 1\n \n for slug, topic in TOPICS.items():\n topic_rows = topic_articles(lang_articles, slug)\n@@ -41,6 +41,9 @@\n MAX_FLASHES = 10\n # Articles shown on the home page feed.\n HOME_FEED_LIMIT = 30\n+# Articles shown on each static news-index page. Pagination keeps the index\n+# crawlable without making the first screen carry the entire archive.\n+NEWS_PAGE_SIZE = 100\n \n \n def _parse_max_age(value, default: int = 120,\n@@ -827,6 +830,19 @@ def norm_url(url: str) -> str:\n )\n \n \n+def is_google_news_url(url: str) -> bool:\n+ """Return whether ``url`` is a Google News aggregator link.\n+\n+ Google News is useful as a discovery feed, but its redirect/app-shell URL\n+ is not an original article URL that can be credited to readers.\n+ """\n+ try:\n+ host = (urlsplit(str(url).strip()).hostname or "").casefold()\n+ except ValueError:\n+ return False\n+ return host == "news.google.com" or host.endswith(".news.google.com")\n+\n+\n def slugify(text: str, max_len: int = 40) -> str:\n text = unicodedata.normalize("NFKD", text)\n text = text.encode("ascii", "ignore").decode("ascii").lower()\n@@ -14,7 +14,7 @@\n Guard rails:\n - Only allowlisted domains join a group; the outlet is identified from\n Google News' own <source> element, and links are resolved off\n- news.google.com when possible.\n+ news.google.com when possible; an unresolved aggregator URL is discarded.\n - A candidate must share enough significant title tokens with the seed\n headline to count as the same topic.\n - Caps: MAX_ITEMS_PER_GROUP companions and MAX_SEARCHES_PER_RUN searches per\n@@ -37,6 +37,7 @@\n from common import ( # noqa: E402\n USER_AGENT,\n iso_minute,\n+ is_google_news_url,\n load_json,\n norm_url,\n squash_text,\n@@ -125,21 +126,25 @@ def _domain_ok(host: str) -> bool:\n return any(host == d or host.endswith("." + d) for d in DOMAINS)\n \n \n-def _resolve(link: str) -> str:\n- """One redirect hop off news.google.com; the Google link otherwise."""\n+def _resolve(link: str) -> str | None:\n+ """Resolve one hop to a direct outlet URL, or fail closed."""\n try:\n resp = requests.get(link, headers=HEADERS, timeout=TIMEOUT,\n allow_redirects=False)\n except requests.RequestException:\n- return link\n+ return None\n location = resp.headers.get("Location") if hasattr(resp, "headers") else None\n if getattr(resp, "status_code", 0) in (301, 302, 303, 307, 308) \\n and location:\n location = urljoin(link, location)\n- host = urlsplit(location).netloc.lower()\n- if host and "google.com" not in host:\n+ host = (urlsplit(location).hostname or "").casefold()\n+ if host and not (\n+ is_google_news_url(location)\n+ or host == "google.com"\n+ or host.endswith(".google.com")\n+ ):\n return location\n- return link\n+ return None\n \n \n def _entry_source(entry) -> tuple[str, str]:\n@@ -212,12 +217,17 @@ def search_candidates(query: str, seed_tokens: list[str],\n for future in as_completed(futures):\n candidate = futures[future]\n try:\n- candidate["url"] = future.result()\n+ resolved = future.result()\n+ if resolved and not is_google_news_url(resolved):\n+ candidate["url"] = resolved\n except Exception:\n- candidate["url"] = candidate["_googleUrl"]\n+ pass\n+ resolved_candidates = []\n for candidate in out:\n candidate.pop("_googleUrl", None)\n- return out\n+ if candidate.get("url") and not is_google_news_url(candidate["url"]):\n+ resolved_candidates.append(candidate)\n+ return resolved_candidates\n \n \n def enrich_thin_groups(groups: list) -> int:\n@@ -29,6 +29,7 @@\n RAW_DIR,\n SOURCES,\n USER_AGENT,\n+ is_google_news_url,\n is_transport_headline,\n iso_minute,\n load_json,\n@@ -285,14 +286,18 @@ def _rss_items(session: requests.Session, key: str, entries: list,\n title = _clean_gnews_title(title, entry)\n if not title:\n continue\n- if (GOOGLE_NEWS_HOST in urlsplit(link).netloc\n- and resolve_failures < MAX_RESOLVE_FAILURES):\n+ if is_google_news_url(link):\n+ if resolve_failures >= MAX_RESOLVE_FAILURES:\n+ continue\n resolved = _resolve_gnews_link(session, link)\n if resolved:\n link = resolved\n resolve_failures = 0\n else:\n resolve_failures += 1\n+ # A Google aggregator page is discovery material only;\n+ # never carry it into pending data as a public source.\n+ continue\n summary = _strip_html(entry.get("summary") or entry.get("description"))\n if is_google and len(summary) <= len(title) + 40:\n summary = "" # google news descriptions just repeat the headline\n@@ -33,7 +33,7 @@\n import requests\n \n sys.path.insert(0, str(Path(__file__).resolve().parent))\n-from common import squash_text # noqa: E402\n+from common import is_google_news_url, squash_text # noqa: E402\n from providers import extract_json # noqa: E402\n \n # `or` (not a get() default) so the empty env var CI passes when the repo\n@@ -201,7 +201,9 @@ def sanitize_items(data: dict, existing_titles: list[str],\n refs = [r for r in refs if isinstance(r, int)\n and 0 <= r < len(chunks)] if isinstance(refs, list) else []\n cited = [chunks[r] for r in dict.fromkeys(refs)]\n- cited = [c for c in cited if c["uri"] and not _is_blocked(c)]\n+ cited = [c for c in cited\n+ if c["uri"] and not _is_blocked(c)\n+ and not is_google_news_url(c["uri"])]\n # forums only alongside at least one non-forum source\n if cited and all(_is_forum(c) for c in cited):\n dropped += 1\n@@ -26,6 +26,7 @@\n import requests\n \n sys.path.insert(0, str(Path(__file__).resolve().parent))\n+from airport_codes import resolve_airport # noqa: E402\n from common import (\n ARTICLES_DIR,\n USER_AGENT,\n@@ -61,6 +62,41 @@\n # Every deterministic entry must be manually vetted for relevance and reuse.\n # They are file photos, never represented as photos from the current event.\n _TOPIC_IMAGES = (\n+ {\n+ "name": "china-airlines",\n+ "patterns": (\n+ re.compile(r"^(?:華航|中華航空)"),\n+ re.compile(r"^China Airlines\b", re.I),\n+ ),\n+ "image": {\n+ "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/China_Airlines_Boeing_737-800_B-18651_on_final_approach_to_Taoyuan_February_2026.jpg/1280px-China_Airlines_Boeing_737-800_B-18651_on_final_approach_to_Taoyuan_February_2026.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail",\n+ "link": "https://commons.wikimedia.org/wiki/File:China_Airlines_Boeing_737-800_B-18651_on_final_approach_to_Taoyuan_February_2026.jpg",\n+ "credit": "4300streetcar",\n+ "license": "CC BY 4.0",\n+ "provider": "Wikimedia Commons",\n+ "kind": "file_photo",\n+ "matched": "topic:china-airlines",\n+ "subject": "China Airlines Boeing 737-800",\n+ "photoYear": 2026,\n+ },\n+ },\n+ {\n+ "name": "f16",\n+ "patterns": (\n+ re.compile(r"(?<![A-Za-z0-9])F-?16(?:[A-Z0-9-]*)(?![A-Za-z0-9])", re.I),\n+ ),\n+ "image": {\n+ "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/be/A_USAF_F-16_pilot_breaking_right_on_final_approach_over_northern_Las_Vegas.jpg/1280px-A_USAF_F-16_pilot_breaking_right_on_final_approach_over_northern_Las_Vegas.jpg",\n+ "link": "https://commons.wikimedia.org/wiki/File:A_USAF_F-16_pilot_breaking_right_on_final_approach_over_northern_Las_Vegas.jpg",\n+ "credit": "Master Sgt. Benjamin Bloker",\n+ "license": "Public domain",\n+ "provider": "Wikimedia Commons",\n+ "kind": "file_photo",\n+ "matched": "topic:f16",\n+ "subject": "USAF F-16 資料照片",\n+ "photoYear": 2017,\n+ },\n+ },\n {\n "name": "drone",\n "patterns": (\n@@ -105,7 +141,7 @@ def _airport_entity(article: dict) -> str | None:\n return None\n for value in airports:\n name = str(value or "").strip()\n- if len(name) >= 3:\n+ if len(name) >= 3 and resolve_airport(name):\n return name\n return None\n \n@@ -46,6 +46,7 @@\n RAW_DIR,\n SITE_ORIGIN,\n group_has_material,\n+ is_google_news_url,\n is_transport_story,\n iso_minute,\n load_json,\n@@ -1515,7 +1516,7 @@ def build_sources(items: list) -> list:\n sources, seen_urls = [], set()\n for item in items:\n url = str(item.get("url") or "").strip()\n- if not url:\n+ if not url or is_google_news_url(url):\n continue\n key = norm_url(url)\n if key in seen_urls:\n@@ -1553,7 +1554,7 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n continue\n used_archive_ids.add(event_id)\n url = indexed["source_url"]\n- if norm_url(url) in source_urls:\n+ if is_google_news_url(url) or norm_url(url) in source_urls:\n continue\n source_urls.add(norm_url(url))\n sources.append({\n@@ -1567,7 +1568,8 @@ def build_article(draft: dict, group: dict, now, used_ids: set, writer=None,\n if not isinstance(source, dict):\n continue\n url = str(source.get("url") or "").strip()\n- if not url or norm_url(url) in source_urls:\n+ if (not url or is_google_news_url(url)\n+ or norm_url(url) in source_urls):\n continue\n source_urls.add(norm_url(url))\n sources.append({\n@@ -533,6 +533,18 @@ a.flash-row:hover { color: inherit; }\n }\n .search-result-footer a { flex-shrink: 0; font-weight: 700; }\n \n+/* — static news-index pagination — */\n+.news-pagination {\n+ display: flex; align-items: center; justify-content: center; gap: 14px;\n+ flex-wrap: wrap; margin-top: 24px; padding-top: 18px;\n+ border-top: 2px solid var(--color-divider);\n+}\n+.news-pagination .btn { text-decoration: none; }\n+.news-pagination-status {\n+ font-size: 12px; font-weight: 800; letter-spacing: .04em;\n+ color: var(--color-muted);\n+}\n+\n /* — responsive fallback: the design is desktop-first; below 1100px the\n three-column home grid simply stacks. — */\n @media (max-width: 1100px) {\n@@ -114,9 +114,9 @@\n <span class="ticker-pass">\n {%- for it in ticker_items -%}\n {%- if it.url -%}\n- <a class="ticker-item" href="{{ it.url }}"{% if it.external %} target="_blank" rel="noopener"{% endif %}><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.pinned|default(false) %}<span class="ticker-pin">PIN</span>{% endif %} {{ it.text }}<span class="ticker-go">↗</span></a>\n+ <a class="ticker-item" href="{{ it.url }}"{% if it.external %} target="_blank" rel="noopener"{% endif %}><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.priority|default(false) %}<span class="ticker-pin">{{ t.priorityLabel }}</span>{% endif %} {{ it.text }}<span class="ticker-go">↗</span></a>\n {%- else -%}\n- <span class="ticker-item"><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.pinned|default(false) %}<span class="ticker-pin">PIN</span>{% endif %} {{ it.text }}</span>\n+ <span class="ticker-item"><b class="ticker-time{% if it.hot %} hot{% endif %}">{{ it.time }}</b>{% if it.priority|default(false) %}<span class="ticker-pin">{{ t.priorityLabel }}</span>{% endif %} {{ it.text }}</span>\n {%- endif -%}\n {%- if not loop.last %}<span class="ticker-sep">▪</span>{% endif %}\n {%- endfor -%}\n@@ -18,13 +18,13 @@ <h6 style="display:flex;justify-content:space-between;align-items:baseline;margi\n {% if f.url %}\n <a class="flash-row" href="{{ f.url }}"{% if f.external %} target="_blank" rel="noopener"{% endif %}>\n <span class="flash-time{% if f.hot %} hot{% endif %}">{{ f.time }}</span>\n- {% if f.pinned|default(false) %}<span class="flash-pin">PIN</span>{% endif %}\n+ {% if f.priority|default(false) %}<span class="flash-pin">{{ t.priorityLabel }}</span>{% endif %}\n <span style="font-size:12.5px;line-height:1.6;">{{ f.text }}</span>\n </a>\n {% else %}\n <div class="flash-row">\n <span class="flash-time{% if f.hot %} hot{% endif %}">{{ f.time }}</span>\n- {% if f.pinned|default(false) %}<span class="flash-pin">PIN</span>{% endif %}\n+ {% if f.priority|default(false) %}<span class="flash-pin">{{ t.priorityLabel }}</span>{% endif %}\n <span style="font-size:12.5px;line-height:1.6;">{{ f.text }}</span>\n </div>\n {% endif %}\n@@ -1,5 +1,9 @@\n {% extends "base.html" %}\n {% from "_summary.html" import notification_summary %}\n+{% block head_extra %}\n+{% if previous_abs_url %}<link rel="prev" href="{{ previous_abs_url }}">{% endif %}\n+{% if next_abs_url %}<link rel="next" href="{{ next_abs_url }}">{% endif %}\n+{% endblock %}\n {% block content %}\n <main class="search-page topic-page">\n <header class="search-header">\n@@ -23,5 +27,12 @@ <h1>{{ t.newsTitle }}</h1>\n <p class="text-muted">{{ t.awaiting }}</p>\n {% endfor %}\n </section>\n+ {% if page_count > 1 %}\n+ <nav class="news-pagination" aria-label="{{ t.paginationLabel }}">\n+ {% if previous_url %}<a class="btn btn-secondary" href="{{ previous_url }}">← {{ t.previousPage }}</a>{% endif %}\n+ <span class="news-pagination-status">{{ t.pageLabel.format(page=page_number, pages=page_count) }}</span>\n+ {% if next_url %}<a class="btn btn-secondary" href="{{ next_url }}">{{ t.nextPage }} →</a>{% endif %}\n+ </nav>\n+ {% endif %}\n </main>\n {% endblock %}\n@@ -108,6 +108,58 @@ def main() -> int:\n assert resolved_ohare_zh is not None\n assert resolved_ohare_zh["code"] == "ORD", resolved_ohare_zh\n \n+ assert airport_codes.resolve_airport("London Heathrow")["code"] == "LHR"\n+ assert airport_codes.resolve_airport("倫敦希斯洛機場")["code"] == "LHR"\n+ assert airport_codes.resolve_airport("Dubai International Airport")["code"] == "DXB"\n+ assert airport_codes.resolve_airport("杜拜國際機場")["code"] == "DXB"\n+ assert airport_codes.resolve_airport("Al Maktoum International")["code"] == "DWC"\n+ assert airport_codes.resolve_airport("阿勒馬克圖姆國際機場")["code"] == "DWC"\n+\n+ # The textual airport identity outranks a stale/wrong embedded code.\n+ resolved_phl = airport_codes.resolve_airport(\n+ "Philadelphia International Airport (PNE)")\n+ assert resolved_phl is not None\n+ assert resolved_phl["code"] == "PHL", resolved_phl\n+\n+ # The first visible article mention is title/body; an invisible summary\n+ # must not consume the code before the reader reaches the body.\n+ visible_body = catania_article()\n+ visible_body["zh"]["title"] = "埃特納火山活動持續"\n+ visible_body["en"]["title"] = "Etna volcanic activity continues"\n+ visible_body["zh"]["summary"] = "卡塔尼亞機場(CTA)摘要"\n+ visible_body["en"]["summary"] = "Catania airport (CTA) summary"\n+ airport_codes.enforce_article(visible_body)\n+ assert "卡塔尼亞機場(CTA)" in visible_body["zh"]["body"][0]\n+ assert "Catania airport (CTA)" in visible_body["en"]["body"][0]\n+\n+ # Two similarly named London airports must retain their own codes; the\n+ # old city-generic matcher could append both codes to one mention.\n+ london = {\n+ "entities": {"airports": [\n+ "London-Gatwick Airport", "London-Heathrow Airport"]},\n+ "zh": {"title": "英國機場航班調整", "summary": "",\n+ "body": ["航班往返 London Gatwick Airport 與 London Heathrow Airport。"]},\n+ "en": {"title": "UK airport schedule update", "summary": "",\n+ "body": ["Flights link London Gatwick Airport with London Heathrow Airport."]},\n+ }\n+ changed, unresolved = airport_codes.enforce_article(london)\n+ assert changed is True and unresolved == []\n+ assert "London Gatwick Airport (LGW)" in london["en"]["body"][0]\n+ assert "London Heathrow Airport (LHR)" in london["en"]["body"][0]\n+ assert "(LHR)" not in london["en"]["body"][0].split("London Heathrow")[0]\n+ assert airport_codes.validate_article(london) == []\n+\n+ # City-only and military-installation entities remain review noise rather\n+ # than being reported as missing civilian airport codes.\n+ noise = {\n+ "entities": {"airports": [\n+ "Atlanta", "Springfield Air National Guard Base"]},\n+ "zh": {"title": "航空安全更新", "summary": "", "body": []},\n+ "en": {"title": "Aviation safety update", "summary": "", "body": []},\n+ }\n+ _changed, unresolved = airport_codes.enforce_article(noise)\n+ assert unresolved == [], unresolved\n+\n # Each article owns its first-mention counter. The second fixture must\n # independently receive ORD even though the first fixture was processed.\n for article in (ohare_article(), ohare_article()):\n@@ -126,6 +178,74 @@ def main() -> int:\n assert zh_all.count("(ORD)") == 1, zh_all\n assert en_all.count("(ORD)") == 1, en_all\n \n+ assert airport_codes.resolve_airport(\n+ "Atlantic City Bader Field (AIY)")["code"] == "AIY"\n+\n+ # A Chinese title can lead an English body paragraph, and a bilingual\n+ # parenthetical should remain readable while receiving only one code.\n+ qantas = {\n+ "entities": {"airports": ["London Heathrow", "Melbourne Airport"]},\n+ "zh": {\n+ "title": "澳洲航空航線調整",\n+ "summary": "",\n+ "body": [\n+ "旅客將繼續前往倫敦希斯洛機場並經由新加坡停靠。",\n+ "澳洲航空集團已與墨爾本機場就基礎設施達成協議。",\n+ ],\n+ },\n+ "en": {\n+ "title": "Qantas network update",\n+ "summary": "",\n+ "body": [\n+ "Passengers will continue to London Heathrow via Singapore.",\n+ "Qantas and Melbourne Airport agreed infrastructure terms.",\n+ ],\n+ },\n+ }\n+ changed, unresolved = airport_codes.enforce_article(qantas)\n+ assert changed is True and unresolved == []\n+ assert "倫敦希斯洛機場(LHR)" in qantas["zh"]["body"][0]\n+ assert "墨爾本機場(MEL)" in qantas["zh"]["body"][1]\n+ assert "London Heathrow (LHR)" in qantas["en"]["body"][0]\n+ assert "Melbourne Airport (MEL)" in qantas["en"]["body"][1]\n+ assert airport_codes.validate_article(qantas) == []\n+\n+ bilingual = {\n+ "entities": {"airports": ["Midland International Air & Space Port"]},\n+ "zh": {"title": "德州米德蘭國際空天港(Midland International Air & Space Port)",\n+ "summary": "", "body": []},\n+ "en": {"title": "Midland International Air & Space Port",\n+ "summary": "", "body": []},\n+ }\n+ airport_codes.enforce_article(bilingual)\n+ assert bilingual["zh"]["title"] == (\n+ "德州米德蘭國際空天港(MAF;Midland International Air & Space Port)")\n+ assert bilingual["zh"]["title"].count("MAF") == 1\n+ assert airport_codes.validate_article(bilingual) == []\n+\n+ # A code-only entity does not provide a name anchor. Preserve its\n+ # already-correct first-mention annotations instead of deleting them.\n+ code_only = {\n+ "entities": {"airports": ["ATL"]},\n+ "zh": {"title": "航線公告", "summary": "", "body": [\n+ "航班由亞特蘭大(ATL)飛往達拉斯-沃斯堡。",\n+ ]},\n+ "en": {"title": "Route update", "summary": "", "body": [\n+ "The flight operated from Atlanta (ATL) to Dallas-Fort Worth; the airport remained open.",\n+ ]},\n+ }\n+ airport_codes.enforce_article(code_only)\n+ assert "亞特蘭大(ATL)" in code_only["zh"]["body"][0]\n+ assert code_only["en"]["body"][0].count("(ATL)") == 1\n+ assert "airport (ATL)" not in code_only["en"]["body"][0]\n+\n+ # Existing bilingual formatting is stable on the second publishing pass.\n+ bilingual_snapshot = copy.deepcopy(bilingual)\n+ changed_again, unresolved_again = airport_codes.enforce_article(bilingual)\n+ assert changed_again is False\n+ assert unresolved_again == []\n+ assert bilingual == bilingual_snapshot\n+\n print("PASS test_airport_codes")\n return 0\n \n@@ -122,6 +122,20 @@ def get(self, url, **kwargs):\n check("outlet suffix stripped from the headline",\n cands[0]["title"].endswith("finish line"))\n \n+# A Google app-shell response without an outlet redirect is not publishable\n+# as a companion source and must be discarded rather than exposed verbatim.\n+unresolved_http = types.SimpleNamespace(\n+ get=lambda url, **kwargs: types.SimpleNamespace(\n+ status_code=200, headers={}))\n+original_requests = companion.requests\n+companion.requests = types.SimpleNamespace(\n+ get=unresolved_http.get, RequestException=Exception)\n+try:\n+ check("unresolved Google News link fails closed",\n+ companion._resolve("https://news.google.com/rss/articles/nope") is None)\n+finally:\n+ companion.requests = original_requests\n+\n # ── group enrichment end-to-end ──────────────────────────────────────────────\n \n group = {"id": "g9", "items": [dict(THIN_GROUP["items"][0])]}\n@@ -147,7 +161,7 @@ def get(self, url, **kwargs):\n companion.enrich_thin_groups(thin_groups)\n searches = [c for c in fake.calls if "news.google.com/rss" in c]\n check("per-run search budget enforced",\n- len(searches) == companion.MAX_SEARCHES_PER_RUN)\n+ len(searches) == min(companion.MAX_SEARCHES_PER_RUN, len(thin_groups)))\n \n print(f"\n{CHECKS} checks passed, {FAILED} failed"\n if not FAILED else f"\n{CHECKS - FAILED}/{CHECKS} passed, "\n@@ -49,6 +49,23 @@ def test_broad_rss_scan_keeps_sparse_airline_story_after_first_25(self):\n self.assertEqual([item["title"] for item in kept],\n ["長榮航空12月開航台北直飛德里"])\n \n+ def test_google_app_shell_without_direct_redirect_is_dropped(self):\n+ class Session:\n+ def get(self, url, **kwargs):\n+ return type("Response", (), {\n+ "status_code": 200, "headers": {}})()\n+\n+ items = fetch._rss_items(\n+ Session(), "google", [{\n+ "title": "Airline update - Reuters",\n+ "link": "https://news.google.com/rss/articles/example",\n+ "summary": "Airline update",\n+ }],\n+ "https://news.google.com/rss/search?q=airline",\n+ max_items=10,\n+ )\n+ self.assertEqual(items, [])\n+\n \n if __name__ == "__main__":\n unittest.main()\n@@ -73,7 +73,8 @@ def item(section="aviation_incidents", chunks=(0,), **kw):\n CHUNKS = [("https://vertexaisearch.example/redirect1", "reuters.com"),\n ("https://www.youtube.com/watch?v=x", "youtube.com"),\n ("https://www.pprune.org/thread", "pprune.org"),\n- ("https://economictimes.com/article", "economictimes.com")]\n+ ("https://economictimes.com/article", "economictimes.com"),\n+ ("https://news.google.com/rss/articles/example?oc=5", "Google News")]\n \n # ── enable gate ──────────────────────────────────────────────────────────────\n \n@@ -110,6 +111,11 @@ def item(section="aviation_incidents", chunks=(0,), **kw):\n check("social/video-only citation dropped",\n out["aviation_incidents"] == [])\n \n+out, _ = grounded.sanitize_items(\n+ resp_data([item(chunks=(4,))], CHUNKS), [], {}, NOW)\n+check("Google News aggregator citation dropped",\n+ out["aviation_incidents"] == [])\n+\n out, _ = grounded.sanitize_items(\n resp_data([item(chunks=(2,))], CHUNKS), [], {}, NOW)\n check("forum-only citation dropped",\n@@ -101,13 +101,28 @@ def test_rotation_payload_contains_localized_hero_fields():\n assert build.HERO_ROTATION_SECONDS == 300\n \n \n+def test_flash_priority_is_distinct_from_legacy_pinned_flag():\n+ flashes = [\n+ {"time": "6:00 PM", "hot": False, "pinned": True,\n+ "articleId": "a-weather", "zh": "天氣航班異動", "en": "Weather flight change",\n+ "available_languages": ["zh"]},\n+ {"time": "5:00 PM", "hot": False, "pinned": True,\n+ "articleId": "a-normal", "zh": "一般航空新聞", "en": "Regular aviation news",\n+ "available_languages": ["zh"]},\n+ ]\n+ views = build.flash_view(flashes, "zh", {"a-weather"})\n+ assert views[0]["pinned"] is True and views[0]["priority"] is True\n+ assert views[1]["pinned"] is True and views[1]["priority"] is False\n+\n+\n def main() -> int:\n tests = (\n test_weather_national_airline_flight_change_is_priority_story,\n test_priority_story_requires_all_three_signals,\n test_priority_story_at_sixteen_hours_is_still_pinned,\n test_stale_priority_story_is_not_pinned,\n test_rotation_payload_contains_localized_hero_fields,\n+ test_flash_priority_is_distinct_from_legacy_pinned_flag,\n )\n for test in tests:\n test()\n@@ -57,6 +57,27 @@ def main() -> int:\n assert english is not None\n assert english["matched"] == "topic:drone"\n \n+ # A China Airlines headline gets an aircraft file photo, not a cabin or\n+ # lounge image; a body-only comparison mention must not trigger it.\n+ china = image_fallbacks.topic_image(\n+ article("華航籌集公益款項捐贈團體", "中華航空公布會員哩程捐贈成果")\n+ )\n+ assert china is not None\n+ assert china["matched"] == "topic:china-airlines"\n+ assert china["subject"] == "China Airlines Boeing 737-800"\n+ assert "Boeing_737-800" in china["url"]\n+ assert image_fallbacks.topic_image(\n+ article("星宇航空公布新航線", "華航也在同篇背景資料中被提及")\n+ ) is None\n+\n+ f16 = image_fallbacks.topic_image(\n+ article("台灣首批F-16V戰機完成交付飛行")\n+ )\n+ assert f16 is not None\n+ assert f16["matched"] == "topic:f16"\n+ assert f16["license"] == "Public domain"\n+ assert "F-16" in f16["subject"]\n+\n # A body-only mention must not turn an unrelated fleet story into a\n # quadcopter card.\n assert image_fallbacks.topic_image(\n@@ -35,10 +35,10 @@ def main() -> None:\n "53ccee38ecb372a446ebd89737070852e0d8876b04af7efdb1709e78d8553621"\n )\n assert hashlib.sha256(logo_path.read_bytes()).hexdigest() == (\n- "9f273f64694d424df681b21d7450d332d363912bdb51b2c93b2e5e34c2e44f2f"\n+ "35f5284ba070088d746d6ca2b1047cbe3463bd8941b3f4f5763bbbbfd6b0805a"\n )\n assert hashlib.sha256(mark_path.read_bytes()).hexdigest() == (\n- "50ec6a6d663a1f4385d1f62ca546e0b6bdf815520b0bc3bd72cc5e1a255022e4"\n+ "1d97c44f571f152880dbf9464faf63ec3a8e34a2b405d0b387aee58580adb6d9"\n )\n assert hashlib.sha256(social_path.read_bytes()).hexdigest() == (\n "b5903b8496c802725fcdfe6072631a138c8c375a438ec1d92606c5e41f7076a5"\n@@ -26,9 +26,6 @@ def main() -> None:\n jetstar = (ROOT / "site" / "news" /\n "a-20260809-0948-jetstar-to-introduce-overhead-locker-fee" /\n "index.html").read_text(encoding="utf-8")\n- david = (ROOT / "site" / "news" /\n- "a-20260809-0853-david-cummins-sworn-in-as-eighth-tsa-adm" /\n- "index.html").read_text(encoding="utf-8")\n emirates = (ROOT / "site" / "news" /\n "a-20260809-1305-emirates-temporarily-suspends-airbus-a38" /\n "index.html").read_text(encoding="utf-8")\n@@ -60,7 +57,7 @@ def main() -> None:\n re.DOTALL,\n )\n assert feed_summaries and all(summary.strip() for summary in feed_summaries)\n- for article_html in (airadvisor, jetstar, david, emirates):\n+ for article_html in (airadvisor, jetstar, emirates):\n assert 'class="summary-preview' not in article_html\n \n zh_long = "中華航空8月10日宣布調整桃園至福岡航班,受機場作業限制影響,兩班延後三小時,旅客須留意報到時間。" * 2\n@@ -2,6 +2,7 @@\n from __future__ import annotations\n \n import json\n+import math\n import re\n import sys\n import xml.etree.ElementTree as ET\n@@ -40,6 +41,25 @@ def main() -> None:\n for slug in build.TOPICS:\n assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/topics/{slug}/"\n in locations)\n+ localized_zh_count = sum(\n+ "zh" in article["available_languages"]\n+ for article in build.collect_articles()\n+ )\n+ expected_zh_pages = max(\n+ 1, math.ceil(localized_zh_count / build.NEWS_PAGE_SIZE))\n+ if expected_zh_pages > 1:\n+ assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/news/page/2/"\n+ in locations)\n+ localized_en_count = sum(\n+ "en" in article["available_languages"]\n+ for article in build.collect_articles()\n+ )\n+ expected_en_pages = max(\n+ 1, math.ceil(localized_en_count / build.NEWS_PAGE_SIZE))\n+ if expected_en_pages > 1:\n+ assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/news/page/2/"\n+ in locations)\n+ for slug in build.TOPICS:\n assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/topics/{slug}/"\n in locations)\n \n@@ -56,6 +76,10 @@ def main() -> None:\n assert feed.findtext("channel/link").endswith("/news/")\n assert en_feed.findtext("channel/link").endswith("/en/news/")\n assert feed.findall("channel/item")\n+ for feed_item in feed.findall("channel/item") + en_feed.findall("channel/item"):\n+ source = feed_item.find("source")\n+ assert source is not None and source.attrib.get("url")\n+ assert "news.google.com" not in source.attrib["url"]\n \n news_sitemap = ET.parse(ROOT / "site" / "news-sitemap.xml").getroot()\n news_rows = news_sitemap.findall("sm:url", ns)\n@@ -88,10 +112,26 @@ def main() -> None:\n assert '<meta name="robots" content="index,follow,max-image-preview:large">' in home\n assert '<link rel="alternate" hreflang="zh-Hant-TW"' in home\n assert "SKYTICAL 航空新聞|臺灣與全球即時航空快訊" in home\n+ assert "可追溯來源" in home\n+ assert "設計原型" not in home and "示意樣本" not in home\n assert "暫無資料" in home\n assert "部分即時數據目前尚未取得" in home\n assert 'type="application/rss+xml"' in home\n \n+ if expected_zh_pages > 1:\n+ page_two = (ROOT / "site" / "news" / "page" / "2" /\n+ "index.html").read_text(encoding="utf-8")\n+ assert 'rel="prev" href="https://skytical.tech/news/"' in page_two\n+ assert 'rel="next" href="https://skytical.tech/news/page/3/"' in page_two\n+ assert "第 2 / " in page_two\n+ assert page_two.count('class="feed-row"') <= build.NEWS_PAGE_SIZE\n+ if expected_en_pages > 1:\n+ en_page_two = (ROOT / "site" / "en" / "news" / "page" / "2" /\n+ "index.html").read_text(encoding="utf-8")\n+ assert 'rel="prev" href="https://skytical.tech/en/news/"' in en_page_two\n+ assert "Page 2 of " in en_page_two\n+ assert en_page_two.count('class="feed-row"') <= build.NEWS_PAGE_SIZE\n+\n article_paths = list((ROOT / "site" / "news").glob("*/index.html"))\n article = article_paths[0].read_text(encoding="utf-8")\n article_schema = _json_ld(article)[0]\n@@ -0,0 +1,64 @@\n+"""Regression checks for direct public source links."""\n+from __future__ import annotations\n+\n+import sys\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build # noqa: E402\n+import common # noqa: E402\n+import write # noqa: E402\n+\n+\n+def main() -> int:\n+ google = "https://news.google.com/rss/articles/example?oc=5"\n+ direct = "https://www.reuters.com/world/example"\n+ assert common.is_google_news_url(google)\n+ assert not common.is_google_news_url(direct)\n+ assert not common.is_google_news_url("https://www.google.com/search?q=aviation")\n+\n+ sources = write.build_sources([\n+ {"title": "Google discovery", "source": "Reuters", "url": google},\n+ {"title": "Original report", "source": "Reuters", "url": direct},\n+ ])\n+ assert [source["url"] for source in sources] == [direct]\n+\n+ raw = {\n+ "id": "a-source-filter",\n+ "publishedUtc": "2026-08-26T00:00Z",\n+ "cat": "ops",\n+ "primarySource": "Reuters",\n+ "zh": {"title": "航空新聞", "summary": "", "body": ["航空公司更新航線。"]},\n+ "en": {"title": "Airline news", "summary": "", "body": ["The airline updated its route."]},\n+ "sources": [\n+ {"name": "Reuters discovery", "url": google},\n+ {"name": "Reuters original", "url": direct},\n+ ],\n+ }\n+ prepared = build.prep_article(raw)\n+ assert prepared is not None\n+ assert [source["url"] for source in prepared["sources"]] == [direct]\n+\n+ briefing_item = build.brief_item_view({\n+ "headline": "快報來源測試",\n+ "summary": "測試摘要",\n+ "sources": [\n+ {"name": "Google discovery", "url": google},\n+ {"name": "Original report", "url": direct},\n+ ],\n+ }, "zh", set())\n+ assert [source["url"] for source in briefing_item["sources"]] == [direct]\n+\n+ public_sources = build.merged_sources(\n+ build.load_json(build.DATA_DIR / "sources.json", []))\n+ assert all(not common.is_google_news_url(source["url"])\n+ for source in public_sources)\n+\n+ print("PASS test_source_links")\n+ return 0\n+\n+\n+if __name__ == "__main__":\n+ raise SystemExit(main())