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 +21 -0
- yatslist-0.1.0/PKG-INFO +112 -0
- yatslist-0.1.0/README.md +92 -0
- yatslist-0.1.0/pyproject.toml +33 -0
- yatslist-0.1.0/setup.cfg +4 -0
- yatslist-0.1.0/src/yatslist/__init__.py +5 -0
- yatslist-0.1.0/src/yatslist/__main__.py +6 -0
- yatslist-0.1.0/src/yatslist/cli.py +398 -0
- yatslist-0.1.0/src/yatslist.egg-info/PKG-INFO +112 -0
- yatslist-0.1.0/src/yatslist.egg-info/SOURCES.txt +12 -0
- yatslist-0.1.0/src/yatslist.egg-info/dependency_links.txt +1 -0
- yatslist-0.1.0/src/yatslist.egg-info/entry_points.txt +2 -0
- yatslist-0.1.0/src/yatslist.egg-info/top_level.txt +1 -0
- yatslist-0.1.0/tests/test_cli.py +167 -0
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.
|
yatslist-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
yatslist-0.1.0/README.md
ADDED
|
@@ -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"]
|
yatslist-0.1.0/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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()
|