note-connector 0.2.5 → 0.2.7

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 (46) hide show
  1. package/dist/paths.js +4 -0
  2. package/dist/setup-dependencies.js +56 -13
  3. package/package.json +3 -2
  4. package/py/pyproject.toml +86 -0
  5. package/py/src/note_mcp/__init__.py +7 -0
  6. package/py/src/note_mcp/__main__.py +65 -0
  7. package/py/src/note_mcp/api/__init__.py +31 -0
  8. package/py/src/note_mcp/api/articles.py +1395 -0
  9. package/py/src/note_mcp/api/client.py +318 -0
  10. package/py/src/note_mcp/api/embeds.py +482 -0
  11. package/py/src/note_mcp/api/images.py +660 -0
  12. package/py/src/note_mcp/api/preview.py +142 -0
  13. package/py/src/note_mcp/api/public_notes.py +150 -0
  14. package/py/src/note_mcp/auth/__init__.py +9 -0
  15. package/py/src/note_mcp/auth/browser.py +574 -0
  16. package/py/src/note_mcp/auth/file_session.py +145 -0
  17. package/py/src/note_mcp/auth/session.py +240 -0
  18. package/py/src/note_mcp/browser/__init__.py +10 -0
  19. package/py/src/note_mcp/browser/config.py +21 -0
  20. package/py/src/note_mcp/browser/manager.py +182 -0
  21. package/py/src/note_mcp/browser/preview.py +68 -0
  22. package/py/src/note_mcp/browser/url_helpers.py +18 -0
  23. package/py/src/note_mcp/chatgpt/__init__.py +1 -0
  24. package/py/src/note_mcp/chatgpt/__main__.py +63 -0
  25. package/py/src/note_mcp/chatgpt/access_log.py +25 -0
  26. package/py/src/note_mcp/chatgpt/auth.py +52 -0
  27. package/py/src/note_mcp/chatgpt/images.py +92 -0
  28. package/py/src/note_mcp/chatgpt/login_once.py +26 -0
  29. package/py/src/note_mcp/chatgpt/middleware.py +31 -0
  30. package/py/src/note_mcp/chatgpt/tools.py +255 -0
  31. package/py/src/note_mcp/chatgpt/widgets.py +121 -0
  32. package/py/src/note_mcp/decorators.py +113 -0
  33. package/py/src/note_mcp/investigator/__init__.py +33 -0
  34. package/py/src/note_mcp/investigator/__main__.py +11 -0
  35. package/py/src/note_mcp/investigator/cli.py +313 -0
  36. package/py/src/note_mcp/investigator/core.py +653 -0
  37. package/py/src/note_mcp/investigator/mcp_tools.py +225 -0
  38. package/py/src/note_mcp/models.py +562 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +944 -0
  41. package/py/src/note_mcp/utils/__init__.py +7 -0
  42. package/py/src/note_mcp/utils/file_parser.py +314 -0
  43. package/py/src/note_mcp/utils/html_to_markdown.py +477 -0
  44. package/py/src/note_mcp/utils/logging.py +119 -0
  45. package/py/src/note_mcp/utils/markdown.py +12 -0
  46. package/py/src/note_mcp/utils/markdown_to_html.py +826 -0
