youread 0.2.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.
Files changed (41) hide show
  1. youread-0.2.0/LICENSE +21 -0
  2. youread-0.2.0/MANIFEST.in +12 -0
  3. youread-0.2.0/PKG-INFO +130 -0
  4. youread-0.2.0/README.md +103 -0
  5. youread-0.2.0/pyproject.toml +47 -0
  6. youread-0.2.0/setup.cfg +4 -0
  7. youread-0.2.0/src/youread/__init__.py +1 -0
  8. youread-0.2.0/src/youread/article_generator.py +201 -0
  9. youread-0.2.0/src/youread/config/settings.yaml +9 -0
  10. youread-0.2.0/src/youread/exceptions.py +45 -0
  11. youread-0.2.0/src/youread/main.py +496 -0
  12. youread-0.2.0/src/youread/metadata.py +83 -0
  13. youread-0.2.0/src/youread/models/__init__.py +6 -0
  14. youread-0.2.0/src/youread/models/transcript.py +22 -0
  15. youread-0.2.0/src/youread/models/video.py +21 -0
  16. youread-0.2.0/src/youread/prompts/TECHNIQUES_APPLIED.md +271 -0
  17. youread-0.2.0/src/youread/prompts/__init__.py +1 -0
  18. youread-0.2.0/src/youread/prompts/examples_v2.md +224 -0
  19. youread-0.2.0/src/youread/prompts/modes/__init__.py +1 -0
  20. youread-0.2.0/src/youread/prompts/modes/detailed.md +39 -0
  21. youread-0.2.0/src/youread/prompts/modes/standard.md +30 -0
  22. youread-0.2.0/src/youread/prompts/modes/summary.md +30 -0
  23. youread-0.2.0/src/youread/prompts/system.md +12 -0
  24. youread-0.2.0/src/youread/prompts/system_prompt_v2.md +259 -0
  25. youread-0.2.0/src/youread/providers/__init__.py +107 -0
  26. youread-0.2.0/src/youread/providers/gemini_provider.py +39 -0
  27. youread-0.2.0/src/youread/providers/openai_provider.py +86 -0
  28. youread-0.2.0/src/youread/sponsorblock.py +107 -0
  29. youread-0.2.0/src/youread/transcript.py +104 -0
  30. youread-0.2.0/src/youread/utils/__init__.py +27 -0
  31. youread-0.2.0/src/youread/utils/config.py +280 -0
  32. youread-0.2.0/src/youread/utils/url_parser.py +51 -0
  33. youread-0.2.0/src/youread.egg-info/PKG-INFO +130 -0
  34. youread-0.2.0/src/youread.egg-info/SOURCES.txt +39 -0
  35. youread-0.2.0/src/youread.egg-info/dependency_links.txt +1 -0
  36. youread-0.2.0/src/youread.egg-info/entry_points.txt +2 -0
  37. youread-0.2.0/src/youread.egg-info/requires.txt +7 -0
  38. youread-0.2.0/src/youread.egg-info/top_level.txt +1 -0
  39. youread-0.2.0/tests/test_config_utils.py +238 -0
  40. youread-0.2.0/tests/test_main_cli_config.py +148 -0
  41. youread-0.2.0/tests/test_secret_hygiene.py +147 -0
