Skip to content

Commit f0c08f0

Browse files
author
AdvancingTitans
committed
feat: improve multi-source news output
1 parent 65c4cce commit f0c08f0

8 files changed

Lines changed: 234 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.7] - 2026-06-01
9+
10+
### Added
11+
- Added `young news <symbol>` for a quick single-stock news/momentum check without printing the full quote report.
12+
13+
### Changed
14+
- News output now shows the source and link status on every item, including a clear "no public link" label instead of blank lines.
15+
- Multi-source news ranking now keeps per-source hit counts and uses a source-balanced display so Futu does not automatically crowd out Sina Finance or Eastmoney items when those sources have matching news.
16+
- Hong Kong stock news aliases now strip suffixes such as `-W` / `-W`, improving matches for names like Meituan-W.
17+
818
## [0.1.6] - 2026-06-01
919

1020
### Fixed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ young hk # Hong Kong indices snapshot
3131
young us # US indices snapshot
3232
young global # A + HK + US in one view
3333
young stock 600519 # one stock snapshot (A-share / HK / US)
34+
young news 3690.HK # multi-source news only
3435
young stock AAPL --no-news
3536
young us --no-news # market data only, skip news links
3637
young indices # A-share indices only
@@ -68,6 +69,7 @@ It is also a foundation for analysis pipelines: every subcommand maps to a Pytho
6869

6970
- **Multiple public quote sources** — Tencent Finance, Sina Finance, and Eastmoney are tried in sequence so temporary source failures can be filled by another no-login endpoint.
7071
- **Single-stock lookup**`young stock 600519`, `young stock 0700.HK`, or `young stock AAPL` prints a compact quote snapshot with source, trade date, price, change, volume, turnover when available, and optional news.
72+
- **Single-stock news**`young news 3690.HK` prints only the news/momentum view, with each item showing source and link status.
7173
- **Smart caching**`~/.young_stock/cache/`, 7-day TTL, auto-pruned. Pass `--refresh` to skip.
7274
- **Trade-day awareness** — nearest-trade-day resolution including weekends and (best-effort) holidays.
7375
- **Rich terminal tables** — readable on dark and light terminals.
@@ -119,6 +121,7 @@ A 股盘后行情命令行工具。免登录、免 API key、免反爬技巧 —
119121
pip install young-stock-cli
120122
young a # A 股盘后总览(主命令)
121123
young stock 600519 # 单只股票速览(A股 / 港股 / 美股)
124+
young news 3690.HK # 单只股票消息面,多源新闻与链接
122125
young hk --no-news # 只看行情,跳过新闻链接
123126
young zt-pool # 涨停 / 跌停 / 炸板分析
124127
young flow # 最新可核验 A 股资金流向

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "young-stock-cli"
7-
version = "0.1.6"
7+
version = "0.1.7"
88
description = "A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/young_stock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""young-stock-cli: A-share after-hours CLI."""
22

3-
__version__ = "0.1.6"
3+
__version__ = "0.1.7"

src/young_stock/_core.py

Lines changed: 144 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,17 @@ def _clean_news_title(title: str) -> str:
16651665
return html.unescape(title).strip()
16661666

16671667

