yatslist 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
yatslist-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yat Tse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: yatslist
3
+ Version: 0.1.0
4
+ Summary: Yat's List 命令行客户端:深圳活动 / 黑客松 / 深圳外国人活动 — Shenzhen events, served to your terminal.
5
+ Author: Yat Tse
6
+ License: MIT
7
+ Project-URL: Homepage, https://yatslist.com
8
+ Project-URL: Events JSON, https://yatslist.com/events.json
9
+ Project-URL: Hackathons JSON, https://yatslist.com/hackathons.json
10
+ Keywords: shenzhen,events,hackathon,cli,yatslist
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # yatslist
22
+
23
+ **Yat's List 的命令行客户端** — 深圳活动、黑客松、深圳外国人活动,三张清单,一条命令。
24
+
25
+ **The Yat's List command-line client** — Shenzhen events, hackathons, and expat events in Shenzhen, right in your terminal. Zero third-party dependencies (Python standard library only).
26
+
27
+ > Shenzhen events, served to your Agent — and now to your terminal, too.
28
+
29
+ ## 安装 / Install
30
+
31
+ ```bash
32
+ pip install yatslist
33
+ ```
34
+
35
+ 需要 Python ≥ 3.9。Requires Python ≥ 3.9. No third-party dependencies.
36
+
37
+ ## 用法 / Usage
38
+
39
+ ```bash
40
+ # 深圳活动:今天起未来 7 天,按日期分组
41
+ yatslist
42
+
43
+ # 只看未来 3 天 / 输出 JSON
44
+ yatslist --days 3
45
+ yatslist --json
46
+
47
+ # 黑客松:报名未截止,按截止日升序
48
+ yatslist hackathons
49
+
50
+ # 黑客松:只看 72 小时内截止的(赶 deadline 用)
51
+ yatslist hackathons --soon
52
+
53
+ # 深圳外国人活动:未来 14 天(英文活动为主)
54
+ yatslist expat
55
+ yatslist expat --days 7
56
+
57
+ # 三张清单一起搜(标题 / 地点 / 简介),结果标注来源清单
58
+ yatslist search Agent
59
+ yatslist search 咖啡
60
+ ```
61
+
62
+ ### 示例输出 / Example output
63
+
64
+ ```
65
+ $ yatslist
66
+ Yat's List · 深圳活动(未来 7 天,共 69 场) 数据更新于 2026-11-08T11:14:12+08:00
67
+ 2026-11-08 周日
68
+ 13:00 xxx Meetup @ 南山智园
69
+ 19:00 xxx 公开课 @ 福田区某空间(进行中)
70
+ 2026-11-09 周一
71
+ ...
72
+
73
+ $ yatslist hackathons --soon
74
+ Yat's List · 黑客松(72 小时内截止,共 12 场,按截止日排序) 数据更新于 ...
75
+ 截止 2026-11-08 BoHack 2026 天津青年黑客松(天津 · 线下)
76
+ ...
77
+
78
+ $ yatslist search AI
79
+ Yat's List · 搜索「AI」(共 5 条)
80
+ [深圳活动] 2026-11-09 19:00 xxx AI 分享会
81
+ https://...
82
+ [黑客松] 截止 2026-11-13 xxx Hackathon
83
+ https://...
84
+ ```
85
+
86
+ ## 数据来源 / Data sources
87
+
88
+ 数据来自 [yatslist.com](https://yatslist.com) 公开 JSON,每日更新、逐条核实自官方页面:
89
+
90
+ | 清单 | 地址 |
91
+ | --- | --- |
92
+ | 深圳活动 | https://yatslist.com/events.json |
93
+ | 黑客松 | https://yatslist.com/hackathons.json |
94
+ | 深圳外国人活动 | https://yatslist.com/expat/events.json |
95
+
96
+ 说明 / Notes:
97
+
98
+ - 时间按数据源原文呈现,不做时区换算;日期筛选按数据时区(UTC+8)计算。
99
+ - Times are shown exactly as published in the data (UTC+8); no timezone conversion is applied.
100
+ - 网络失败或超时(10 秒)时会给出中文错误提示并以非零状态码退出。
101
+ - `search` 只搜未结束 / 未截止的条目。
102
+
103
+ ## 开发 / Development
104
+
105
+ ```bash
106
+ python -m unittest discover -s tests -v # 离线单元测试(fixtures 样本,不联网)
107
+ python -m build # 构建 sdist + wheel
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT © Yat Tse
@@ -0,0 +1,92 @@
1
+ # yatslist
2
+
3
+ **Yat's List 的命令行客户端** — 深圳活动、黑客松、深圳外国人活动,三张清单,一条命令。
4
+
5
+ **The Yat's List command-line client** — Shenzhen events, hackathons, and expat events in Shenzhen, right in your terminal. Zero third-party dependencies (Python standard library only).
6
+
7
+ > Shenzhen events, served to your Agent — and now to your terminal, too.
8
+
9
+ ## 安装 / Install
10
+
11
+ ```bash
12
+ pip install yatslist
13
+ ```
14
+
15
+ 需要 Python ≥ 3.9。Requires Python ≥ 3.9. No third-party dependencies.
16
+
17
+ ## 用法 / Usage
18
+
19
+ ```bash
20
+ # 深圳活动:今天起未来 7 天,按日期分组
21
+ yatslist
22
+
23
+ # 只看未来 3 天 / 输出 JSON
24
+ yatslist --days 3
25
+ yatslist --json
26
+
27
+ # 黑客松:报名未截止,按截止日升序
28
+ yatslist hackathons
29
+
30
+ # 黑客松:只看 72 小时内截止的(赶 deadline 用)
31
+ yatslist hackathons --soon
32
+
33
+ # 深圳外国人活动:未来 14 天(英文活动为主)
34
+ yatslist expat
35
+ yatslist expat --days 7
36
+
37
+ # 三张清单一起搜(标题 / 地点 / 简介),结果标注来源清单
38
+ yatslist search Agent
39
+ yatslist search 咖啡
40
+ ```
41
+
42
+ ### 示例输出 / Example output
43
+
44
+ ```
45
+ $ yatslist
46
+ Yat's List · 深圳活动(未来 7 天,共 69 场) 数据更新于 2026-11-08T11:14:12+08:00
47
+ 2026-11-08 周日
48
+ 13:00 xxx Meetup @ 南山智园
49
+ 19:00 xxx 公开课 @ 福田区某空间(进行中)
50
+ 2026-11-09 周一
51
+ ...
52
+
53
+ $ yatslist hackathons --soon
54
+ Yat's List · 黑客松(72 小时内截止,共 12 场,按截止日排序) 数据更新于 ...
55
+ 截止 2026-11-08 BoHack 2026 天津青年黑客松(天津 · 线下)
56
+ ...
57
+
58
+ $ yatslist search AI
59
+ Yat's List · 搜索「AI」(共 5 条)
60
+ [深圳活动] 2026-11-09 19:00 xxx AI 分享会
61
+ https://...
62
+ [黑客松] 截止 2026-11-13 xxx Hackathon
63
+ https://...
64
+ ```
65
+
66
+ ## 数据来源 / Data sources
67
+
68
+ 数据来自 [yatslist.com](https://yatslist.com) 公开 JSON,每日更新、逐条核实自官方页面:
69
+
70
+ | 清单 | 地址 |
71
+ | --- | --- |
72
+ | 深圳活动 | https://yatslist.com/events.json |
73
+ | 黑客松 | https://yatslist.com/hackathons.json |
74
+ | 深圳外国人活动 | https://yatslist.com/expat/events.json |
75
+
76
+ 说明 / Notes:
77
+
78
+ - 时间按数据源原文呈现,不做时区换算;日期筛选按数据时区(UTC+8)计算。
79
+ - Times are shown exactly as published in the data (UTC+8); no timezone conversion is applied.
80
+ - 网络失败或超时(10 秒)时会给出中文错误提示并以非零状态码退出。
81
+ - `search` 只搜未结束 / 未截止的条目。
82
+
83
+ ## 开发 / Development
84
+
85
+ ```bash
86
+ python -m unittest discover -s tests -v # 离线单元测试(fixtures 样本,不联网)
87
+ python -m build # 构建 sdist + wheel
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT © Yat Tse
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yatslist"
7
+ version = "0.1.0"
8
+ description = "Yat's List 命令行客户端:深圳活动 / 黑客松 / 深圳外国人活动 — Shenzhen events, served to your terminal."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Yat Tse" },
14
+ ]
15
+ keywords = ["shenzhen", "events", "hackathon", "cli", "yatslist"]
16
+ classifiers = [
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Utilities",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://yatslist.com"
26
+ "Events JSON" = "https://yatslist.com/events.json"
27
+ "Hackathons JSON" = "https://yatslist.com/hackathons.json"
28
+
29
+ [project.scripts]
30
+ yatslist = "yatslist.cli:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """yatslist — Yat's List 命令行客户端。"""
2
+
3
+ from .cli import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,398 @@
1
+ """yatslist — Yat's List 命令行客户端。
2
+
3
+ 零第三方依赖,只用 Python 标准库。从 yatslist.com 拉取三张公开清单的
4
+ JSON 数据(深圳活动 / 黑客松 / 深圳外国人活动),在终端里按日期浏览、
5
+ 按截止日排序、跨清单搜索。
6
+
7
+ 数据结构(2026-11 实测):
8
+ - events.json / expat events.json:顶层 {updated_at, events: [...]},
9
+ 条目 start/end 为带时区的 ISO 时间字符串(如 2026-11-08T19:00:00+08:00,
10
+ 可缺秒、可为 null)。
11
+ - hackathons.json:顶层 {updated_at, hackathons: [...]},
12
+ 条目 deadline/start 为纯日期字符串(YYYY-MM-DD)。
13
+ 输出时间一律取数据源原文,不做时区换算。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ import urllib.error
22
+ import urllib.request
23
+ from datetime import date, datetime, time, timedelta, timezone
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ # 数据源地址(模块级常量,改版时只改这里)
28
+ EVENTS_URL = "https://yatslist.com/events.json"
29
+ HACKATHONS_URL = "https://yatslist.com/hackathons.json"
30
+ EXPAT_URL = "https://yatslist.com/expat/events.json"
31
+
32
+ TIMEOUT = 10 # 秒
33
+ USER_AGENT = f"yatslist-cli/{__version__}"
34
+
35
+ # 三份数据均为 +08:00(Asia/Shanghai,无夏令时),用固定偏移做日期判断
36
+ DATA_TZ = timezone(timedelta(hours=8))
37
+
38
+ WEEKDAYS_ZH = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
39
+
40
+ SOURCE_EVENTS = "深圳活动"
41
+ SOURCE_HACKATHONS = "黑客松"
42
+ SOURCE_EXPAT = "外国人活动"
43
+
44
+
45
+ class FetchError(Exception):
46
+ """拉取或解析远程 JSON 失败。"""
47
+
48
+
49
+ # ---------------------------------------------------------------- 拉取与解析
50
+
51
+ def fetch_json(url: str):
52
+ """拉取并解析 JSON;失败抛 FetchError(带中文友好信息)。"""
53
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
56
+ raw = resp.read()
57
+ except urllib.error.HTTPError as e:
58
+ raise FetchError(f"服务器返回 {e.code}({url})") from e
59
+ except urllib.error.URLError as e:
60
+ raise FetchError(f"网络连接失败:{e.reason}({url})") from e
61
+ except TimeoutError as e:
62
+ raise FetchError(f"请求超时({TIMEOUT} 秒,{url})") from e
63
+ except OSError as e:
64
+ raise FetchError(f"网络错误:{e}({url})") from e
65
+ try:
66
+ return json.loads(raw.decode("utf-8"))
67
+ except (ValueError, UnicodeDecodeError) as e:
68
+ raise FetchError(f"数据格式异常,无法解析({url})") from e
69
+
70
+
71
+ def parse_datetime(value):
72
+ """解析 ISO 时间字符串 → aware datetime;纯日期按当天 00:00 处理。
73
+
74
+ 无法解析或为空返回 None。
75
+ """
76
+ if not value or not isinstance(value, str):
77
+ return None
78
+ text = value.strip()
79
+ try:
80
+ if "T" not in text and " " not in text:
81
+ d = date.fromisoformat(text)
82
+ return datetime.combine(d, time.min, tzinfo=DATA_TZ)
83
+ dt = datetime.fromisoformat(text)
84
+ except ValueError:
85
+ return None
86
+ if dt.tzinfo is None:
87
+ dt = dt.replace(tzinfo=DATA_TZ)
88
+ return dt
89
+
90
+
91
+ def parse_date(value):
92
+ """解析日期:优先取 ISO 时间的日期部分,也接受纯日期字符串。"""
93
+ dt = parse_datetime(value)
94
+ return dt.date() if dt else None
95
+
96
+
97
+ def raw_time(value):
98
+ """从 ISO 字符串原文截取 HH:MM(不做任何时区换算);无时间部分返回 None。"""
99
+ if not value or not isinstance(value, str):
100
+ return None
101
+ text = value.strip()
102
+ for sep in ("T", " "):
103
+ if sep in text:
104
+ tail = text.split(sep, 1)[1]
105
+ if len(tail) >= 5 and tail[2] == ":":
106
+ return tail[:5]
107
+ return None
108
+ return None
109
+
110
+
111
+ def now_in_data_tz() -> datetime:
112
+ return datetime.now(DATA_TZ)
113
+
114
+
115
+ # ---------------------------------------------------------------- 通用筛选
116
+
117
+ def filter_upcoming_events(items, days: int, today: date):
118
+ """筛出窗口 [today, today+days) 内仍未结束的活动。
119
+
120
+ 规则:开始日期 < 窗口末日,且结束日期(无 end 时用开始日期)>= today。
121
+ 返回 [(展示日期, 条目)],展示日期 = 开始日期;已开始未结束的(进行中)
122
+ 展示日期记为 today。无 start 的条目跳过。
123
+ """
124
+ window_end = today + timedelta(days=days)
125
+ picked = []
126
+ for item in items:
127
+ start_d = parse_date(item.get("start"))
128
+ if start_d is None:
129
+ continue
130
+ end_d = parse_date(item.get("end")) or start_d
131
+ if end_d < today or start_d >= window_end:
132
+ continue
133
+ display_d = start_d if start_d >= today else today
134
+ picked.append((display_d, item))
135
+ picked.sort(key=lambda pair: (pair[0], raw_time(pair[1].get("start")) or "", pair[1].get("title") or ""))
136
+ return picked
137
+
138
+
139
+ def group_by_date(picked):
140
+ """[(日期, 条目)] → [(日期, [条目, ...])],保持已有顺序。"""
141
+ groups = []
142
+ for d, item in picked:
143
+ if groups and groups[-1][0] == d:
144
+ groups[-1][1].append(item)
145
+ else:
146
+ groups.append((d, [item]))
147
+ return groups
148
+
149
+
150
+ def filter_hackathons(items, now: datetime, soon: bool = False, days=None):
151
+ """筛出报名未截止的黑客松,按截止日升序。
152
+
153
+ deadline 为纯日期,按当天 23:59:59(数据时区)仍可报名计。
154
+ soon=True 时只保留 72 小时内截止的;days 不为 None 时只保留
155
+ days 天内截止的。无 deadline 的条目跳过。
156
+ """
157
+ picked = []
158
+ for item in items:
159
+ dl = parse_date(item.get("deadline"))
160
+ if dl is None:
161
+ continue
162
+ dl_end = datetime.combine(dl, time(23, 59, 59), tzinfo=DATA_TZ)
163
+ if dl_end < now:
164
+ continue
165
+ if soon and dl_end > now + timedelta(hours=72):
166
+ continue
167
+ if days is not None and dl_end > now + timedelta(days=days):
168
+ continue
169
+ picked.append((dl, item))
170
+ picked.sort(key=lambda pair: (pair[0], pair[1].get("title") or ""))
171
+ return picked
172
+
173
+
174
+ # ---------------------------------------------------------------- 搜索
175
+
176
+ def _match(item, keyword, fields):
177
+ kw = keyword.lower()
178
+ for f in fields:
179
+ v = item.get(f)
180
+ if isinstance(v, str) and kw in v.lower():
181
+ return True
182
+ if isinstance(v, list) and any(isinstance(x, str) and kw in x.lower() for x in v):
183
+ return True
184
+ return False
185
+
186
+
187
+ EVENT_SEARCH_FIELDS = ["title", "title_en", "title_zh", "venue", "district", "summary", "summary_zh", "organizer"]
188
+ HACKATHON_SEARCH_FIELDS = ["title", "title_en", "city", "summary", "prize", "topics"]
189
+
190
+
191
+ def search_all(events, hackathons, expat_events, keyword, today: date, now: datetime):
192
+ """三张清单一起搜(仅未结束/未截止条目),返回 [(来源, 排序日期, 条目)]。"""
193
+ results = []
194
+ live_events = {id(it) for _, it in filter_upcoming_events(events, 3660, today)}
195
+ for item in events:
196
+ if id(item) in live_events and _match(item, keyword, EVENT_SEARCH_FIELDS):
197
+ results.append((SOURCE_EVENTS, parse_date(item.get("start")), item))
198
+ live_expat = {id(it) for _, it in filter_upcoming_events(expat_events, 3660, today)}
199
+ for item in expat_events:
200
+ if id(item) in live_expat and _match(item, keyword, EVENT_SEARCH_FIELDS):
201
+ results.append((SOURCE_EXPAT, parse_date(item.get("start")), item))
202
+ for dl, item in filter_hackathons(hackathons, now):
203
+ if _match(item, keyword, HACKATHON_SEARCH_FIELDS):
204
+ results.append((SOURCE_HACKATHONS, dl, item))
205
+ results.sort(key=lambda r: (r[1] or date.max, r[0], r[2].get("title") or ""))
206
+ return results
207
+
208
+
209
+ # ---------------------------------------------------------------- 输出格式
210
+
211
+ def fmt_date_header(d: date) -> str:
212
+ return f"{d.isoformat()} {WEEKDAYS_ZH[d.weekday()]}"
213
+
214
+
215
+ def fmt_event_line(item, today: date) -> str:
216
+ t = raw_time(item.get("start")) or "全天"
217
+ title = item.get("title") or "(无标题)"
218
+ line = f" {t} {title}"
219
+ venue = item.get("venue")
220
+ if venue:
221
+ line += f" @ {venue}"
222
+ start_d = parse_date(item.get("start"))
223
+ if start_d is not None and start_d < today:
224
+ line += "(进行中)"
225
+ return line
226
+
227
+
228
+ def fmt_expat_line(item, today: date) -> str:
229
+ t = raw_time(item.get("start")) or "全天"
230
+ title = item.get("title") or item.get("title_zh") or "(无标题)"
231
+ line = f" {t} {title}"
232
+ venue = item.get("venue") or item.get("area") or item.get("address")
233
+ if venue:
234
+ line += f" @ {venue}"
235
+ start_d = parse_date(item.get("start"))
236
+ if start_d is not None and start_d < today:
237
+ line += "(进行中)"
238
+ return line
239
+
240
+
241
+ def fmt_hackathon_line(item) -> str:
242
+ title = item.get("title") or "(无标题)"
243
+ line = f" 截止 {item.get('deadline')} {title}"
244
+ place = item.get("city")
245
+ fmt = item.get("format")
246
+ if place and fmt:
247
+ line += f"({place} · {fmt})"
248
+ elif place or fmt:
249
+ line += f"({place or fmt})"
250
+ return line
251
+
252
+
253
+ def print_event_groups(picked, today: date, line_fmt):
254
+ for d, group in group_by_date(picked):
255
+ print(fmt_date_header(d))
256
+ for item in group:
257
+ print(line_fmt(item, today))
258
+
259
+
260
+ # ---------------------------------------------------------------- 子命令
261
+
262
+ def _items(data, key):
263
+ if isinstance(data, dict) and isinstance(data.get(key), list):
264
+ return data[key]
265
+ if isinstance(data, list):
266
+ return data
267
+ return []
268
+
269
+
270
+ def cmd_events(args, expat: bool = False) -> int:
271
+ url = EXPAT_URL if expat else EVENTS_URL
272
+ days = args.days if args.days is not None else (14 if expat else 7)
273
+ data = fetch_json(url)
274
+ items = _items(data, "events")
275
+ today = now_in_data_tz().date()
276
+ picked = filter_upcoming_events(items, days, today)
277
+ if args.json:
278
+ print(json.dumps([it for _, it in picked], ensure_ascii=False, indent=2))
279
+ return 0
280
+ name = SOURCE_EXPAT if expat else SOURCE_EVENTS
281
+ updated = data.get("updated_at", "") if isinstance(data, dict) else ""
282
+ print(f"Yat's List · {name}(未来 {days} 天,共 {len(picked)} 场)" + (f" 数据更新于 {updated}" if updated else ""))
283
+ if not picked:
284
+ print("(这个时间段暂时没有活动)")
285
+ return 0
286
+ print_event_groups(picked, today, fmt_expat_line if expat else fmt_event_line)
287
+ return 0
288
+
289
+
290
+ def cmd_hackathons(args) -> int:
291
+ data = fetch_json(HACKATHONS_URL)
292
+ items = _items(data, "hackathons")
293
+ now = now_in_data_tz()
294
+ picked = filter_hackathons(items, now, soon=args.soon, days=args.days)
295
+ if args.json:
296
+ print(json.dumps([it for _, it in picked], ensure_ascii=False, indent=2))
297
+ return 0
298
+ scope = "72 小时内截止" if args.soon else "报名未截止"
299
+ updated = data.get("updated_at", "") if isinstance(data, dict) else ""
300
+ print(f"Yat's List · {SOURCE_HACKATHONS}({scope},共 {len(picked)} 场,按截止日排序)" + (f" 数据更新于 {updated}" if updated else ""))
301
+ if not picked:
302
+ print("(暂时没有符合条件的黑客松)")
303
+ return 0
304
+ for _, item in picked:
305
+ print(fmt_hackathon_line(item))
306
+ return 0
307
+
308
+
309
+ def cmd_search(args) -> int:
310
+ ev = _items(fetch_json(EVENTS_URL), "events")
311
+ hk = _items(fetch_json(HACKATHONS_URL), "hackathons")
312
+ ex = _items(fetch_json(EXPAT_URL), "events")
313
+ now = now_in_data_tz()
314
+ results = search_all(ev, hk, ex, args.keyword, now.date(), now)
315
+ if args.json:
316
+ out = []
317
+ for source, _, item in results:
318
+ obj = dict(item)
319
+ obj["source_list"] = source
320
+ out.append(obj)
321
+ print(json.dumps(out, ensure_ascii=False, indent=2))
322
+ return 0
323
+ print(f"Yat's List · 搜索「{args.keyword}」(共 {len(results)} 条)")
324
+ if not results:
325
+ print("(没有找到匹配的活动,换个关键词试试)")
326
+ return 0
327
+ for source, d, item in results:
328
+ if source == SOURCE_HACKATHONS:
329
+ head = f" [{source}] 截止 {item.get('deadline')}"
330
+ else:
331
+ t = raw_time(item.get("start")) or ""
332
+ head = f" [{source}] {(d.isoformat() if d else '')} {t}".rstrip()
333
+ print(f"{head} {item.get('title') or '(无标题)'}")
334
+ if item.get("url"):
335
+ print(f" {item['url']}")
336
+ return 0
337
+
338
+
339
+ # ---------------------------------------------------------------- 入口
340
+
341
+ def build_parser() -> argparse.ArgumentParser:
342
+ parser = argparse.ArgumentParser(
343
+ prog="yatslist",
344
+ description="Yat's List 命令行客户端:深圳活动 / 黑客松 / 深圳外国人活动(数据来源 https://yatslist.com)",
345
+ )
346
+ parser.add_argument("--version", action="version", version=f"yatslist {__version__}")
347
+ parser.add_argument("--days", type=int, default=None, help="只看未来 N 天(默认 7)")
348
+ parser.add_argument("--json", action="store_true", help="输出 JSON 数组")
349
+ sub = parser.add_subparsers(dest="command")
350
+
351
+ p_hk = sub.add_parser("hackathons", help="黑客松清单(报名未截止,按截止日排序)")
352
+ p_hk.add_argument("--soon", action="store_true", help="只看 72 小时内截止的")
353
+ p_hk.add_argument("--days", type=int, default=None, help="只看 N 天内截止的")
354
+ p_hk.add_argument("--json", action="store_true", help="输出 JSON 数组")
355
+
356
+ p_ex = sub.add_parser("expat", help="深圳外国人活动(默认未来 14 天)")
357
+ p_ex.add_argument("--days", type=int, default=None, help="只看未来 N 天(默认 14)")
358
+ p_ex.add_argument("--json", action="store_true", help="输出 JSON 数组")
359
+
360
+ p_se = sub.add_parser("search", help="三张清单一起搜")
361
+ p_se.add_argument("keyword", help="关键词(匹配标题/地点/简介)")
362
+ p_se.add_argument("--json", action="store_true", help="输出 JSON 数组")
363
+ return parser
364
+
365
+
366
+ def main(argv=None) -> int:
367
+ args = build_parser().parse_args(argv)
368
+ try:
369
+ return _dispatch(args)
370
+ except BrokenPipeError:
371
+ # 管道被关闭(如 | head):把 stdout 转到 /dev/null 后安静退出,不打堆栈
372
+ import os
373
+
374
+ try:
375
+ devnull = os.open(os.devnull, os.O_WRONLY)
376
+ os.dup2(devnull, sys.stdout.fileno())
377
+ except OSError:
378
+ pass
379
+ return 0
380
+
381
+
382
+ def _dispatch(args) -> int:
383
+ try:
384
+ if args.command == "hackathons":
385
+ return cmd_hackathons(args)
386
+ if args.command == "expat":
387
+ return cmd_events(args, expat=True)
388
+ if args.command == "search":
389
+ return cmd_search(args)
390
+ return cmd_events(args, expat=False)
391
+ except FetchError as e:
392
+ print(f"错误:{e}", file=sys.stderr)
393
+ print("请检查网络连接后重试。", file=sys.stderr)
394
+ return 1
395
+
396
+
397
+ if __name__ == "__main__":
398
+ sys.exit(main())
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: yatslist
3
+ Version: 0.1.0
4
+ Summary: Yat's List 命令行客户端:深圳活动 / 黑客松 / 深圳外国人活动 — Shenzhen events, served to your terminal.
5
+ Author: Yat Tse
6
+ License: MIT
7
+ Project-URL: Homepage, https://yatslist.com
8
+ Project-URL: Events JSON, https://yatslist.com/events.json
9
+ Project-URL: Hackathons JSON, https://yatslist.com/hackathons.json
10
+ Keywords: shenzhen,events,hackathon,cli,yatslist
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # yatslist
22
+
23
+ **Yat's List 的命令行客户端** — 深圳活动、黑客松、深圳外国人活动,三张清单,一条命令。
24
+
25
+ **The Yat's List command-line client** — Shenzhen events, hackathons, and expat events in Shenzhen, right in your terminal. Zero third-party dependencies (Python standard library only).
26
+
27
+ > Shenzhen events, served to your Agent — and now to your terminal, too.
28
+
29
+ ## 安装 / Install
30
+
31
+ ```bash
32
+ pip install yatslist
33
+ ```
34
+
35
+ 需要 Python ≥ 3.9。Requires Python ≥ 3.9. No third-party dependencies.
36
+
37
+ ## 用法 / Usage
38
+
39
+ ```bash
40
+ # 深圳活动:今天起未来 7 天,按日期分组
41
+ yatslist
42
+
43
+ # 只看未来 3 天 / 输出 JSON
44
+ yatslist --days 3
45
+ yatslist --json
46
+
47
+ # 黑客松:报名未截止,按截止日升序
48
+ yatslist hackathons
49
+
50
+ # 黑客松:只看 72 小时内截止的(赶 deadline 用)
51
+ yatslist hackathons --soon
52
+
53
+ # 深圳外国人活动:未来 14 天(英文活动为主)
54
+ yatslist expat
55
+ yatslist expat --days 7
56
+
57
+ # 三张清单一起搜(标题 / 地点 / 简介),结果标注来源清单
58
+ yatslist search Agent
59
+ yatslist search 咖啡
60
+ ```
61
+
62
+ ### 示例输出 / Example output
63
+
64
+ ```
65
+ $ yatslist
66
+ Yat's List · 深圳活动(未来 7 天,共 69 场) 数据更新于 2026-11-08T11:14:12+08:00
67
+ 2026-11-08 周日
68
+ 13:00 xxx Meetup @ 南山智园
69
+ 19:00 xxx 公开课 @ 福田区某空间(进行中)
70
+ 2026-11-09 周一
71
+ ...
72
+
73
+ $ yatslist hackathons --soon
74
+ Yat's List · 黑客松(72 小时内截止,共 12 场,按截止日排序) 数据更新于 ...
75
+ 截止 2026-11-08 BoHack 2026 天津青年黑客松(天津 · 线下)
76
+ ...
77
+
78
+ $ yatslist search AI
79
+ Yat's List · 搜索「AI」(共 5 条)
80
+ [深圳活动] 2026-11-09 19:00 xxx AI 分享会
81
+ https://...
82
+ [黑客松] 截止 2026-11-13 xxx Hackathon
83
+ https://...
84
+ ```
85
+
86
+ ## 数据来源 / Data sources
87
+
88
+ 数据来自 [yatslist.com](https://yatslist.com) 公开 JSON,每日更新、逐条核实自官方页面:
89
+
90
+ | 清单 | 地址 |
91
+ | --- | --- |
92
+ | 深圳活动 | https://yatslist.com/events.json |
93
+ | 黑客松 | https://yatslist.com/hackathons.json |
94
+ | 深圳外国人活动 | https://yatslist.com/expat/events.json |
95
+
96
+ 说明 / Notes:
97
+
98
+ - 时间按数据源原文呈现,不做时区换算;日期筛选按数据时区(UTC+8)计算。
99
+ - Times are shown exactly as published in the data (UTC+8); no timezone conversion is applied.
100
+ - 网络失败或超时(10 秒)时会给出中文错误提示并以非零状态码退出。
101
+ - `search` 只搜未结束 / 未截止的条目。
102
+
103
+ ## 开发 / Development
104
+
105
+ ```bash
106
+ python -m unittest discover -s tests -v # 离线单元测试(fixtures 样本,不联网)
107
+ python -m build # 构建 sdist + wheel
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT © Yat Tse
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/yatslist/__init__.py
5
+ src/yatslist/__main__.py
6
+ src/yatslist/cli.py
7
+ src/yatslist.egg-info/PKG-INFO
8
+ src/yatslist.egg-info/SOURCES.txt
9
+ src/yatslist.egg-info/dependency_links.txt
10
+ src/yatslist.egg-info/entry_points.txt
11
+ src/yatslist.egg-info/top_level.txt
12
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yatslist = yatslist.cli:main
@@ -0,0 +1 @@
1
+ yatslist
@@ -0,0 +1,167 @@
1
+ """离线单元测试:用 tests/fixtures 下的样本 JSON 测筛选/分组/搜索逻辑。
2
+
3
+ 固定 now = 2026-11-08(周日)12:00 +08:00,保证结果确定。
4
+ 运行:python -m unittest discover -s tests -v(在项目根目录)
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import unittest
11
+ from datetime import datetime
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
14
+
15
+ from yatslist import cli # noqa: E402
16
+
17
+ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
18
+ NOW = datetime(2026, 11, 8, 12, 0, tzinfo=cli.DATA_TZ)
19
+ TODAY = NOW.date()
20
+
21
+
22
+ def load(name):
23
+ with open(os.path.join(FIXTURES, name), encoding="utf-8") as f:
24
+ return json.load(f)
25
+
26
+
27
+ class TestParsing(unittest.TestCase):
28
+ def test_parse_datetime_with_seconds(self):
29
+ dt = cli.parse_datetime("2026-11-08T14:00:00+08:00")
30
+ self.assertEqual((dt.hour, dt.minute), (14, 0))
31
+
32
+ def test_parse_datetime_without_seconds(self):
33
+ dt = cli.parse_datetime("2026-11-14T09:30+08:00")
34
+ self.assertEqual((dt.hour, dt.minute), (9, 30))
35
+
36
+ def test_parse_datetime_date_only(self):
37
+ dt = cli.parse_datetime("2026-11-10")
38
+ self.assertEqual(dt.date().isoformat(), "2026-11-10")
39
+
40
+ def test_parse_none_and_garbage(self):
41
+ self.assertIsNone(cli.parse_datetime(None))
42
+ self.assertIsNone(cli.parse_datetime(""))
43
+ self.assertIsNone(cli.parse_datetime("not-a-date"))
44
+
45
+ def test_raw_time_takes_source_text(self):
46
+ self.assertEqual(cli.raw_time("2026-11-08T19:00:00+08:00"), "19:00")
47
+ self.assertEqual(cli.raw_time("2026-11-08T09:30+08:00"), "09:30")
48
+ self.assertIsNone(cli.raw_time("2026-11-08"))
49
+ self.assertIsNone(cli.raw_time(None))
50
+
51
+
52
+ class TestFilterEvents(unittest.TestCase):
53
+ def setUp(self):
54
+ self.items = load("events.json")["events"]
55
+
56
+ def test_window_7_days(self):
57
+ picked = cli.filter_upcoming_events(self.items, 7, TODAY)
58
+ titles = [it["title"] for _, it in picked]
59
+ self.assertIn("今天下午的分享会", titles)
60
+ self.assertIn("进行中的展览", titles)
61
+ self.assertIn("第七天的活动(窗口内)", titles) # 11-14 在 [11-08, 11-15) 内
62
+ self.assertNotIn("第八天的活动(窗口外)", titles) # 11-15 起,窗口外
63
+ self.assertNotIn("已经结束的活动", titles)
64
+ self.assertNotIn("没有开始时间的活动", titles)
65
+ self.assertEqual(len(picked), 3)
66
+
67
+ def test_ongoing_displayed_under_today(self):
68
+ picked = cli.filter_upcoming_events(self.items, 7, TODAY)
69
+ by_title = {it["title"]: d for d, it in picked}
70
+ self.assertEqual(by_title["进行中的展览"], TODAY) # 11-01 开始、未结束 → 归到今天
71
+
72
+ def test_grouping(self):
73
+ picked = cli.filter_upcoming_events(self.items, 7, TODAY)
74
+ groups = cli.group_by_date(picked)
75
+ self.assertEqual([g[0].isoformat() for g in groups], ["2026-11-08", "2026-11-14"])
76
+ self.assertEqual(len(groups[0][1]), 2)
77
+
78
+ def test_days_override(self):
79
+ picked = cli.filter_upcoming_events(self.items, 1, TODAY)
80
+ titles = [it["title"] for _, it in picked]
81
+ self.assertEqual(sorted(titles), ["今天下午的分享会", "进行中的展览"])
82
+
83
+
84
+ class TestFilterHackathons(unittest.TestCase):
85
+ def setUp(self):
86
+ self.items = load("hackathons.json")["hackathons"]
87
+
88
+ def test_unexpired_sorted_by_deadline(self):
89
+ picked = cli.filter_hackathons(self.items, NOW)
90
+ titles = [it["title"] for _, it in picked]
91
+ self.assertEqual(
92
+ titles,
93
+ ["今天截止的黑客松", "两天后截止的黑客松", "下个月截止的黑客松"],
94
+ )
95
+
96
+ def test_soon_72h(self):
97
+ picked = cli.filter_hackathons(self.items, NOW, soon=True)
98
+ titles = [it["title"] for _, it in picked]
99
+ self.assertEqual(titles, ["今天截止的黑客松", "两天后截止的黑客松"])
100
+
101
+ def test_days_window(self):
102
+ picked = cli.filter_hackathons(self.items, NOW, days=3)
103
+ self.assertEqual(len(picked), 2)
104
+
105
+
106
+ class TestExpatWindow(unittest.TestCase):
107
+ def test_default_14_days(self):
108
+ items = load("expat.json")["events"]
109
+ picked = cli.filter_upcoming_events(items, 14, TODAY)
110
+ titles = [it["title"] for _, it in picked]
111
+ self.assertEqual(sorted(titles), ["English Comedy Night", "Long-running Art Exhibition"])
112
+
113
+
114
+ class TestSearch(unittest.TestCase):
115
+ def setUp(self):
116
+ self.ev = load("events.json")["events"]
117
+ self.hk = load("hackathons.json")["hackathons"]
118
+ self.ex = load("expat.json")["events"]
119
+
120
+ def test_search_events_by_venue(self):
121
+ results = cli.search_all(self.ev, self.hk, self.ex, "美术馆", TODAY, NOW)
122
+ self.assertEqual(len(results), 1)
123
+ self.assertEqual(results[0][0], cli.SOURCE_EVENTS)
124
+ self.assertEqual(results[0][2]["title"], "进行中的展览")
125
+
126
+ def test_search_hackathons_by_topic(self):
127
+ results = cli.search_all(self.ev, self.hk, self.ex, "web3", TODAY, NOW)
128
+ self.assertEqual(len(results), 1)
129
+ self.assertEqual(results[0][0], cli.SOURCE_HACKATHONS)
130
+
131
+ def test_search_expat_case_insensitive(self):
132
+ results = cli.search_all(self.ev, self.hk, self.ex, "COMEDY", TODAY, NOW)
133
+ self.assertEqual(len(results), 1)
134
+ self.assertEqual(results[0][0], cli.SOURCE_EXPAT)
135
+
136
+ def test_search_skips_expired(self):
137
+ results = cli.search_all(self.ev, self.hk, self.ex, "咖啡馆", TODAY, NOW)
138
+ self.assertEqual(results, []) # 唯一匹配是已结束活动
139
+
140
+ def test_search_no_match(self):
141
+ results = cli.search_all(self.ev, self.hk, self.ex, "不存在的词xyz", TODAY, NOW)
142
+ self.assertEqual(results, [])
143
+
144
+
145
+ class TestFormatting(unittest.TestCase):
146
+ def test_event_line(self):
147
+ item = load("events.json")["events"][0]
148
+ line = cli.fmt_event_line(item, TODAY)
149
+ self.assertEqual(line, " 14:00 今天下午的分享会 @ 南山智园")
150
+
151
+ def test_ongoing_marker(self):
152
+ item = load("events.json")["events"][1]
153
+ line = cli.fmt_event_line(item, TODAY)
154
+ self.assertTrue(line.endswith("(进行中)"))
155
+
156
+ def test_hackathon_line(self):
157
+ item = load("hackathons.json")["hackathons"][0]
158
+ line = cli.fmt_hackathon_line(item)
159
+ self.assertEqual(line, " 截止 2026-11-08 今天截止的黑客松(深圳 · 线下)")
160
+
161
+ def test_date_header_weekday(self):
162
+ # 2026-11-08 是周日
163
+ self.assertEqual(cli.fmt_date_header(TODAY), "2026-11-08 周日")
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()