@@ -0,0 +1,482 @@
1
+ """Embed URL detection and HTML generation for note.com.
2
+
3
+ This module provides functions for detecting embed URLs (YouTube, Twitter, note.com,
4
+ GitHub Gist, GitHub Repository, noteマネー, Zenn.dev, Google Slides, SpeakerDeck, Qiita,
5
+ connpass) and generating the required HTML structure for note.com embeds.
6
+
7
+ This is the single source of truth for embed URL patterns (DRY principle).
8
+
9
+ Issue #116: Server-registered embed keys are required for proper iframe rendering.
10
+ Random keys generated locally will not work - note.com frontend only renders embeds
11
+ with keys registered via the embed_by_external_api endpoint.
12
+
13
+ Issue #195: GitHub Gist embed support added. Gist URLs use the same
14
+ /v2/embed_by_external_api endpoint as YouTube and Twitter.
15
+
16
+ Issue #222: Zenn.dev article embed support added. Zenn URLs use
17
+ 'external-article' service type via the same /v2/embed_by_external_api endpoint.
18
+
19
+ Issue #226: GitHub Repository embed support added. Repository URLs use
20
+ 'githubRepository' service type via the same /v2/embed_by_external_api endpoint.
21
+
22
+ Issue #223: SpeakerDeck presentation embed support added. SpeakerDeck URLs use
23
+ 'speakerdeck' service type via the same /v2/embed_by_external_api endpoint.
24
+
25
+ Issue #244: Qiita article embed support added. Qiita URLs use 'external-article'
26
+ service type (same as Zenn.dev) via the same /v2/embed_by_external_api endpoint.
27
+
28
+ Issue #254: connpass event embed support added. connpass URLs use 'external-article'
29
+ service type (same as Zenn.dev and Qiita) via the same /v2/embed_by_external_api endpoint.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import html
35
+ import logging
36
+ import re
37
+ import uuid
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ from note_mcp.api.client import NoteAPIClient
41
+ from note_mcp.models import ErrorCode, NoteAPIError
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ if TYPE_CHECKING:
46
+ from note_mcp.models import Session
47
+
48
+ # Embed URL patterns (single source of truth - DRY principle)
49
+ # YouTube: youtube.com/watch?v=xxx or youtu.be/xxx
50
+ YOUTUBE_PATTERN = re.compile(r"^https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)[\w-]+$")
51
+
52
+ # Twitter/X: twitter.com/user/status/xxx or x.com/user/status/xxx
53
+ TWITTER_PATTERN = re.compile(r"^https?://(?:www\.)?(?:twitter\.com|x\.com)/\w+/status/\d+$")
54
+
55
+ # note.com: note.com/user/n/xxx
56
+ NOTE_PATTERN = re.compile(r"^https?://note\.com/\w+/n/\w+$")
57
+
58
+ # GitHub Gist: gist.github.com/user/gist_id (with optional trailing slash and file fragment)
59
+ GIST_PATTERN = re.compile(r"^https?://gist\.github\.com/[\w-]+/[\w]+/?(?:#[\w-]+)?$")
60
+
61
+ # noteマネー (stock chart): money.note.com/companies|us-companies|indices|investments/xxx
62
+ # Supports Japanese stocks, US stocks, indices, and investment trusts
63
+ MONEY_PATTERN = re.compile(r"^https?://money\.note\.com/(companies|us-companies|indices|investments)/[\w-]+/?$")
64
+
65
+ # Zenn.dev: zenn.dev/username/articles/article-slug
66
+ # Example: https://zenn.dev/zenn/articles/markdown-guide (Issue #222)
67
+ ZENN_PATTERN = re.compile(r"^https?://zenn\.dev/[\w-]+/articles/[\w-]+$")
68
+
69
+ # Qiita: qiita.com/username/items/item_id
70
+ # Example: https://qiita.com/driller/items/31c1ff4d0bf5813f624f (Issue #244)
71
+ QIITA_PATTERN = re.compile(r"^https?://qiita\.com/[\w-]+/items/[\w]+$")
72
+
73
+ # connpass: {group}.connpass.com/event/{event_id}/
74
+ # Example: https://fin-py.connpass.com/event/381982/ (Issue #254)
75
+ # Note: connpass uses subdomain format for group names
76
+ # Note: www subdomain is excluded (connpass canonical URLs use group subdomain)
77
+ CONNPASS_PATTERN = re.compile(r"^https?://(?!www\.)([\w-]+)\.connpass\.com/event/\d+/?$")
78
+
79
+ # GitHub Repository: github.com/owner/repo (with optional trailing slash)
80
+ # Example: https://github.com/anthropics/claude-code (Issue #226)
81
+ # Note: This pattern must NOT match gist.github.com (handled by GIST_PATTERN)
82
+ # Note: This pattern must NOT match subpaths like /issues, /pull, /blob
83
+ GITHUB_REPO_PATTERN = re.compile(r"^https?://(?:www\.)?github\.com/[\w-]+/[\w.-]+/?$")
84
+
85
+ # Google Slides: docs.google.com/presentation/d/{id}/...
86
+ # Example: https://docs.google.com/presentation/d/1W543BSd-hHANrJOzCPyNf-r3x0s5s7ljc9xA7a7x960/edit (Issue #224)
87
+ # Supports: /edit, /pub, /view, /embed, or no suffix
88
+ # Supports: query parameters and fragment identifiers (#slide=id.xxx)
89
+ GOOGLE_SLIDES_PATTERN = re.compile(
90
+ r"^https?://docs\.google\.com/presentation/d/[\w-]+(?:/[^?#]*)?(?:\?[^#]*)?(?:#.*)?$"
91
+ )
92
+
93
+ # SpeakerDeck: speakerdeck.com/user/slide-name
94
+ # Example: https://speakerdeck.com/tomohisa/introducing-decider-pattern-with-event-sourcing (Issue #223)
95
+ SPEAKERDECK_PATTERN = re.compile(r"^https?://speakerdeck\.com/[\w-]+/[\w-]+$")
96
+
97
+ # Generic URL pattern: catch-all for any HTTP(S) URL
98
+ # Used as fallback for URL card / link card embeds (Open Graph preview)
99
+ # Matches any web URL not already handled by specific patterns above.
100
+ GENERIC_URL_PATTERN = re.compile(r"^https?://[^\s<>\"{}|\\^`\[\]]+$")
101
+
102
+ # Data-driven pattern to service mapping (Issue #235: DRY principle)
103
+ # Note: GIST_PATTERN and GITHUB_REPO_PATTERN are mutually exclusive by design
104
+ # (GIST_PATTERN matches gist.github.com, GITHUB_REPO_PATTERN matches github.com only).
105
+ # Note: GENERIC_URL_PATTERN must be LAST so specific services match first.
106
+ EMBED_PATTERNS: list[tuple[re.Pattern[str], str]] = [
107
+ (YOUTUBE_PATTERN, "youtube"),
108
+ (TWITTER_PATTERN, "twitter"),
109
+ (NOTE_PATTERN, "note"),
110
+ (GIST_PATTERN, "gist"), # gist.github.com (distinct from github.com)
111
+ (GITHUB_REPO_PATTERN, "githubRepository"),
112
+ (GOOGLE_SLIDES_PATTERN, "googlepresentation"),
113
+ (SPEAKERDECK_PATTERN, "speakerdeck"),
114
+ (MONEY_PATTERN, "oembed"),
115
+ (ZENN_PATTERN, "external-article"),
116
+ (QIITA_PATTERN, "external-article"), # Qiita also uses external-article (Issue #244)
117
+ (CONNPASS_PATTERN, "external-article"), # connpass.com events (Issue #254)
118
+ (GENERIC_URL_PATTERN, "external-article"), # URL cards (Open Graph preview) — MUST be last
119
+ ]
120
+
121
+
122
+ def get_embed_service(url: str) -> str | None:
123
+ """Get embed service type from URL.
124
+
125
+ Uses data-driven pattern matching from EMBED_PATTERNS (Issue #235: DRY principle).
126
+
127
+ Args:
128
+ url: The URL to check.
129
+
130
+ Returns:
131
+ Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
132
+ 'googlepresentation', 'speakerdeck', 'oembed', 'external-article') or None if unsupported.
133
+ """
134
+ for pattern, service in EMBED_PATTERNS:
135
+ if pattern.match(url):
136
+ return service
137
+ return None
138
+
139
+
140
+ def is_embed_url(url: str) -> bool:
141
+ """Check if URL is a supported embed URL.
142
+
143
+ Args:
144
+ url: The URL to check.
145
+
146
+ Returns:
147
+ True if the URL is a supported embed URL, False otherwise.
148
+ """
149
+ return get_embed_service(url) is not None
150
+
151
+
152
+ def _build_embed_figure_html(
153
+ url: str,
154
+ embed_key: str,
155
+ service: str,
156
+ ) -> str:
157
+ """Build the HTML figure element for an embed.
158
+
159
+ Internal helper to generate the figure HTML structure.
160
+ This is the single source of truth for embed figure HTML format (DRY).
161
+
162
+ Args:
163
+ url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
164
+ noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
165
+ embed_key: Embed key (random for placeholder, server-registered for final).
166
+ service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
167
+ 'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
168
+
169
+ Returns:
170
+ HTML figure element string.
171
+ """
172
+ element_id = str(uuid.uuid4())
173
+ escaped_url = html.escape(url, quote=True)
174
+
175
+ return (
176
+ f'<figure name="{element_id}" id="{element_id}" '
177
+ f'data-src="{escaped_url}" '
178
+ f'embedded-service="{service}" '
179
+ f'embedded-content-key="{embed_key}" '
180
+ f'contenteditable="false"></figure>'
181
+ )
182
+
183
+
184
+ def generate_embed_html(
185
+ url: str,
186
+ service: str | None = None,
187
+ embed_key: str | None = None,
188
+ ) -> str:
189
+ """Generate embed HTML for note.com.
190
+
191
+ Creates a figure element with the required attributes for note.com
192
+ to render the embed (iframe is rendered client-side by note.com frontend).
193
+
194
+ Args:
195
+ url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
196
+ noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
197
+ service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
198
+ 'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
199
+ If None, auto-detected from URL.
200
+ embed_key: Server-registered embed key. If None, generates a random
201
+ placeholder key (for markdown-to-html conversion, replaced later via API).
202
+
203
+ Returns:
204
+ HTML figure element string.
205
+
206
+ Raises:
207
+ ValueError: If URL is not a supported embed URL.
208
+ """
209
+ if service is None:
210
+ service = get_embed_service(url)
211
+
212
+ if service is None:
213
+ raise ValueError(f"Unsupported embed URL: {url}")
214
+
215
+ if embed_key is None:
216
+ embed_key = f"emb{uuid.uuid4().hex[:13]}"
217
+
218
+ return _build_embed_figure_html(url, embed_key, service)
219
+
220
+
221
+ def _extract_and_validate_embed_response(
222
+ response: dict[str, Any],
223
+ path: list[str],
224
+ url: str,
225
+ article_key: str,
226
+ service: str,
227
+ ) -> tuple[str, str]:
228
+ """Extract and validate embed key and HTML from API response.
229
+
230
+ Internal helper to avoid duplicated response validation logic (DRY principle).
231
+
232
+ Args:
233
+ response: API response dictionary.
234
+ path: List of keys to navigate to the embed data
235
+ (e.g., ["data", "embedded_content"] for note.com,
236
+ ["data"] for external services).
237
+ url: Original embed URL (for error context).
238
+ article_key: Article key (for error context).
239
+ service: Service name for error message (e.g., "note", "youtube").
240
+
241
+ Returns:
242
+ Tuple of (embed_key, html_for_embed).
243
+
244
+ Raises:
245
+ NoteAPIError: If required fields are missing from response.
246
+ """
247
+ # Navigate to the embed data following the path
248
+ data: dict[str, Any] = response
249
+ for key in path:
250
+ data = data.get(key, {})
251
+
252
+ embed_key = data.get("key")
253
+ html_for_embed = data.get("html_for_embed")
254
+
255
+ # Article 6: Validate required fields - no implicit fallbacks
256
+ if not embed_key or not html_for_embed:
257
+ raise NoteAPIError(
258
+ code=ErrorCode.API_ERROR,
259
+ message=(
260
+ f"Failed to fetch {service} embed key: API response missing required field(s) 'key' or 'html_for_embed'"
261
+ ),
262
+ details={"url": url, "article_key": article_key, "response": response},
263
+ )
264
+
265
+ return str(embed_key), str(html_for_embed)
266
+
267
+
268
+ async def _fetch_note_embed_key(
269
+ session: Session,
270
+ url: str,
271
+ article_key: str,
272
+ ) -> tuple[str, str]:
273
+ """Fetch embed key for note.com article via /v1/embed endpoint.
274
+
275
+ This internal function handles note.com article embeds which require
276
+ a different API endpoint than external services (YouTube, Twitter).
277
+
278
+ Issue #121: note.com articles must use POST /v1/embed instead of
279
+ GET /v2/embed_by_external_api which returns 500 error.
280
+
281
+ Args:
282
+ session: Authenticated session with valid cookies.
283
+ url: note.com article URL (e.g., https://note.com/user/n/xxx).
284
+ article_key: Article key where embed will be inserted
285
+ (e.g., "n1234567890ab").
286
+
287
+ Returns:
288
+ Tuple of (embed_key, html_for_embed):
289
+ - embed_key: Server-registered key (e.g., "emb0076d44f4f7f")
290
+ - html_for_embed: HTML snippet for rendering the embed
291
+
292
+ Raises:
293
+ NoteAPIError: If API request fails or returns empty response.
294
+ """
295
+ payload = {
296
+ "url": url,
297
+ "embeddable_key": article_key,
298
+ "embeddable_type": "Note",
299
+ }
300
+
301
+ async with NoteAPIClient(session) as client:
302
+ response = await client.post("/v1/embed", json=payload)
303
+
304
+ # Response structure: {"data": {"embedded_content": {"key": ..., "html_for_embed": ...}}}
305
+ return _extract_and_validate_embed_response(
306
+ response,
307
+ path=["data", "embedded_content"],
308
+ url=url,
309
+ article_key=article_key,
310
+ service="note",
311
+ )
312
+
313
+
314
+ async def fetch_embed_key(
315
+ session: Session,
316
+ url: str,
317
+ article_key: str,
318
+ ) -> tuple[str, str]:
319
+ """Fetch server-registered embed key from note.com API.
320
+
321
+ This function calls the appropriate API endpoint to register
322
+ the embed URL with note.com's server and obtain a valid embed key.
323
+
324
+ The server-registered key is required for note.com's frontend to
325
+ render the iframe. Random keys generated locally will not work.
326
+
327
+ Issue #121: Different endpoints are used for different services:
328
+ - note.com articles: POST /v1/embed
329
+ - Other services (YouTube/Twitter/Gist/etc.): GET /v2/embed_by_external_api
330
+
331
+ Args:
332
+ session: Authenticated session with valid cookies.
333
+ url: Embed URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
334
+ noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
335
+ article_key: Article key where the embed will be inserted
336
+ (e.g., "n1234567890ab").
337
+
338
+ Returns:
339
+ Tuple of (embed_key, html_for_embed):
340
+ - embed_key: Server-registered key (e.g., "emb0076d44f4f7f")
341
+ - html_for_embed: HTML snippet for rendering the embed
342
+
343
+ Raises:
344
+ ValueError: If URL is not a supported embed URL.
345
+ NoteAPIError: If API request fails or returns empty response.
346
+ """
347
+ service = get_embed_service(url)
348
+ if service is None:
349
+ raise ValueError(f"Unsupported embed URL: {url}")
350
+
351
+ # Issue #121: note.com articles use a different API endpoint
352
+ if service == "note":
353
+ return await _fetch_note_embed_key(session, url, article_key)
354
+
355
+ # External services: use /v2/embed_by_external_api endpoint
356
+ params = {
357
+ "url": url,
358
+ "service": service,
359
+ "embeddable_key": article_key,
360
+ "embeddable_type": "Note",
361
+ }
362
+
363
+ async with NoteAPIClient(session) as client:
364
+ response = await client.get("/v2/embed_by_external_api", params=params)
365
+
366
+ # Response structure: {"data": {"key": ..., "html_for_embed": ...}}
367
+ return _extract_and_validate_embed_response(
368
+ response,
369
+ path=["data"],
370
+ url=url,
371
+ article_key=article_key,
372
+ service=service,
373
+ )
374
+
375
+
376
+ def generate_embed_html_with_key(
377
+ url: str,
378
+ embed_key: str,
379
+ service: str | None = None,
380
+ ) -> str:
381
+ """Generate embed HTML with a server-registered key.
382
+
383
+ .. deprecated::
384
+ Use ``generate_embed_html(url, service, embed_key)`` instead.
385
+
386
+ Args:
387
+ url: Original URL (YouTube, Twitter, note.com, GitHub Gist, GitHub Repository,
388
+ noteマネー, Zenn.dev, Google Slides, SpeakerDeck).
389
+ embed_key: Server-registered embed key from fetch_embed_key().
390
+ service: Service type ('youtube', 'twitter', 'note', 'gist', 'githubRepository',
391
+ 'googlepresentation', 'speakerdeck', 'oembed', 'external-article').
392
+ If None, auto-detected from URL.
393
+
394
+ Returns:
395
+ HTML figure element string with server-registered key.
396
+
397
+ Raises:
398
+ ValueError: If URL is not a supported embed URL.
399
+ """
400
+ import warnings
401
+
402
+ warnings.warn(
403
+ "generate_embed_html_with_key is deprecated. Use generate_embed_html(url, service=..., embed_key=...) instead.",
404
+ DeprecationWarning,
405
+ stacklevel=2,
406
+ )
407
+ return generate_embed_html(url, service, embed_key)
408
+
409
+
410
+ # Pattern to find embed figure elements with their keys and URLs
411
+ # Uses non-greedy matching to handle any attribute order
412
+ _EMBED_FIGURE_PATTERN = re.compile(
413
+ r"<figure\s+"
414
+ r"(?=(?:[^>]*?data-src=\"([^\"]+)\"))" # Lookahead for data-src
415
+ r"(?=(?:[^>]*?embedded-content-key=\"([^\"]+)\"))" # Lookahead for embedded-content-key
416
+ r"[^>]*>",
417
+ re.IGNORECASE,
418
+ )
419
+
420
+
421
+ async def resolve_embed_keys(
422
+ session: Session,
423
+ html_body: str,
424
+ article_key: str,
425
+ ) -> str:
426
+ """Replace random embed keys with server-registered keys.
427
+
428
+ Finds all <figure> elements with embedded-content-key attribute and
429
+ replaces their keys with server-registered keys obtained via API.
430
+
431
+ This function should be called after markdown_to_html() conversion
432
+ and before saving the article body to note.com.
433
+
434
+ Issue #121: API errors for individual embeds are logged and skipped,
435
+ allowing other embeds to be processed successfully.
436
+
437
+ Args:
438
+ session: Authenticated session with valid cookies.
439
+ html_body: HTML body containing figure elements with random embed keys.
440
+ article_key: Article key where embeds will be inserted
441
+ (e.g., "n1234567890ab").
442
+
443
+ Returns:
444
+ HTML body with embed keys replaced by server-registered keys.
445
+ Embeds that fail to resolve keep their original placeholder keys.
446
+ """
447
+ # Find all embed figures in the HTML
448
+ matches = list(_EMBED_FIGURE_PATTERN.finditer(html_body))
449
+
450
+ if not matches:
451
+ # No embeds found, return unchanged
452
+ return html_body
453
+
454
+ result = html_body
455
+
456
+ # Process each embed figure
457
+ for match in matches:
458
+ data_src = match.group(1)
459
+ old_key = match.group(2)
460
+
461
+ # Unescape the URL (it was escaped when generating HTML)
462
+ url = html.unescape(data_src)
463
+
464
+ # Skip if URL is not a supported embed URL
465
+ if get_embed_service(url) is None:
466
+ continue
467
+
468
+ # Fetch server-registered key with error handling (Issue #121)
469
+ try:
470
+ server_key, _ = await fetch_embed_key(session, url, article_key)
471
+
472
+ # Replace the old key with the server key
473
+ result = result.replace(
474
+ f'embedded-content-key="{old_key}"',
475
+ f'embedded-content-key="{server_key}"',
476
+ )
477
+ except NoteAPIError as e:
478
+ # Log warning and continue processing other embeds
479
+ logger.warning("Embed key fetch failed for %s: %s", url, e.message)
480
+ # Original placeholder key is preserved
481
+
482
+ return result