rednote-cli 0.1.0__py3-none-any.whl

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.
Files changed (81) hide show
  1. rednote_cli/__init__.py +5 -0
  2. rednote_cli/_runtime/__init__.py +0 -0
  3. rednote_cli/_runtime/common/__init__.py +0 -0
  4. rednote_cli/_runtime/common/app_utils.py +77 -0
  5. rednote_cli/_runtime/common/config.py +83 -0
  6. rednote_cli/_runtime/common/enums.py +17 -0
  7. rednote_cli/_runtime/common/errors.py +22 -0
  8. rednote_cli/_runtime/core/__init__.py +0 -0
  9. rednote_cli/_runtime/core/account_manager.py +349 -0
  10. rednote_cli/_runtime/core/browser/__init__.py +0 -0
  11. rednote_cli/_runtime/core/browser/manager.py +247 -0
  12. rednote_cli/_runtime/core/database/__init__.py +0 -0
  13. rednote_cli/_runtime/core/database/manager.py +334 -0
  14. rednote_cli/_runtime/platforms/__init__.py +0 -0
  15. rednote_cli/_runtime/platforms/base.py +62 -0
  16. rednote_cli/_runtime/platforms/factory.py +55 -0
  17. rednote_cli/_runtime/platforms/publishing/__init__.py +12 -0
  18. rednote_cli/_runtime/platforms/publishing/media.py +275 -0
  19. rednote_cli/_runtime/platforms/publishing/models.py +59 -0
  20. rednote_cli/_runtime/platforms/publishing/validator.py +124 -0
  21. rednote_cli/_runtime/services/__init__.py +1 -0
  22. rednote_cli/_runtime/services/scraper_service.py +235 -0
  23. rednote_cli/adapters/__init__.py +1 -0
  24. rednote_cli/adapters/output/__init__.py +1 -0
  25. rednote_cli/adapters/output/event_stream.py +29 -0
  26. rednote_cli/adapters/output/formatter_json.py +23 -0
  27. rednote_cli/adapters/output/formatter_table.py +39 -0
  28. rednote_cli/adapters/output/writer.py +17 -0
  29. rednote_cli/adapters/persistence/__init__.py +1 -0
  30. rednote_cli/adapters/persistence/file_account_repo.py +51 -0
  31. rednote_cli/adapters/platform/__init__.py +1 -0
  32. rednote_cli/adapters/platform/rednote/__init__.py +1 -0
  33. rednote_cli/adapters/platform/rednote/extractor.py +65 -0
  34. rednote_cli/adapters/platform/rednote/publisher.py +26 -0
  35. rednote_cli/adapters/platform/rednote/runtime_extractor.py +818 -0
  36. rednote_cli/adapters/platform/rednote/runtime_publisher.py +373 -0
  37. rednote_cli/adapters/platform/rednote/runtime_registration.py +20 -0
  38. rednote_cli/application/__init__.py +1 -0
  39. rednote_cli/application/dto/__init__.py +1 -0
  40. rednote_cli/application/dto/input_models.py +121 -0
  41. rednote_cli/application/dto/output_models.py +78 -0
  42. rednote_cli/application/use_cases/__init__.py +1 -0
  43. rednote_cli/application/use_cases/account_list.py +9 -0
  44. rednote_cli/application/use_cases/account_mutation.py +22 -0
  45. rednote_cli/application/use_cases/auth_login.py +64 -0
  46. rednote_cli/application/use_cases/auth_status.py +96 -0
  47. rednote_cli/application/use_cases/doctor.py +49 -0
  48. rednote_cli/application/use_cases/init_runtime.py +20 -0
  49. rednote_cli/application/use_cases/note_get.py +22 -0
  50. rednote_cli/application/use_cases/note_search.py +26 -0
  51. rednote_cli/application/use_cases/publish_note.py +25 -0
  52. rednote_cli/application/use_cases/user_get.py +18 -0
  53. rednote_cli/application/use_cases/user_search.py +8 -0
  54. rednote_cli/application/use_cases/user_self.py +8 -0
  55. rednote_cli/cli/__init__.py +1 -0
  56. rednote_cli/cli/__main__.py +5 -0
  57. rednote_cli/cli/commands/__init__.py +1 -0
  58. rednote_cli/cli/commands/account.py +204 -0
  59. rednote_cli/cli/commands/doctor.py +20 -0
  60. rednote_cli/cli/commands/init.py +20 -0
  61. rednote_cli/cli/commands/note.py +101 -0
  62. rednote_cli/cli/commands/publish.py +147 -0
  63. rednote_cli/cli/commands/search.py +185 -0
  64. rednote_cli/cli/commands/user.py +113 -0
  65. rednote_cli/cli/main.py +163 -0
  66. rednote_cli/cli/options.py +13 -0
  67. rednote_cli/cli/runtime.py +142 -0
  68. rednote_cli/cli/utils.py +74 -0
  69. rednote_cli/domain/__init__.py +1 -0
  70. rednote_cli/domain/errors.py +50 -0
  71. rednote_cli/domain/note_search_filters.py +155 -0
  72. rednote_cli/infra/__init__.py +1 -0
  73. rednote_cli/infra/exit_codes.py +30 -0
  74. rednote_cli/infra/logger.py +11 -0
  75. rednote_cli/infra/paths.py +31 -0
  76. rednote_cli/infra/platforms.py +4 -0
  77. rednote_cli-0.1.0.dist-info/METADATA +81 -0
  78. rednote_cli-0.1.0.dist-info/RECORD +81 -0
  79. rednote_cli-0.1.0.dist-info/WHEEL +5 -0
  80. rednote_cli-0.1.0.dist-info/entry_points.txt +2 -0
  81. rednote_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import typer