1668+
def _news_aliases(symbol: str, name: str = "") -> list[str]:
1669+
aliases = [symbol]
1670+
if symbol.upper().endswith(".HK"):
1671+
raw = symbol.upper().replace(".HK", "")
1672+
aliases.extend([raw, raw.lstrip("0"), raw.zfill(5)])
1673+
if name:
1674+
aliases.append(name)
1675+
aliases.append(re.split(r"[--—((]", name, maxsplit=1)[0].strip())
1676+
return [alias for alias in dict.fromkeys(a for a in aliases if a)]
1677+
1678+
16681679
def _normalize_futu_feed(
16691680
feed_data: dict[str, Any],
16701681
size: int = 5,
@@ -1790,6 +1801,67 @@ def _parse_news_time(value: Any) -> int:
17901801
return 0
17911802

17921803

1804+
def _news_source_label(source: str) -> str:
1805+
return {
1806+
"futu_news": "富途资讯",
1807+
"futu_feed": "富途社区/资讯",
1808+
"sina_roll": "新浪财经",
1809+
"eastmoney_fast": "东方财富快讯",
1810+
"东方财富": "东方财富",
1811+
"新浪财经": "新浪财经",
1812+
}.get(source, source or "未知来源")
1813+
1814+
1815+
def _normalize_news_url(item: dict[str, Any]) -> str:
1816+
for key in ("url", "jump_url", "link", "news_url", "article_url", "wapurl"):
1817+
value = item.get(key)
1818+
if value:
1819+
return str(value)
1820+
return ""
1821+
1822+
1823+
def _select_diverse_news(items: list[dict[str, Any]], size: int) -> list[dict[str, Any]]:
1824+
"""展示时尽量保留多来源;热度计算仍基于所有命中。"""
1825+
if len(items) <= size:
1826+
return items
1827+
buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
1828+
for item in items:
1829+
buckets[str(item.get("source", "news"))].append(item)
1830+
for bucket in buckets.values():
1831+
bucket.sort(key=lambda item: _parse_news_time(item.get("publish_time")), reverse=True)
1832+
1833+
selected: list[dict[str, Any]] = []
1834+
seen_titles: set[str] = set()
1835+
source_order = sorted(
1836+
buckets,
1837+
key=lambda source: (
1838+
-len(buckets[source]),
1839+
-max((_parse_news_time(item.get("publish_time")) for item in buckets[source]), default=0),
1840+
),
1841+
)
1842+
while len(selected) < size and source_order:
1843+
progressed = False
1844+
for source in list(source_order):
1845+
bucket = buckets[source]
1846+
while bucket:
1847+
item = bucket.pop(0)
1848+
title = str(item.get("title", ""))
1849+
if title in seen_titles:
1850+
continue
1851+
selected.append(item)
1852+
seen_titles.add(title)
1853+
progressed = True
1854+
break
1855+
if not bucket:
1856+
source_order.remove(source)
1857+
if len(selected) >= size:
1858+
break
1859+
if not progressed:
1860+
break
1861+
selected.sort(key=lambda item: _parse_news_time(item.get("publish_time")), reverse=True)
1862+
return selected[:size]
1863+
1864+
17931865
def news_search_chain(keyword: str, size: int = 5, lang: str = "en", aliases: list[str] | None = None) -> dict[str, Any]:
17941866
"""资讯四段式:Futu 新闻 → Futu feed → 新浪财经 → 东方财富。"""
17951867
news = futu_news_search(keyword, size=size, lang=lang)
@@ -1814,15 +1886,22 @@ def news_search_chain(keyword: str, size: int = 5, lang: str = "en", aliases: li
18141886

18151887
def combined_news_search(keyword: str, size: int = 5, lang: str = "zh-CN", aliases: list[str] | None = None) -> dict[str, Any]:
18161888
"""聚合免登录新闻源,用于热度排序和展示。"""
1889+
per_source_size = max(size, 8)
18171890
sources = [
1818-
futu_news_search(keyword, size=size, lang=lang),
1819-
_normalize_futu_feed(futu_stock_feed(keyword, size=size * 2), size=size, keyword=keyword, aliases=aliases),
1820-
sina_roll_news(keyword, size=size, aliases=aliases),
1821-
eastmoney_fast_news(keyword, size=size, aliases=aliases),
1891+
futu_news_search(keyword, size=per_source_size, lang=lang),
1892+
_normalize_futu_feed(
1893+
futu_stock_feed(keyword, size=per_source_size * 2),
1894+
size=per_source_size,
1895+
keyword=keyword,
1896+
aliases=aliases,
1897+
),
1898+
sina_roll_news(keyword, size=per_source_size, aliases=aliases),
1899+
eastmoney_fast_news(keyword, size=per_source_size, aliases=aliases),
18221900
]
18231901
seen: set[str] = set()
18241902
items: list[dict[str, Any]] = []
18251903
used_sources: list[str] = []
1904+
source_counts: Counter[str] = Counter()
18261905
for idx, source in enumerate(sources):
18271906
if not source.get("data"):
18281907
continue
@@ -1836,10 +1915,19 @@ def combined_news_search(keyword: str, size: int = 5, lang: str = "zh-CN", alias
18361915
seen.add(title)
18371916
normalized = dict(item)
18381917
normalized["title"] = title
1918+
normalized["url"] = _normalize_news_url(normalized)
18391919
normalized.setdefault("source", source_name)
18401920
items.append(normalized)
1921+
source_counts[source_name] += 1
18411922
items.sort(key=lambda item: _parse_news_time(item.get("publish_time")), reverse=True)
1842-
return {"source": "+".join(s for s in used_sources if s) or "none", "data": items[:size]}
1923+
linked_items = [item for item in items if item.get("url")]
1924+
display_items = linked_items if len(linked_items) >= size else items
1925+
return {
1926+
"source": "+".join(s for s in used_sources if s) or "none",
1927+
"data": _select_diverse_news(display_items, size),
1928+
"all_count": len(items),
1929+
"source_counts": dict(source_counts),
1930+
}
18431931

18441932

18451933
def rank_symbols_by_news_heat(
@@ -1859,9 +1947,10 @@ def rank_symbols_by_news_heat(
18591947
aliases.append(names[symbol])
18601948
keyword = names.get(symbol) or symbol
18611949
news = combined_news_search(keyword, size=5, lang=lang, aliases=aliases)
1862-
score = 0.0
1950+
score = float(news.get("all_count", 0))
1951+
source_counts = news.get("source_counts") or {}
1952+
score += max(0, len(source_counts) - 1) * 0.8
18631953
for item in news.get("data", []):
1864-
score += 1.0
18651954
ts = _parse_news_time(item.get("publish_time"))
18661955
if ts and now_ts - ts < 24 * 3600:
18671956
score += 0.5
@@ -2238,41 +2327,67 @@ def run_stock_quote(symbol: str, date_str: str, include_news: bool = True) -> No
22382327
if include_news:
22392328
keyword = qd.name if qd.market in ("cn_market", "hk_market") and qd.name else qd.symbol
22402329
lang = "zh-CN" if qd.market in ("cn_market", "hk_market") else "en"
2241-
aliases = [qd.symbol, qd.name] if qd.name else [qd.symbol]
2330+
aliases = _news_aliases(qd.symbol, qd.name)
22422331
print_futu_news(combined_news_search(keyword, size=5, lang=lang, aliases=aliases), keyword)
22432332

22442333
if DIAGNOSTICS:
22452334
print_diagnostic_summary()
22462335
print_report_footer()
22472336

22482337

2249-
def print_futu_news(news_data: dict, keyword: str) -> None:
2338+
def run_stock_news(symbol: str, date_str: str, size: int = 8) -> None:
2339+
DIAGNOSTICS.clear()
2340+
normalized = symbol
2341+
market = detect_market_type(symbol)
2342+
name = ""
2343+
try:
2344+
normalized, market = normalize_stock_symbol(symbol)
2345+
qd = get_single_stock_quote(normalized, date_str)
2346+
if qd:
2347+
name = qd.name
2348+
market = qd.market
2349+
except ValueError:
2350+
pass
2351+
2352+
keyword = name if market in ("cn_market", "hk_market") and name else normalized
2353+
lang = "zh-CN" if market in ("cn_market", "hk_market") else "en"
2354+
aliases = _news_aliases(normalized, name)
2355+
print(f"# 消息面速览:{name or normalized} ({normalized})\n")
2356+
print_stage_line(date_str)
2357+
news = combined_news_search(keyword, size=size, lang=lang, aliases=aliases)
2358+
print_futu_news(news, keyword, limit=size)
2359+
if not news.get("data"):
2360+
print("暂未从免登录来源拿到匹配新闻,可以稍后加 --refresh 重试。")
2361+
print_report_footer()
2362+
2363+
2364+
def print_futu_news(news_data: dict, keyword: str, limit: int = 5) -> None:
22502365
if "_error" in news_data:
22512366
return
22522367
data = news_data.get("data", [])
22532368
if not data:
22542369
return
22552370
source = news_data.get("source", "futu_news")
2256-
if "+" in str(source):
2257-
source_label = "多源聚合"
2258-
else:
2259-
source_label = {
2260-
"futu_news": "富途资讯",
2261-
"futu_feed": "富途社区/资讯",
2262-
"sina_roll": "新浪财经",
2263-
"eastmoney_fast": "东方财富快讯",
2264-
}.get(source, str(source))
2265-
print(f"## {keyword} 相关新闻({source_label},前5条)\n")
2266-
for i, item in enumerate(data[:5], 1):
2371+
source_label = "多源聚合" if "+" in str(source) else _news_source_label(str(source))
2372+
shown = min(limit, len(data))
2373+
print(f"## {keyword} 相关新闻({source_label},前{shown}条)\n")
2374+
source_counts = news_data.get("source_counts") or {}
2375+
if source_counts:
2376+
parts = [f"{_news_source_label(str(k))} {v}条" for k, v in source_counts.items()]
2377+
print(" 来源覆盖: " + " / ".join(parts))
2378+
print()
2379+
for i, item in enumerate(data[:limit], 1):
22672380
ts = item.get("publish_time", 0)
22682381
# 富途返回的 publish_time 是字符串,先转 int
22692382
try:
22702383
ts_int = int(ts)
22712384
dt_str = datetime.fromtimestamp(ts_int).strftime("%m-%d %H:%M") if ts_int else ""
22722385
except (TypeError, ValueError):
22732386
dt_str = ""
2387+
item_source = _news_source_label(str(item.get("source") or source))
2388+
url = _normalize_news_url(item)
22742389
print(f"{i}. [{dt_str}] {_clean_news_title(item.get('title', ''))}")
2275-
print(f" {item.get('url', '')}")
2390+
print(f" 来源: {item_source} | 链接: {url or '暂无公开链接'}")
22762391
print()
22772392

22782393

@@ -2650,19 +2765,20 @@ def main():
26502765
i += 1
26512766
elif args[i] in ("--stock", "--symbol") and i + 1 < len(args):
26522767
stock_symbol = args[i + 1]
2653-
market = "stock"
2768+
if market != "news":
2769+
market = "stock"
26542770
i += 2
26552771
elif re.fullmatch(r"\d{8}", args[i]):
26562772
date_str = args[i]
26572773
i += 1
26582774
else:
26592775
i += 1
26602776

2661-
if market not in ("a", "hk", "us", "global", "stock"):
2662-
print("错误: --market 参数必须是 a、hk、us、global 或 stock", file=sys.stderr)
2777+
if market not in ("a", "hk", "us", "global", "stock", "news"):
2778+
print("错误: --market 参数必须是 a、hk、us、global、stocknews", file=sys.stderr)
26632779
sys.exit(1)
2664-
if market == "stock" and not stock_symbol:
2665-
print("错误: --market stock 需要配合 --stock 600519 使用", file=sys.stderr)
2780+
if market in ("stock", "news") and not stock_symbol:
2781+
print(f"错误: --market {market} 需要配合 --stock 600519 使用", file=sys.stderr)
26662782
sys.exit(1)
26672783

26682784
# 清理过期缓存
@@ -2681,6 +2797,8 @@ def main():
26812797
run_global_market(date_str)
26822798
elif market == "stock" and stock_symbol:
26832799
run_stock_quote(stock_symbol, date_str, include_news=include_news)
2800+
elif market == "news" and stock_symbol:
2801+
run_stock_news(stock_symbol, date_str)
26842802

26852803

26862804
if __name__ == "__main__":

src/young_stock/cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ def stock(symbol: str, date: str | None, refresh: bool, no_news: bool) -> None:
136136
_core.run_stock_quote(symbol, date_str, include_news=not no_news)
137137

138138

139+
@cli.command(help="Show multi-source news for one stock, e.g. 600519, 0700.HK, AAPL.")
140+
@click.argument("symbol")
141+
@_date_opt
142+
@_refresh_opt
143+
@click.option("--limit", default=8, show_default=True, help="Maximum news items to show.")
144+
def news(symbol: str, date: str | None, refresh: bool, limit: int) -> None:
145+
if refresh:
146+
_core.NO_CACHE = True
147+
_core.cache_clear_old(days=7)
148+
date_str = date or _core.nearest_trade_date()
149+
_core.run_stock_news(symbol, date_str, size=limit)
150+
151+
139152
@cli.command(help="Clear cached responses older than N days.")
140153
@click.option("--days", default=7, show_default=True, help="Delete cache files older than this many days.")
141154
def cache_clear(days: int) -> None:

tests/test_cli.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_cli_subcommands_registered():
3131
from click.testing import CliRunner
3232
runner = CliRunner()
3333
result = runner.invoke(cli, ["--help"])
34-
for sub in ["a", "hk", "us", "global", "indices", "zt-pool", "flow", "stock", "cache-clear", "update"]:
34+
for sub in ["a", "hk", "us", "global", "indices", "zt-pool", "flow", "stock", "news", "cache-clear", "update"]:
3535
assert sub in result.output, f"subcommand `{sub}` missing from help"
3636

3737

@@ -68,6 +68,21 @@ def test_cli_us_no_news(monkeypatch):
6868
assert calls == [("20260529", False)]
6969

7070

71+
def test_cli_news_runs_stock_news(monkeypatch):
72+
from click.testing import CliRunner
73+
74+
calls = []
75+
monkeypatch.setattr(cli_module._core, "nearest_trade_date", lambda: "20260529")
76+
monkeypatch.setattr(cli_module._core, "cache_clear_old", lambda days: None)
77+
monkeypatch.setattr(cli_module._core, "run_stock_news", lambda symbol, date_str, size=8: calls.append((symbol, date_str, size)))
78+
79+
runner = CliRunner()
80+
result = runner.invoke(cli, ["news", "0700.HK", "--limit", "6"])
81+
82+
assert result.exit_code == 0
83+
assert calls == [("0700.HK", "20260529", 6)]
84+
85+
7186
def test_cli_hk_no_news(monkeypatch):
7287
from click.testing import CliRunner
7388

0 commit comments

Comments
 (0)