note-connector 0.2.4 → 0.2.6

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 +61 -7
  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 +456 -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 +557 -0
  39. package/py/src/note_mcp/py.typed +0 -0
  40. package/py/src/note_mcp/server.py +905 -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,574 @@
1
+ """Browser-based login flow for note.com.
2
+
3
+ Uses Playwright to open a browser for manual user login,
4
+ then extracts session cookies for API authentication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from playwright.async_api import TimeoutError as PlaywrightTimeout
13
+
14
+ from note_mcp.auth.session import SessionManager
15
+ from note_mcp.browser.manager import BrowserManager
16
+ from note_mcp.models import LoginError, Session
17
+
18
+ if TYPE_CHECKING:
19
+ from playwright.async_api import Page
20
+
21
+
22
+ # note.com URLs
23
+ NOTE_LOGIN_URL = "https://note.com/login"
24
+ NOTE_HOME_URL = "https://note.com/"
25
+ NOTE_ACCOUNT_SETTINGS_URL = "https://note.com/settings/account"
26
+
27
+ # Required cookie names (note_gql_auth_token is optional, set on-demand by note.com)
28
+ REQUIRED_COOKIES = ["_note_session_v5"]
29
+ OPTIONAL_COOKIES = ["note_gql_auth_token", "XSRF-TOKEN"]
30
+
31
+ # Default timeout for login (5 minutes)
32
+ DEFAULT_LOGIN_TIMEOUT = 300
33
+
34
+ # Login form selectors
35
+ LOGIN_EMAIL_SELECTOR = 'input[name="email"]'
36
+ LOGIN_PASSWORD_SELECTOR = 'input[name="password"]'
37
+ LOGIN_SUBMIT_SELECTOR = 'button[type="submit"]'
38
+
39
+ # Obstacle detection selectors
40
+ RECAPTCHA_SELECTOR = '[class*="recaptcha"], iframe[src*="recaptcha"]'
41
+ TWO_FACTOR_SELECTOR = '[data-testid="two-factor"], input[name="otp"], input[placeholder*="認証コード"]'
42
+
43
+ # Timeout constants (in milliseconds)
44
+ EMAIL_INPUT_TIMEOUT_MS = 10000
45
+ NETWORK_IDLE_TIMEOUT_MS = 15000
46
+ REDIRECT_TIMEOUT_MS = 15000
47
+
48
+
49
+ def extract_session_cookies(cookies: list[dict[str, Any]]) -> dict[str, str]:
50
+ """Extract required session cookies from browser cookies.
51
+
52
+ Args:
53
+ cookies: List of cookie dictionaries from Playwright
54
+
55
+ Returns:
56
+ Dictionary with required and optional cookie name-value pairs
57
+
58
+ Raises:
59
+ ValueError: If required cookies are missing
60
+ """
61
+ result: dict[str, str] = {}
62
+
63
+ all_cookies = REQUIRED_COOKIES + OPTIONAL_COOKIES
64
+ for cookie in cookies:
65
+ name = cookie.get("name", "")
66
+ if name in all_cookies:
67
+ result[name] = cookie.get("value", "")
68
+
69
+ # Validate required cookies only
70
+ for required_cookie in REQUIRED_COOKIES:
71
+ if required_cookie not in result:
72
+ raise ValueError(f"Missing required cookie: {required_cookie}")
73
+
74
+ return result
75
+
76
+
77
+ async def get_user_from_browser(page: Any) -> dict[str, Any]:
78
+ """Get user info from browser page using JavaScript.
79
+
80
+ Extracts user information from note.com's account settings page.
81
+ Uses the profile link element to extract the username.
82
+
83
+ Args:
84
+ page: Playwright page object (should be on /settings/account page)
85
+
86
+ Returns:
87
+ User info dictionary with 'id' and 'urlname' fields
88
+
89
+ Raises:
90
+ ValueError: If user info cannot be retrieved
91
+ """
92
+ import logging
93
+
94
+ logger = logging.getLogger(__name__)
95
+
96
+ # Try to get user info from note.com's account settings page
97
+ user_info = await page.evaluate("""
98
+ () => {
99
+ // Method 1: Best method - find <a href="/settings/account/note_id"> and get <p> inside
100
+ // Structure: <a href="/settings/account/note_id"><div><h3>note ID</h3><p>USERNAME</p></div></a>
101
+ const noteIdLink = document.querySelector('a[href="/settings/account/note_id"]');
102
+ if (noteIdLink) {
103
+ const pElement = noteIdLink.querySelector('p');
104
+ if (pElement) {
105
+ const username = (pElement.textContent || '').trim();
106
+ if (username && /^[a-zA-Z0-9_-]+$/.test(username)) {
107
+ return { id: '', urlname: username };
108
+ }
109
+ }
110
+ }
111
+
112
+ // Method 2: Try __NEXT_DATA__ (Next.js server-side props)
113
+ if (window.__NEXT_DATA__ && window.__NEXT_DATA__.props) {
114
+ const pageProps = window.__NEXT_DATA__.props.pageProps;
115
+ if (pageProps && pageProps.currentUser) {
116
+ return {
117
+ id: String(pageProps.currentUser.id || ''),
118
+ urlname: pageProps.currentUser.urlname || ''
119
+ };
120
+ }
121
+ }
122
+
123
+ // Method 3: Search localStorage for user info
124
+ for (let i = 0; i < localStorage.length; i++) {
125
+ const key = localStorage.key(i);
126
+ if (key) {
127
+ try {
128
+ const value = localStorage.getItem(key);
129
+ const data = JSON.parse(value);
130
+ const search = (obj, depth = 0) => {
131
+ if (depth > 5 || !obj || typeof obj !== 'object') return null;
132
+ if (obj.urlname && typeof obj.urlname === 'string') {
133
+ return obj.urlname;
134
+ }
135
+ for (const v of Object.values(obj)) {
136
+ const result = search(v, depth + 1);
137
+ if (result) return result;
138
+ }
139
+ return null;
140
+ };
141
+ const urlname = search(data);
142
+ if (urlname) {
143
+ return { id: '', urlname: urlname };
144
+ }
145
+ } catch (e) {
146
+ // Skip non-JSON values
147
+ }
148
+ }
149
+ }
150
+
151
+ // Return empty if no username found
152
+ return { id: '', urlname: '' };
153
+ }
154
+ """)
155
+
156
+ logger.debug(f"Browser user info result: {user_info}")
157
+
158
+ if user_info and (user_info.get("id") or user_info.get("urlname")):
159
+ return {
160
+ "id": str(user_info.get("id", "")),
161
+ "urlname": user_info.get("urlname", ""),
162
+ }
163
+
164
+ raise ValueError("Could not retrieve user info from browser")
165
+
166
+
167
+ async def get_current_user(cookies: dict[str, str], xsrf_token: str | None = None) -> dict[str, Any]:
168
+ """Get current user info from note.com API.
169
+
170
+ Args:
171
+ cookies: Session cookies for authentication
172
+ xsrf_token: XSRF token for CSRF protection
173
+
174
+ Returns:
175
+ User info dictionary with 'id' and 'urlname' fields
176
+
177
+ Raises:
178
+ ValueError: If user info cannot be retrieved
179
+ """
180
+ import httpx
181
+
182
+ async with httpx.AsyncClient() as client:
183
+ # Build cookie header
184
+ cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())
185
+
186
+ # Build headers with XSRF token if available
187
+ headers: dict[str, str] = {
188
+ "Cookie": cookie_header,
189
+ "Accept": "application/json",
190
+ }
191
+ if xsrf_token:
192
+ headers["X-XSRF-TOKEN"] = xsrf_token
193
+
194
+ response = await client.get(
195
+ "https://note.com/api/v1/stats/pv",
196
+ headers=headers,
197
+ )
198
+
199
+ if response.status_code != 200:
200
+ raise ValueError(f"Failed to get user info: HTTP {response.status_code}")
201
+
202
+ data = response.json()
203
+
204
+ # Extract user info from response
205
+ # The stats/pv endpoint includes user info
206
+ user_data = data.get("data", {})
207
+ user_id = user_data.get("user_id") or user_data.get("id")
208
+ urlname = user_data.get("urlname") or user_data.get("username")
209
+
210
+ if not user_id or not urlname:
211
+ # Try alternative endpoint
212
+ response = await client.get(
213
+ "https://note.com/api/v2/self",
214
+ headers=headers,
215
+ )
216
+
217
+ if response.status_code != 200:
218
+ raise ValueError(f"Failed to get user info: HTTP {response.status_code}")
219
+
220
+ data = response.json()
221
+ user_data = data.get("data", {})
222
+ user_id = user_data.get("id", "")
223
+ urlname = user_data.get("urlname", "")
224
+
225
+ if not user_id:
226
+ raise ValueError("Could not retrieve user ID")
227
+
228
+ return {"id": str(user_id), "urlname": urlname or ""}
229
+
230
+
231
+ async def _check_login_obstacles(page: Page) -> None:
232
+ """ログイン障害(reCAPTCHA/2FA)を検出する。
233
+
234
+ Args:
235
+ page: Playwrightページオブジェクト
236
+
237
+ Raises:
238
+ LoginError: 障害検出時
239
+ """
240
+ # reCAPTCHA検出
241
+ recaptcha = page.locator(RECAPTCHA_SELECTOR)
242
+ if await recaptcha.count() > 0:
243
+ raise LoginError(
244
+ code="RECAPTCHA_DETECTED",
245
+ message="reCAPTCHAが検出されました",
246
+ resolution="手動でログインしセッションを保存してください",
247
+ )
248
+
249
+ # 2FA検出
250
+ two_factor = page.locator(TWO_FACTOR_SELECTOR)
251
+ if await two_factor.count() > 0:
252
+ raise LoginError(
253
+ code="TWO_FACTOR_REQUIRED",
254
+ message="二段階認証が要求されています",
255
+ resolution="手動でログインしセッションを保存してください",
256
+ )
257
+
258
+ # ログインエラー検出(認証情報エラー)
259
+ error_message = page.locator('[class*="error"], [class*="alert"]')
260
+ if await error_message.count() > 0:
261
+ error_text = await error_message.first.text_content()
262
+ if error_text and ("パスワード" in error_text or "メールアドレス" in error_text):
263
+ raise LoginError(
264
+ code="INVALID_CREDENTIALS",
265
+ message="認証情報が無効です",
266
+ resolution="ユーザー名とパスワードを確認してください",
267
+ )
268
+
269
+
270
+ async def _perform_auto_login(page: Page, username: str, password: str) -> None:
271
+ """自動ログインを実行する。
272
+
273
+ reCAPTCHAや2FAが検出された場合はLoginErrorを送出する。
274
+
275
+ Args:
276
+ page: Playwrightページオブジェクト
277
+ username: ログインユーザー名(メールアドレス)
278
+ password: ログインパスワード
279
+
280
+ Raises:
281
+ LoginError: reCAPTCHA/2FA検出時、認証失敗時
282
+ """
283
+ # ユーザー名入力
284
+ email_input = page.locator(LOGIN_EMAIL_SELECTOR)
285
+ try:
286
+ await email_input.wait_for(state="visible", timeout=EMAIL_INPUT_TIMEOUT_MS)
287
+ except PlaywrightTimeout as e:
288
+ raise LoginError(
289
+ code="FORM_NOT_FOUND",
290
+ message="ログインフォームが見つかりません",
291
+ resolution="note.comのログインページが正しく読み込まれていることを確認してください",
292
+ ) from e
293
+ await email_input.fill(username)
294
+
295
+ # パスワード入力
296
+ password_input = page.locator(LOGIN_PASSWORD_SELECTOR)
297
+ await password_input.fill(password)
298
+
299
+ # ログインボタンクリック
300
+ submit_button = page.locator(LOGIN_SUBMIT_SELECTOR)
301
+ await submit_button.click()
302
+
303
+ # ログイン結果を待機
304
+ await page.wait_for_load_state("networkidle", timeout=NETWORK_IDLE_TIMEOUT_MS)
305
+
306
+ # 障害検出
307
+ await _check_login_obstacles(page)
308
+
309
+
310
+ async def login_with_browser(
311
+ timeout: int = DEFAULT_LOGIN_TIMEOUT,
312
+ credentials: tuple[str, str] | None = None,
313
+ ) -> Session:
314
+ """Open browser for login and extract session.
315
+
316
+ If credentials are provided, performs automatic login.
317
+ If a saved session exists, injects cookies into browser to restore session.
318
+ Otherwise, opens the note.com login page for manual login.
319
+
320
+ Args:
321
+ timeout: Maximum time to wait for login (seconds)
322
+ credentials: Optional tuple of (username, password) for automatic login.
323
+ If provided, attempts automatic login instead of waiting for manual input.
324
+
325
+ Returns:
326
+ Session object with cookies and user info
327
+
328
+ Raises:
329
+ TimeoutError: If login is not completed within timeout
330
+ ValueError: If required cookies are not found
331
+ LoginError: If automatic login encounters obstacles (reCAPTCHA, 2FA, invalid credentials)
332
+ """
333
+ import logging
334
+
335
+ # Configure logging to file for debugging
336
+ logging.basicConfig(
337
+ level=logging.DEBUG,
338
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
339
+ handlers=[
340
+ logging.FileHandler("/tmp/note_mcp_login.log"),
341
+ logging.StreamHandler(),
342
+ ],
343
+ )
344
+ logger = logging.getLogger(__name__)
345
+
346
+ manager = BrowserManager.get_instance()
347
+
348
+ # Close existing browser to ensure fresh context
349
+ logger.info("Closing existing browser...")
350
+ await manager.close()
351
+
352
+ # Get fresh page in headful mode (user needs to see browser for manual login)
353
+ logger.info("Getting fresh page in headful mode...")
354
+ page = await manager.get_page(headless=False)
355
+
356
+ # Check for saved session and inject cookies if available
357
+ session_manager = SessionManager()
358
+ saved_session = session_manager.load()
359
+
360
+ if saved_session and not saved_session.is_expired() and saved_session.cookies:
361
+ logger.info("Found saved session, injecting cookies into browser...")
362
+ # Convert saved cookies to Playwright format
363
+ playwright_cookies: list[dict[str, Any]] = []
364
+ for name, value in saved_session.cookies.items():
365
+ playwright_cookies.append(
366
+ {
367
+ "name": name,
368
+ "value": value,
369
+ "domain": ".note.com",
370
+ "path": "/",
371
+ }
372
+ )
373
+ await page.context.add_cookies(playwright_cookies) # type: ignore[arg-type]
374
+ logger.info(f"Injected {len(playwright_cookies)} cookies")
375
+
376
+ # Navigate to home page to check if session is valid
377
+ logger.info(f"Navigating to {NOTE_HOME_URL} to verify session...")
378
+ await page.goto(NOTE_HOME_URL, wait_until="domcontentloaded")
379
+ else:
380
+ logger.info("No saved session or session expired, navigating to login page...")
381
+ # Navigate to login page
382
+ await page.goto(NOTE_LOGIN_URL, wait_until="domcontentloaded")
383
+
384
+ current_url = page.url
385
+ logger.info(f"Current URL after navigation: {current_url}")
386
+
387
+ # Define what a logged-in URL looks like
388
+ def is_logged_in(url: str) -> bool:
389
+ # Must be on note.com but NOT on login-related pages
390
+ if not url.startswith(NOTE_HOME_URL):
391
+ return False
392
+ # Exclude login and auth pages
393
+ login_paths = ["/login", "/signup", "/auth", "/oauth"]
394
+ result = all(f"note.com{path}" not in url for path in login_paths)
395
+ logger.debug(f"is_logged_in({url}) = {result}")
396
+ return result
397
+
398
+ # Check if already logged in (URL changed during navigation)
399
+ if is_logged_in(current_url):
400
+ logger.info("Already logged in, extracting cookies...")
401
+ elif credentials:
402
+ # Automatic login with provided credentials
403
+ username, password = credentials
404
+ logger.info(f"Attempting automatic login for user: {username}")
405
+ await _perform_auto_login(page, username, password)
406
+ logger.info("Automatic login completed, verifying...")
407
+
408
+ # Verify login succeeded by checking URL
409
+ current_url = page.url
410
+ if not is_logged_in(current_url):
411
+ # Wait for redirect after login
412
+ try:
413
+ await page.wait_for_url(
414
+ is_logged_in,
415
+ timeout=REDIRECT_TIMEOUT_MS,
416
+ )
417
+ logger.info("Login redirect detected!")
418
+ except PlaywrightTimeout as e:
419
+ raise LoginError(
420
+ code="LOGIN_TIMEOUT",
421
+ message="ログイン後のリダイレクトがタイムアウトしました",
422
+ resolution="認証情報を確認するか、手動でログインしてください",
423
+ ) from e
424
+ else:
425
+ # Wait for user to complete login (redirected away from login page)
426
+ # This will block until user manually logs in via the browser
427
+ logger.info("Waiting for user to complete login...")
428
+ try:
429
+ await page.wait_for_url(
430
+ is_logged_in,
431
+ timeout=timeout * 1000, # Convert to milliseconds
432
+ )
433
+ logger.info("Login detected!")
434
+ except Exception as e:
435
+ raise TimeoutError(f"Login not completed within {timeout} seconds") from e
436
+
437
+ # Navigate to account settings page to ensure all cookies are set
438
+ # and to extract user info from the profile link
439
+ logger.info(f"Navigating to account settings to extract user info: {NOTE_ACCOUNT_SETTINGS_URL}")
440
+ await page.goto(NOTE_ACCOUNT_SETTINGS_URL, wait_until="domcontentloaded")
441
+ # Wait a bit for cookies to be set
442
+ import asyncio
443
+
444
+ await asyncio.sleep(1)
445
+ logger.info(f"Account settings URL: {page.url}")
446
+
447
+ # Extract cookies from browser context
448
+ logger.info("Extracting cookies from browser context...")
449
+ final_cookies = await page.context.cookies()
450
+ logger.info(f"Found {len(final_cookies)} cookies")
451
+
452
+ # Log cookie names for debugging
453
+ cookie_names = [c.get("name", "") for c in final_cookies]
454
+ logger.info(f"Cookie names: {cookie_names}")
455
+
456
+ # Convert Cookie objects to dicts for extraction
457
+ final_cookie_dicts: list[dict[str, Any]] = [
458
+ {"name": c.get("name", ""), "value": c.get("value", "")} for c in final_cookies
459
+ ]
460
+ cookies = extract_session_cookies(final_cookie_dicts)
461
+
462
+ # Extract XSRF token for API calls
463
+ xsrf_token: str | None = None
464
+ for cookie in final_cookies:
465
+ if cookie.get("name") == "XSRF-TOKEN":
466
+ xsrf_token = cookie.get("value")
467
+ break
468
+
469
+ # Get user info - try multiple methods
470
+ user_id = ""
471
+ username = ""
472
+
473
+ # Method 1: Try to get from browser page first (most reliable)
474
+ try:
475
+ user_info = await get_user_from_browser(page)
476
+ user_id = user_info["id"]
477
+ username = user_info["urlname"]
478
+ logger.info(f"User info from browser: {username} (ID: {user_id})")
479
+ except ValueError as e:
480
+ logger.debug(f"Browser method failed: {e}")
481
+
482
+ # Method 2: Click on profile avatar/link and extract username from URL
483
+ if not username:
484
+ try:
485
+ logger.info("Trying to get username by clicking on profile link...")
486
+
487
+ # Try to find and click the profile avatar/link in header
488
+ # This usually appears as an image link that navigates to /{username}
489
+ clicked = await page.evaluate("""
490
+ () => {
491
+ // Look for profile avatar in header (usually an img wrapped in an anchor)
492
+ const headerAvatars = document.querySelectorAll('header a img, header button img');
493
+ for (const img of headerAvatars) {
494
+ const link = img.closest('a');
495
+ if (link) {
496
+ const href = link.getAttribute('href');
497
+ // Check if it looks like a profile link
498
+ if (href && href.match(/^\\/[a-zA-Z0-9_-]+$/)) {
499
+ link.click();
500
+ return href;
501
+ }
502
+ }
503
+ }
504
+
505
+ // Try clicking on any link in header that matches username pattern
506
+ const headerLinks = document.querySelectorAll('header a[href^="/"]');
507
+ const systemPaths = ['login', 'signup', 'search', 'notifications',
508
+ 'settings', 'premium', 'contests', 'hashtag', 'sitesettings',
509
+ 'explore', 'ranking', 'magazine', 'api', 'n', 'm', 'note'];
510
+ for (const link of headerLinks) {
511
+ const href = link.getAttribute('href');
512
+ if (href) {
513
+ const match = href.match(/^\\/([a-zA-Z0-9_-]+)$/);
514
+ if (match && !systemPaths.includes(match[1].toLowerCase())) {
515
+ link.click();
516
+ return href;
517
+ }
518
+ }
519
+ }
520
+
521
+ return null;
522
+ }
523
+ """)
524
+
525
+ if clicked:
526
+ logger.info(f"Clicked on profile link: {clicked}")
527
+ # Wait for navigation
528
+ await asyncio.sleep(1)
529
+
530
+ # Extract username from current URL
531
+ current_url = page.url
532
+ logger.info(f"Current URL after navigation: {current_url}")
533
+
534
+ # Parse URL to get username: https://note.com/{username}
535
+ import re
536
+
537
+ match = re.match(r"https://note\.com/([a-zA-Z0-9_-]+)", current_url)
538
+ if match:
539
+ username = match.group(1)
540
+ user_id = username
541
+ logger.info(f"Extracted username from URL: {username}")
542
+ except Exception as e:
543
+ logger.warning(f"Profile navigation method failed: {e}")
544
+
545
+ # Method 3: Fallback to API if browser method failed
546
+ if not username:
547
+ try:
548
+ user_info = await get_current_user(cookies, xsrf_token=xsrf_token)
549
+ user_id = user_info["id"]
550
+ username = user_info["urlname"]
551
+ logger.info(f"User info from API: {username} (ID: {user_id})")
552
+ except ValueError as e:
553
+ # Article 6: No placeholder values allowed
554
+ # All three methods failed to retrieve user info
555
+ raise ValueError(
556
+ "Failed to retrieve user information after successful login. "
557
+ "All methods (noteInitData, profile URL, API) failed. "
558
+ f"Last error: {e}"
559
+ ) from e
560
+
561
+ # Create session
562
+ session = Session(
563
+ cookies=cookies,
564
+ user_id=user_id,
565
+ username=username,
566
+ expires_at=None, # No explicit expiry from cookies
567
+ created_at=int(time.time()),
568
+ )
569
+
570
+ # Save session to keyring
571
+ session_manager = SessionManager()
572
+ session_manager.save(session)
573
+
574
+ return session