youread-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Guransh Singh (singhDevs)
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,12 @@
1
+ exclude .env
2
+ exclude .env.*
3
+ global-exclude *.env
4
+ global-exclude *.env.*
5
+ prune api-test
6
+ prune build
7
+ prune dist
8
+ prune doc
9
+ prune output
10
+ prune venv
11
+ prune .claude
12
+ prune .github
youread-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: youread
3
+ Version: 0.2.0
4
+ Summary: Convert YouTube videos into comprehensive, reading-optimized markdown articles
5
+ Author: Guransh Singh (singhDevs)
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/singhDevs/YouRead
8
+ Project-URL: Issues, https://github.com/singhDevs/YouRead/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Topic :: Utilities
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: youtube-transcript-api>=0.6.0
20
+ Requires-Dist: google-genai>=1.0.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Requires-Dist: pyyaml>=6.0
23
+ Requires-Dist: typer>=0.9.0
24
+ Requires-Dist: rich>=13.0.0
25
+ Requires-Dist: openai==2.16.0
26
+ Dynamic: license-file
27
+
28
+ # YouRead
29
+
30
+ Turn a YouTube video into a readable Markdown article.
31
+
32
+ Built for people who prefer reading over watching, who got 100s of youtube tabs opened but none watched — technical talks, podcasts, discussions. It goes beyond summaries; the goal is an article detailed enough that you usually don't need to watch the video.
33
+
34
+
35
+ ## Quick Start
36
+
37
+ ```bash
38
+ pip install .
39
+ youread config set --provider openai --set-api-key
40
+ youread run "https://youtube.com/watch?v=VIDEO_ID"
41
+ ```
42
+
43
+ ## Install
44
+
45
+ Requires Python 3.10+.
46
+
47
+ ```bash
48
+ pip install .
49
+ # or for development:
50
+ pip install -e .
51
+ ```
52
+
53
+ ## Configure
54
+
55
+ YouRead supports OpenAI and Gemini. Defaults:
56
+
57
+ ```yaml
58
+ llm:
59
+ provider: openai
60
+ model: gpt-5
61
+ max_tokens: 8000
62
+ temperature: 0.3
63
+ output:
64
+ directory: ./output
65
+ processing:
66
+ remove_sponsors: true
67
+ ```
68
+
69
+ ```bash
70
+ # set provider + model
71
+ youread config set --provider openai --model gpt-5
72
+ # set API key (secure prompt, no echo)
73
+ youread config set --set-api-key
74
+ # view current config
75
+ youread config show
76
+ # check if active provider has a key
77
+ youread config check
78
+ ```
79
+
80
+ Provider/model pairs are validated at save time — `--provider gemini --model gpt-5` is rejected immediately.
81
+
82
+ ## Usage
83
+
84
+ ```bash
85
+ youread run "https://youtube.com/watch?v=VIDEO_ID"
86
+
87
+ # with options
88
+ youread run "URL" --mode detailed
89
+ youread run "URL" --prompt "Focus on code examples"
90
+ youread run "URL" --model gemini-2.5-flash
91
+ youread run "URL" --no-sponsorblock
92
+ ```
93
+
94
+ | Flag | What it does |
95
+ |------|-------------|
96
+ | `--mode summary\|standard\|detailed` | Article depth (default: standard) |
97
+ | `--prompt "..."` | Custom instructions appended to the system prompt |
98
+ | `--model <name>` | Override configured model for one run |
99
+ | `--no-sponsorblock` | Skip sponsor segment removal |
100
+
101
+ Output goes to the configured directory (`./output` by default). After generation, YouRead asks whether to open the file.
102
+
103
+ ## Supported Providers
104
+
105
+ | Provider | Example Model | Env variable |
106
+ |----------|---------------|-------------|
107
+ | OpenAI | `gpt-5` | `OPENAI_API_KEY` |
108
+ | Gemini | `gemini-2.5-flash` | `GEMINI_API_KEY` |
109
+
110
+ ## Limitations
111
+
112
+ YouRead is transcript-only. It doesn't see video frames, slides, or code on screen.
113
+
114
+ | Situation | Outcome |
115
+ |-----------|---------|
116
+ | No transcript | Can't process |
117
+ | Auto-generated captions | Quality suffers |
118
+ | Visual-heavy content | Details missed |
119
+ | Code not spoken | Won't appear |
120
+ | Non-English video | English captions only |
121
+
122
+ For best results, use videos where the speaker explains things verbally.
123
+
124
+ ## Troubleshooting
125
+
126
+ **`No API key found`** — `youread config check` then `youread config set --set-api-key`.
127
+
128
+ **`Provider mismatch`** — You paired `--provider gemini` with `--model gpt-5`. Use matching pairs: `openai` + `gpt-5`, `gemini` + `gemini-2.5-flash`.
129
+
130
+ **`No transcript available`** — Video has no accessible captions.
@@ -0,0 +1,103 @@
1
+ # YouRead
2
+
3
+ Turn a YouTube video into a readable Markdown article.
4
+
5
+ Built for people who prefer reading over watching, who got 100s of youtube tabs opened but none watched — technical talks, podcasts, discussions. It goes beyond summaries; the goal is an article detailed enough that you usually don't need to watch the video.
6
+
7
+
8
+ ## Quick Start
9
+
10
+ ```bash
11
+ pip install .
12
+ youread config set --provider openai --set-api-key
13
+ youread run "https://youtube.com/watch?v=VIDEO_ID"
14
+ ```
15
+
16
+ ## Install
17
+
18
+ Requires Python 3.10+.
19
+
20
+ ```bash
21
+ pip install .
22
+ # or for development:
23
+ pip install -e .
24
+ ```
25
+
26
+ ## Configure
27
+
28
+ YouRead supports OpenAI and Gemini. Defaults:
29
+
30
+ ```yaml
31
+ llm:
32
+ provider: openai
33
+ model: gpt-5
34
+ max_tokens: 8000
35
+ temperature: 0.3
36
+ output:
37
+ directory: ./output
38
+ processing:
39
+ remove_sponsors: true
40
+ ```
41
+
42
+ ```bash
43
+ # set provider + model
44
+ youread config set --provider openai --model gpt-5
45
+ # set API key (secure prompt, no echo)
46
+ youread config set --set-api-key
47
+ # view current config
48
+ youread config show
49
+ # check if active provider has a key
50
+ youread config check
51
+ ```
52
+
53
+ Provider/model pairs are validated at save time — `--provider gemini --model gpt-5` is rejected immediately.
54
+
55
+ ## Usage
56
+
57
+ ```bash
58
+ youread run "https://youtube.com/watch?v=VIDEO_ID"
59
+
60
+ # with options
61
+ youread run "URL" --mode detailed
62
+ youread run "URL" --prompt "Focus on code examples"
63
+ youread run "URL" --model gemini-2.5-flash
64
+ youread run "URL" --no-sponsorblock
65
+ ```
66
+
67
+ | Flag | What it does |
68
+ |------|-------------|
69
+ | `--mode summary\|standard\|detailed` | Article depth (default: standard) |
70
+ | `--prompt "..."` | Custom instructions appended to the system prompt |
71
+ | `--model <name>` | Override configured model for one run |
72
+ | `--no-sponsorblock` | Skip sponsor segment removal |
73
+
74
+ Output goes to the configured directory (`./output` by default). After generation, YouRead asks whether to open the file.
75
+
76
+ ## Supported Providers
77
+
78
+ | Provider | Example Model | Env variable |
79
+ |----------|---------------|-------------|
80
+ | OpenAI | `gpt-5` | `OPENAI_API_KEY` |
81
+ | Gemini | `gemini-2.5-flash` | `GEMINI_API_KEY` |
82
+
83
+ ## Limitations
84
+
85
+ YouRead is transcript-only. It doesn't see video frames, slides, or code on screen.
86
+
87
+ | Situation | Outcome |
88
+ |-----------|---------|
89
+ | No transcript | Can't process |
90
+ | Auto-generated captions | Quality suffers |
91
+ | Visual-heavy content | Details missed |
92
+ | Code not spoken | Won't appear |
93
+ | Non-English video | English captions only |
94
+
95
+ For best results, use videos where the speaker explains things verbally.
96
+
97
+ ## Troubleshooting
98
+
99
+ **`No API key found`** — `youread config check` then `youread config set --set-api-key`.
100
+
101
+ **`Provider mismatch`** — You paired `--provider gemini` with `--model gpt-5`. Use matching pairs: `openai` + `gpt-5`, `gemini` + `gemini-2.5-flash`.
102
+
103
+ **`No transcript available`** — Video has no accessible captions.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "youread"
7
+ version = "0.2.0"
8
+ description = "Convert YouTube videos into comprehensive, reading-optimized markdown articles"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [
14
+ {name = "Guransh Singh (singhDevs)"},
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Utilities",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ ]
25
+
26
+ dependencies = [
27
+ "youtube-transcript-api>=0.6.0",
28
+ "google-genai>=1.0.0",
29
+ "python-dotenv>=1.0.0",
30
+ "pyyaml>=6.0",
31
+ "typer>=0.9.0",
32
+ "rich>=13.0.0",
33
+ "openai==2.16.0",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/singhDevs/YouRead"
38
+ Issues = "https://github.com/singhDevs/YouRead/issues"
39
+
40
+ [project.scripts]
41
+ youread = "youread.main:app"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ youread = ["config/settings.yaml", "prompts/*.md", "prompts/modes/*.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,201 @@
1
+ """Article generation using LLM providers."""
2
+
3
+ import math
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Literal
7
+ from importlib.resources import files
8
+
9
+ from .models import VideoInfo
10
+ from .providers import call_llm
11
+
12
+
13
+ # Valid mode choices
14
+ ArticleMode = Literal["summary", "standard", "detailed"]
15
+
16
+ # Reading speed for technical content (words per minute)
17
+ # 150 WPM is realistic for technical/educational material requiring comprehension
18
+ TECHNICAL_WPM = 150
19
+
20
+
21
+ def calculate_reading_time(text: str, wpm: int = TECHNICAL_WPM) -> str:
22
+ """Calculate reading time from text content.
23
+
24
+ Args:
25
+ text: The article text to measure.
26
+ wpm: Words per minute reading speed.
27
+
28
+ Returns:
29
+ Formatted reading time string (e.g., "~5 min read").
30
+ """
31
+ # Count words (split on whitespace)
32
+ word_count = len(text.split())
33
+
34
+ # Calculate minutes, round up to nearest minute
35
+ minutes = math.ceil(word_count / wpm)
36
+
37
+ return f"~{minutes} min read"
38
+
39
+
40
+ def insert_reading_time(article: str) -> str:
41
+ """Replace the reading time placeholder with calculated reading time.
42
+
43
+ Args:
44
+ article: The generated article with READING_TIME_PLACEHOLDER.
45
+
46
+ Returns:
47
+ Article with accurate reading time inserted.
48
+ """
49
+ reading_time = calculate_reading_time(article)
50
+ return article.replace("READING_TIME_PLACEHOLDER", reading_time)
51
+
52
+
53
+ def load_prompt(prompt_path: Optional[Path] = None) -> str:
54
+ """Load a prompt from file.
55
+
56
+ Args:
57
+ prompt_path: Path to the prompt file. Defaults to prompts/system_prompt_v2.md.
58
+
59
+ Returns:
60
+ The prompt content as a string.
61
+ """
62
+ if prompt_path is None:
63
+ prompt_path = files("youread.prompts") / "system_prompt_v2.md"
64
+
65
+ if hasattr(prompt_path, 'read_text'):
66
+ return prompt_path.read_text(encoding="utf-8")
67
+
68
+ path = Path(prompt_path)
69
+ if not path.exists():
70
+ raise FileNotFoundError(f"Prompt file not found: {prompt_path}")
71
+
72
+ return path.read_text(encoding="utf-8")
73
+
74
+
75
+ def load_mode_prompt(mode: ArticleMode) -> str:
76
+ """Load the mode-specific prompt fragment.
77
+
78
+ Args:
79
+ mode: The article generation mode (summary, standard, detailed).
80
+
81
+ Returns:
82
+ The mode prompt content as a string.
83
+ """
84
+ mode_path = files("youread.prompts.modes") / f"{mode}.md"
85
+
86
+ if hasattr(mode_path, 'read_text'):
87
+ return mode_path.read_text(encoding="utf-8")
88
+
89
+ path = Path(mode_path)
90
+ if not path.exists():
91
+ raise FileNotFoundError(f"Mode prompt file not found: {mode_path}")
92
+
93
+ return path.read_text(encoding="utf-8")
94
+
95
+
96
+ def compose_system_prompt(
97
+ mode: ArticleMode = "standard",
98
+ custom_prompt: Optional[str] = None,
99
+ video_id: Optional[str] = None,
100
+ ) -> str:
101
+ """Compose the full system prompt from base + mode + custom.
102
+
103
+ Args:
104
+ mode: The article generation mode.
105
+ custom_prompt: Optional user-provided custom instructions.
106
+ video_id: Optional video ID for constructing the source URL.
107
+
108
+ Returns:
109
+ The composed system prompt.
110
+ """
111
+ # Load base prompt
112
+ base_prompt = load_prompt()
113
+
114
+ # Load mode-specific instructions
115
+ mode_instructions = load_mode_prompt(mode)
116
+
117
+ # Replace the mode placeholder
118
+ prompt = base_prompt.replace("{{MODE_INSTRUCTIONS}}", mode_instructions)
119
+
120
+ # Replace video URL placeholder if video_id provided
121
+ if video_id:
122
+ video_url = f"https://youtube.com/watch?v={video_id}"
123
+ prompt = prompt.replace("VIDEO_URL_PLACEHOLDER", video_url)
124
+
125
+ # Append custom prompt if provided
126
+ if custom_prompt:
127
+ custom_section = f"""
128
+ <custom_instructions>
129
+ The user has provided the following additional instructions. Follow them while maintaining the core quality standards:
130
+
131
+ {custom_prompt}
132
+ </custom_instructions>
133
+ """
134
+ prompt += custom_section
135
+
136
+ return prompt
137
+
138
+
139
+ def generate_article(
140
+ transcript_text: str,
141
+ video_info: VideoInfo,
142
+ provider: str,
143
+ api_key: str,
144
+ video_id: Optional[str] = None,
145
+ model: str = "gpt-5",
146
+ max_tokens: int = 8000,
147
+ temperature: float = 0.3,
148
+ mode: ArticleMode = "standard",
149
+ custom_prompt: Optional[str] = None,
150
+ ) -> str:
151
+ """Generate a readable article from a video transcript.
152
+
153
+ Args:
154
+ transcript_text: The full transcript text.
155
+ video_info: Video metadata (title, channel).
156
+ provider: LLM provider name (e.g., "openai", "gemini").
157
+ api_key: API key for the provider.
158
+ video_id: Video ID for source link.
159
+ model: Model identifier to use.
160
+ max_tokens: Maximum tokens in the response.
161
+ temperature: Sampling temperature (0.0-1.0).
162
+ mode: Article generation mode (summary, standard, detailed).
163
+ custom_prompt: Optional custom instructions from user.
164
+
165
+ Returns:
166
+ The generated article as markdown text.
167
+
168
+ Raises:
169
+ Exception: If the API call fails.
170
+ """
171
+ # Compose the system prompt
172
+ system_prompt = compose_system_prompt(
173
+ mode=mode,
174
+ custom_prompt=custom_prompt,
175
+ video_id=video_id,
176
+ )
177
+
178
+ # Build the user prompt with context
179
+ user_prompt = f"""## Video Information
180
+ **Title:** {video_info.title}
181
+ **Channel:** {video_info.channel}
182
+
183
+ ## Transcript
184
+ {transcript_text}
185
+ """
186
+
187
+ # Generate the article via provider dispatch
188
+ raw_response = call_llm(
189
+ provider=provider,
190
+ api_key=api_key,
191
+ model=model,
192
+ system_prompt=system_prompt,
193
+ user_prompt=user_prompt,
194
+ max_tokens=max_tokens,
195
+ temperature=temperature,
196
+ )
197
+
198
+ # Calculate and insert accurate reading time
199
+ article = insert_reading_time(raw_response)
200
+
201
+ return article
@@ -0,0 +1,9 @@
1
+ llm:
2
+ provider: openai
3
+ model: gpt-5
4
+ max_tokens: 8000
5
+ temperature: 0.3
6
+ output:
7
+ directory: ./output
8
+ processing:
9
+ remove_sponsors: true
@@ -0,0 +1,45 @@
1
+ """Custom exceptions for YouRead."""
2
+
3
+
4
+ class YouReadError(Exception):
5
+ """Base exception for all YouRead errors."""
6
+ pass
7
+
8
+
9
+ class InvalidURLError(YouReadError):
10
+ def __init__(self, url: str) -> None:
11
+ self.url = url
12
+ super().__init__(f"Invalid YouTube URL: {url}")
13
+
14
+
15
+ class TranscriptNotFoundError(YouReadError):
16
+ def __init__(self, video_id: str) -> None:
17
+ self.video_id = video_id
18
+ super().__init__(f"No transcript available for video: {video_id}")
19
+
20
+
21
+ class VideoNotFoundError(YouReadError):
22
+ def __init__(self, video_id: str) -> None:
23
+ self.video_id = video_id
24
+ super().__init__(f"Video not found or unavailable: {video_id}")
25
+
26
+
27
+ class UnsupportedProviderError(YouReadError):
28
+ def __init__(self, provider: str, supported: list[str]) -> None:
29
+ self.provider = provider
30
+ self.supported = supported
31
+ super().__init__(
32
+ f"Unsupported provider '{provider}'. "
33
+ f"Supported: {', '.join(supported)}"
34
+ )
35
+
36
+
37
+ class APIKeyMissingError(YouReadError):
38
+ def __init__(self, provider: str) -> None:
39
+ self.provider = provider
40
+ env_var = f"{provider.upper()}_API_KEY"
41
+ super().__init__(
42
+ f"API key for '{provider}' not found. "
43
+ f"Save {env_var} to YouRead's managed .env file, or use: "
44
+ f"youread config set --provider {provider} --set-api-key"
45
+ )