6
+ from loguru import logger
7
+
8
+ from rednote_cli._runtime.common.config import is_browser_headful_forced
9
+ from rednote_cli._runtime.core.database.manager import init_db
10
+ from rednote_cli.adapters.platform.rednote.runtime_registration import register_rednote_runtime
11
+ from rednote_cli.cli.commands import account, doctor, init, note, publish, search, user
12
+ from rednote_cli.cli.options import CliContext
13
+ from rednote_cli.infra.logger import setup_cli_logger
14
+ from rednote_cli.infra.paths import configure_legacy_data_paths, ensure_runtime_dirs
15
+ from rednote_cli.infra.platforms import REDNOTE_PLATFORM
16
+
17
+ APP_HELP = """
18
+ Rednote CLI(非驻留,一次命令一次进程)。
19
+
20
+ 推荐给 AI/Agent 的调用约定:
21
+ - 输出固定使用 `--format json`
22
+ - 每次调用传 `--trace-id`
23
+ - 需要批量参数时使用 `--input <json-file|->`
24
+
25
+ 输入优先级:
26
+ - 显式 CLI 参数 > `--input` JSON 同名字段 > 默认值
27
+
28
+ 输出契约与错误码:
29
+ - 成功/失败 JSON 结构见 `schemas/*.json`
30
+ - 退出码:0 成功,2 参数错,3 认证错,4 风控,5 内部错,6 超时,7 依赖缺失/未实现
31
+
32
+ 快速开始:
33
+ - `rednote-cli init runtime`
34
+ - `rednote-cli doctor run --format json`
35
+ - `rednote-cli search note --keyword 旅行 --size 10 --sort-by latest --note-type image_text --format json`
36
+ - `rednote-cli note --note-id <id> --format json`
37
+ """.strip()
38
+
39
+ app = typer.Typer(help=APP_HELP, add_completion=False, no_args_is_help=True)
40
+
41
+ app.add_typer(init.app, name="init")
42
+ app.add_typer(doctor.app, name="doctor")
43
+ app.add_typer(account.app, name="account")
44
+ app.add_typer(note.app, name="note")
45
+ app.add_typer(user.app, name="user")
46
+ app.add_typer(search.app, name="search")
47
+ app.add_typer(publish.app, name="publish")
48
+
49
+
50
+ @app.callback()
51
+ def main(
52
+ ctx: typer.Context,
53
+ format: str = typer.Option("table", "--format", help="输出格式:json(推荐给 Agent) | jsonl(流式) | table(人工查看)"),
54
+ out: str | None = typer.Option(None, "--out", help="将最终输出写入文件;stderr 日志不受影响"),
55
+ quiet: bool = typer.Option(False, "--quiet", help="静默日志,仅输出结果"),
56
+ trace_id: str | None = typer.Option(None, "--trace-id", help="请求链路 ID,方便排查和关联日志/结果;建议外部系统全程透传"),
57
+ timeout: int = typer.Option(120, "--timeout", help="命令超时(秒)"),
58
+ ):
59
+ if format not in {"json", "jsonl", "table"}:
60
+ raise typer.BadParameter("--format 仅支持 json|jsonl|table")
61
+
62
+ register_rednote_runtime()
63
+ ensure_runtime_dirs()
64
+ configure_legacy_data_paths()
65
+ init_db()
66
+ setup_cli_logger(quiet=quiet)
67
+ if is_browser_headful_forced():
68
+ logger.warning("Browser headful mode forced by env (REDNOTE_CLI_HEADFUL/BROWSER_HEADFUL).")
69
+
70
+ ctx.obj = CliContext(
71
+ platform_name=REDNOTE_PLATFORM,
72
+ output_format=format,
73
+ out_file=out,
74
+ quiet=quiet,
75
+ trace_id=trace_id,
76
+ timeout=timeout,
77
+ )
78
+
79
+
80
+ def _normalize_global_options_position() -> None:
81
+ """Allow global options to appear after subcommands.
82
+
83
+ Click/Typer requires group options to appear before subcommands by default.
84
+ This shim rewrites argv so both forms work:
85
+ - rednote-cli --format json doctor run
86
+ - rednote-cli doctor run --format json
87
+ """
88
+ if len(sys.argv) <= 1:
89
+ return
90
+
91
+ command_names = {"init", "doctor", "account", "note", "user", "search", "publish"}
92
+ value_options = {"--format", "--out", "--trace-id", "--timeout"}
93
+ flag_options = {"--quiet"}
94
+ all_options = value_options | flag_options
95
+
96
+ raw_args = sys.argv[1:]
97
+ moved: list[str] = []
98
+ kept: list[str] = []
99
+
100
+ def _die_missing_value(option: str) -> None:
101
+ option_examples = {
102
+ "--format": "rednote-cli doctor run --format json",
103
+ "--out": "rednote-cli doctor run --out result.json",
104
+ "--trace-id": "rednote-cli doctor run --trace-id req-001",
105
+ "--timeout": "rednote-cli doctor run --timeout 120",
106
+ }
107
+ example = option_examples.get(option, f"rednote-cli doctor run {option} <value>")
108
+ typer.echo(
109
+ f"Error: `{option}` 需要一个值。例如:`{example}`",
110
+ err=True,
111
+ )
112
+ raise SystemExit(2)
113
+
114
+ i = 0
115
+ while i < len(raw_args):
116
+ token = raw_args[i]
117
+ if token == "--":
118
+ kept.extend(raw_args[i:])
119
+ break
120
+
121
+ if token in flag_options:
122
+ moved.append(token)
123
+ i += 1
124
+ continue
125
+
126
+ if token in value_options:
127
+ if i + 1 >= len(raw_args):
128
+ _die_missing_value(token)
129
+ next_token = raw_args[i + 1]
130
+ if next_token == "--":
131
+ _die_missing_value(token)
132
+ if token == "--out" and next_token in command_names:
133
+ _die_missing_value(token)
134
+ moved.append(token)
135
+ moved.append(next_token)
136
+ i += 2
137
+ continue
138
+
139
+ matched_inline = False
140
+ for opt in all_options:
141
+ prefix = f"{opt}="
142
+ if token.startswith(prefix):
143
+ moved.append(token)
144
+ matched_inline = True
145
+ break
146
+ if matched_inline:
147
+ i += 1
148
+ continue
149
+
150
+ kept.append(token)
151
+ i += 1
152
+
153
+ if moved:
154
+ sys.argv = [sys.argv[0], *moved, *kept]
155
+
156
+
157
+ def run() -> None:
158
+ _normalize_global_options_position()
159
+ app()
160
+
161
+
162
+ if __name__ == "__main__":
163
+ run()
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class CliContext:
8
+ platform_name: str = "Rednote"
9
+ output_format: str = "table"
10
+ out_file: str | None = None
11
+ quiet: bool = False
12
+ trace_id: str | None = None
13
+ timeout: int = 120
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ import uuid
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ import typer
9
+ from pydantic import ValidationError
10
+
11
+ from rednote_cli._runtime.common.errors import InvalidPublishParameterError, PublishNoteException
12
+ from rednote_cli._runtime.platforms.base import RiskControlException
13
+ from rednote_cli.adapters.output.formatter_json import format_json, format_jsonl
14
+ from rednote_cli.adapters.output.formatter_table import format_table
15
+ from rednote_cli.adapters.output.writer import write_output
16
+ from rednote_cli.application.dto.output_models import build_error_output, build_success_output
17
+ from rednote_cli.cli.options import CliContext
18
+ from rednote_cli.domain.errors import CliError, InternalError
19
+ from rednote_cli.infra.exit_codes import ExitCode, map_error_code_to_exit_code
20
+
21
+
22
+ async def _with_timeout(coro: Awaitable[Any], timeout: int) -> Any:
23
+ return await asyncio.wait_for(coro, timeout=timeout)
24
+
25
+
26
+ def _render_payload(payload: Any, output_format: str) -> str:
27
+ if output_format == "json":
28
+ return format_json(payload)
29
+ if output_format == "jsonl":
30
+ if isinstance(payload, list):
31
+ return format_jsonl(payload)
32
+ return format_jsonl([payload])
33
+ if hasattr(payload, "model_dump"):
34
+ payload = payload.model_dump(mode="json")
35
+ if isinstance(payload, dict) and "data" in payload:
36
+ return format_table(payload["data"])
37
+ return format_table(payload)
38
+
39
+
40
+ def _normalize_error(exc: Exception) -> CliError:
41
+ if isinstance(exc, CliError):
42
+ return exc
43
+ if isinstance(exc, ValidationError):
44
+ try:
45
+ normalized_errors = exc.errors(include_context=False, include_input=False)
46
+ except TypeError:
47
+ normalized_errors = exc.errors()
48
+ return CliError(code="INVALID_ARGS", message="参数校验失败", details={"errors": normalized_errors})
49
+ if isinstance(exc, InvalidPublishParameterError):
50
+ return CliError(code="INVALID_ARGS", message=str(exc))
51
+ if isinstance(exc, RiskControlException):
52
+ return CliError(code="RISK_CONTROL_TRIGGERED", message=str(exc))
53
+ if isinstance(exc, asyncio.TimeoutError):
54
+ return CliError(code="TIMEOUT", message="命令执行超时")
55
+ if isinstance(exc, PublishNoteException):
56
+ return CliError(code="INTERNAL_ERROR", message=str(exc))
57
+ if "No available" in str(exc):
58
+ return CliError(code="ACCOUNT_UNAVAILABLE", message=str(exc))
59
+ internal = InternalError(message=f"未处理异常: {exc}")
60
+ return CliError(code=internal.code, message=internal.message)
61
+
62
+
63
+ def run_sync_command(
64
+ *,
65
+ ctx: CliContext,
66
+ command: str,
67
+ func: Callable[[], Any],
68
+ platform: str | None = None,
69
+ account_uid: str | None = None,
70
+ ) -> None:
71
+ effective_platform = platform or ctx.platform_name
72
+ trace_id = ctx.trace_id or uuid.uuid4().hex
73
+ start = time.time()
74
+ try:
75
+ data = func()
76
+ duration_ms = int((time.time() - start) * 1000)
77
+ payload = build_success_output(
78
+ command=command,
79
+ data=data,
80
+ trace_id=trace_id,
81
+ duration_ms=duration_ms,
82
+ platform=effective_platform,
83
+ account_uid=account_uid,
84
+ )
85
+ write_output(_render_payload(payload, ctx.output_format), out_file=ctx.out_file)
86
+ raise typer.Exit(code=ExitCode.SUCCESS.value)
87
+ except typer.Exit:
88
+ raise
89
+ except Exception as exc:
90
+ err = _normalize_error(exc)
91
+ duration_ms = int((time.time() - start) * 1000)
92
+ payload = build_error_output(
93
+ command=command,
94
+ code=err.code,
95
+ message=err.message,
96
+ details=err.details,
97
+ trace_id=trace_id,
98
+ duration_ms=duration_ms,
99
+ )
100
+ write_output(format_json(payload), out_file=ctx.out_file)
101
+ raise typer.Exit(code=map_error_code_to_exit_code(err.code).value)
102
+
103
+
104
+ def run_async_command(
105
+ *,
106
+ ctx: CliContext,
107
+ command: str,
108
+ func: Callable[[], Awaitable[Any]],
109
+ platform: str | None = None,
110
+ account_uid: str | None = None,
111
+ ) -> None:
112
+ effective_platform = platform or ctx.platform_name
113
+ trace_id = ctx.trace_id or uuid.uuid4().hex
114
+ start = time.time()
115
+ try:
116
+ data = asyncio.run(_with_timeout(func(), timeout=ctx.timeout))
117
+ duration_ms = int((time.time() - start) * 1000)
118
+ payload = build_success_output(
119
+ command=command,
120
+ data=data,
121
+ trace_id=trace_id,
122
+ duration_ms=duration_ms,
123
+ platform=effective_platform,
124
+ account_uid=account_uid,
125
+ )
126
+ write_output(_render_payload(payload, ctx.output_format), out_file=ctx.out_file)
127
+ raise typer.Exit(code=ExitCode.SUCCESS.value)
128
+ except typer.Exit:
129
+ raise
130
+ except Exception as exc:
131
+ err = _normalize_error(exc)
132
+ duration_ms = int((time.time() - start) * 1000)
133
+ payload = build_error_output(
134
+ command=command,
135
+ code=err.code,
136
+ message=err.message,
137
+ details=err.details,
138
+ trace_id=trace_id,
139
+ duration_ms=duration_ms,
140
+ )
141
+ write_output(format_json(payload), out_file=ctx.out_file)
142
+ raise typer.Exit(code=map_error_code_to_exit_code(err.code).value)
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from click import Option
8
+ from click.core import ParameterSource
9
+
10
+
11
+ def parse_csv(value: str | None) -> list[str]:
12
+ if not value:
13
+ return []
14
+ return [item.strip() for item in value.split(",") if item and item.strip()]
15
+
16
+
17
+ def load_json_input(path_or_dash: str | None) -> dict[str, Any]:
18
+ if not path_or_dash:
19
+ return {}
20
+ if path_or_dash == "-":
21
+ import sys
22
+
23
+ raw = sys.stdin.read()
24
+ return json.loads(raw) if raw.strip() else {}
25
+ path = Path(path_or_dash).expanduser().resolve()
26
+ if not path.exists():
27
+ raise FileNotFoundError(f"input 文件不存在: {path}")
28
+ return json.loads(path.read_text(encoding="utf-8"))
29
+
30
+
31
+ def pick_value(value: Any, fallback_dict: dict[str, Any], key: str) -> Any:
32
+ if value is not None:
33
+ return value
34
+ return fallback_dict.get(key)
35
+
36
+
37
+ def pick_cli_or_input(
38
+ *,
39
+ ctx: Any,
40
+ param_name: str,
41
+ cli_value: Any,
42
+ payload: dict[str, Any],
43
+ payload_key: str,
44
+ ) -> Any:
45
+ """Resolve value precedence: explicit CLI option > --input JSON > CLI default."""
46
+ try:
47
+ source = ctx.get_parameter_source(param_name)
48
+ except Exception:
49
+ source = None
50
+
51
+ if source == ParameterSource.DEFAULT and payload_key in payload:
52
+ return payload[payload_key]
53
+ if cli_value is not None:
54
+ return cli_value
55
+ return payload.get(payload_key)
56
+
57
+
58
+ def all_option_params_are_default(*, ctx: Any) -> bool:
59
+ """Return True when all option params are from default source (no CLI override)."""
60
+ command = getattr(ctx, "command", None)
61
+ params = getattr(command, "params", None) or []
62
+ for param in params:
63
+ if not isinstance(param, Option):
64
+ continue
65
+ name = getattr(param, "name", None)
66
+ if not name:
67
+ continue
68
+ try:
69
+ source = ctx.get_parameter_source(name)
70
+ except Exception:
71
+ return False
72
+ if source != ParameterSource.DEFAULT:
73
+ return False
74
+ return True
@@ -0,0 +1 @@
1
+ """Domain layer."""
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class CliError(Exception):
8
+ """Base CLI error carrying protocol-level codes."""
9
+
10
+ code: str
11
+ message: str
12
+ details: dict | None = None
13
+
14
+ def __str__(self) -> str: # pragma: no cover - trivial
15
+ return self.message
16
+
17
+
18
+ class InvalidArgsError(CliError):
19
+ def __init__(self, message: str, details: dict | None = None):
20
+ super().__init__("INVALID_ARGS", message, details)
21
+
22
+
23
+ class AuthRequiredError(CliError):
24
+ def __init__(self, message: str = "登录失效或未登录", details: dict | None = None):
25
+ super().__init__("AUTH_REQUIRED", message, details)
26
+
27
+
28
+ class AccountUnavailableError(CliError):
29
+ def __init__(self, message: str = "无可用账号", details: dict | None = None):
30
+ super().__init__("ACCOUNT_UNAVAILABLE", message, details)
31
+
32
+
33
+ class RiskControlError(CliError):
34
+ def __init__(self, message: str = "命中风控", details: dict | None = None):
35
+ super().__init__("RISK_CONTROL_TRIGGERED", message, details)
36
+
37
+
38
+ class DependencyMissingError(CliError):
39
+ def __init__(self, message: str = "运行依赖缺失", details: dict | None = None):
40
+ super().__init__("DEPENDENCY_MISSING", message, details)
41
+
42
+
43
+ class CliTimeoutError(CliError):
44
+ def __init__(self, message: str = "执行超时", details: dict | None = None):
45
+ super().__init__("TIMEOUT", message, details)
46
+
47
+
48
+ class InternalError(CliError):
49
+ def __init__(self, message: str = "内部异常", details: dict | None = None):
50
+ super().__init__("INTERNAL_ERROR", message, details)
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ FILTER_FIELD_ORDER = ("sort_by", "note_type", "publish_time", "search_scope", "location")
6
+
7
+ _FILTER_CONFIG = {
8
+ "sort_by": {
9
+ "aliases": {
10
+ "comprehensive": "comprehensive",
11
+ "综合": "comprehensive",
12
+ "latest": "latest",
13
+ "最新": "latest",
14
+ "most_liked": "most_liked",
15
+ "最多点赞": "most_liked",
16
+ "most_commented": "most_commented",
17
+ "最多评论": "most_commented",
18
+ "most_favorited": "most_favorited",
19
+ "最多收藏": "most_favorited",
20
+ },
21
+ "selectors": {
22
+ "comprehensive": (1, 1, "综合"),
23
+ "latest": (1, 2, "最新"),
24
+ "most_liked": (1, 3, "最多点赞"),
25
+ "most_commented": (1, 4, "最多评论"),
26
+ "most_favorited": (1, 5, "最多收藏"),
27
+ },
28
+ },
29
+ "note_type": {
30
+ "aliases": {
31
+ "all": "all",
32
+ "不限": "all",
33
+ "video": "video",
34
+ "视频": "video",
35
+ "image_text": "image_text",
36
+ "图文": "image_text",
37
+ },
38
+ "selectors": {
39
+ "all": (2, 1, "不限"),
40
+ "video": (2, 2, "视频"),
41
+ "image_text": (2, 3, "图文"),
42
+ },
43
+ },
44
+ "publish_time": {
45
+ "aliases": {
46
+ "all": "all",
47
+ "不限": "all",
48
+ "day": "day",
49
+ "一天内": "day",
50
+ "week": "week",
51
+ "一周内": "week",
52
+ "half_year": "half_year",
53
+ "半年内": "half_year",
54
+ },
55
+ "selectors": {
56
+ "all": (3, 1, "不限"),
57
+ "day": (3, 2, "一天内"),
58
+ "week": (3, 3, "一周内"),
59
+ "half_year": (3, 4, "半年内"),
60
+ },
61
+ },
62
+ "search_scope": {
63
+ "aliases": {
64
+ "all": "all",
65
+ "不限": "all",
66
+ "viewed": "viewed",
67
+ "已看过": "viewed",
68
+ "unviewed": "unviewed",
69
+ "未看过": "unviewed",
70
+ "following": "following",
71
+ "已关注": "following",
72
+ },
73
+ "selectors": {
74
+ "all": (4, 1, "不限"),
75
+ "viewed": (4, 2, "已看过"),
76
+ "unviewed": (4, 3, "未看过"),
77
+ "following": (4, 4, "已关注"),
78
+ },
79
+ },
80
+ "location": {
81
+ "aliases": {
82
+ "all": "all",
83
+ "不限": "all",
84
+ "local": "local",
85
+ "同城": "local",
86
+ "nearby": "nearby",
87
+ "附近": "nearby",
88
+ },
89
+ "selectors": {
90
+ "all": (5, 1, "不限"),
91
+ "local": (5, 2, "同城"),
92
+ "nearby": (5, 3, "附近"),
93
+ },
94
+ },
95
+ }
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class SearchFilterSelection:
100
+ field: str
101
+ value: str
102
+ filters_index: int
103
+ tags_index: int
104
+ label: str
105
+
106
+
107
+ def normalize_filter_value(field: str, value: str | None) -> str | None:
108
+ if value is None:
109
+ return None
110
+ text = str(value).strip()
111
+ if not text:
112
+ return None
113
+ config = _FILTER_CONFIG.get(field)
114
+ if config is None:
115
+ raise ValueError(f"未知筛选字段: {field}")
116
+ aliases = config["aliases"]
117
+ normalized = aliases.get(text) or aliases.get(text.lower())
118
+ if normalized:
119
+ return normalized
120
+ canonical = sorted(config["selectors"].keys())
121
+ raise ValueError(f"{field} 不支持 '{text}',可选值: {', '.join(canonical)}")
122
+
123
+
124
+ def build_search_filter_selections(
125
+ *,
126
+ sort_by: str | None = None,
127
+ note_type: str | None = None,
128
+ publish_time: str | None = None,
129
+ search_scope: str | None = None,
130
+ location: str | None = None,
131
+ ) -> list[SearchFilterSelection]:
132
+ values = {
133
+ "sort_by": sort_by,
134
+ "note_type": note_type,
135
+ "publish_time": publish_time,
136
+ "search_scope": search_scope,
137
+ "location": location,
138
+ }
139
+ selections: list[SearchFilterSelection] = []
140
+ for field in FILTER_FIELD_ORDER:
141
+ value = values[field]
142
+ if not value:
143
+ continue
144
+ selectors = _FILTER_CONFIG[field]["selectors"]
145
+ filters_index, tags_index, label = selectors[value]
146
+ selections.append(
147
+ SearchFilterSelection(
148
+ field=field,
149
+ value=value,
150
+ filters_index=filters_index,
151
+ tags_index=tags_index,
152
+ label=label,
153
+ )
154
+ )
155
+ return selections
@@ -0,0 +1 @@
1
+ """Infrastructure layer."""
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class ExitCode(IntEnum):
7
+ SUCCESS = 0
8
+ INVALID_ARGS = 2
9
+ AUTH_REQUIRED = 3
10
+ RISK_CONTROL = 4
11
+ INTERNAL_ERROR = 5
12
+ TIMEOUT = 6
13
+ DEPENDENCY_MISSING = 7
14
+
15
+
16
+ ERROR_CODE_TO_EXIT_CODE: dict[str, ExitCode] = {
17
+ "INVALID_ARGS": ExitCode.INVALID_ARGS,
18
+ "AUTH_REQUIRED": ExitCode.AUTH_REQUIRED,
19
+ "ACCOUNT_UNAVAILABLE": ExitCode.INVALID_ARGS,
20
+ "RISK_CONTROL_TRIGGERED": ExitCode.RISK_CONTROL,
21
+ "RATE_LIMITED": ExitCode.RISK_CONTROL,
22
+ "UPSTREAM_CHANGED": ExitCode.INTERNAL_ERROR,
23
+ "TIMEOUT": ExitCode.TIMEOUT,
24
+ "DEPENDENCY_MISSING": ExitCode.DEPENDENCY_MISSING,
25
+ "INTERNAL_ERROR": ExitCode.INTERNAL_ERROR,
26
+ }
27
+
28
+
29
+ def map_error_code_to_exit_code(code: str) -> ExitCode:
30
+ return ERROR_CODE_TO_EXIT_CODE.get(code, ExitCode.INTERNAL_ERROR)
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from loguru import logger
6
+
7
+
8
+ def setup_cli_logger(quiet: bool = False) -> None:
9
+ logger.remove()
10
+ level = "ERROR" if quiet else "INFO"
11
+ logger.add(sys.stderr, level=level, colorize=True, backtrace=False, diagnose=False)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ APP_HOME = Path(os.getenv("REDNOTE_OPERATOR_HOME", str(Path.cwd() / ".rednote_cli"))).expanduser().resolve()
7
+ SCHEMA_DIR = APP_HOME / "schemas"
8
+ LOG_DIR = APP_HOME / "logs"
9
+ OUTPUT_DIR = APP_HOME / "outputs"
10
+ DB_FILE = APP_HOME / "rednote_cli.db"
11
+
12
+
13
+ def ensure_runtime_dirs() -> None:
14
+ APP_HOME.mkdir(parents=True, exist_ok=True)
15
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
16
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
17
+
18
+
19
+ def configure_legacy_data_paths() -> None:
20
+ """Force legacy modules to use CLI runtime paths.
21
+
22
+ Existing modules import DB path at module level, so both origins must be patched.
23
+ """
24
+ import rednote_cli._runtime.common.config as common_config
25
+ import rednote_cli._runtime.core.database.manager as db_manager
26
+
27
+ common_config.APP_DATA_DIR = APP_HOME
28
+ common_config.LOG_DIR = LOG_DIR
29
+ common_config.LOG_FILE = LOG_DIR / "app.log"
30
+ common_config.DB_FILE = DB_FILE
31
+ db_manager.DB_FILE = DB_FILE
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ REDNOTE_PLATFORM = "Rednote"
4
+ TIKTOK_PLATFORM = "TikTok"