line-ext-msg 1.0.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.
- line_ext_msg/__init__.py +34 -0
- line_ext_msg/auth.py +58 -0
- line_ext_msg/chrome.py +94 -0
- line_ext_msg/cli.py +99 -0
- line_ext_msg/client.py +287 -0
- line_ext_msg/errors.py +34 -0
- line_ext_msg/messages.py +278 -0
- line_ext_msg/models.py +40 -0
- line_ext_msg/progress.py +36 -0
- line_ext_msg/rooms.py +88 -0
- line_ext_msg/session.py +168 -0
- line_ext_msg/settings.py +79 -0
- line_ext_msg/storage.py +8 -0
- line_ext_msg-1.0.0.dist-info/METADATA +79 -0
- line_ext_msg-1.0.0.dist-info/RECORD +18 -0
- line_ext_msg-1.0.0.dist-info/WHEEL +4 -0
- line_ext_msg-1.0.0.dist-info/entry_points.txt +2 -0
- line_ext_msg-1.0.0.dist-info/licenses/LICENSE +21 -0
line_ext_msg/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""line-ext-msg: pull messages from LINE Chrome Extension via Playwright CDP."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from .client import LineClient
|
|
6
|
+
from .messages import sender_stats
|
|
7
|
+
from .errors import (
|
|
8
|
+
AppNotReady,
|
|
9
|
+
AttachFailed,
|
|
10
|
+
ChromeNotReady,
|
|
11
|
+
ExtensionMissing,
|
|
12
|
+
LineError,
|
|
13
|
+
LoginRequired,
|
|
14
|
+
RoomNotFound,
|
|
15
|
+
)
|
|
16
|
+
from .models import Message, Room, StepResult
|
|
17
|
+
from .settings import Settings
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"LineClient",
|
|
22
|
+
"sender_stats",
|
|
23
|
+
"Room",
|
|
24
|
+
"Message",
|
|
25
|
+
"StepResult",
|
|
26
|
+
"Settings",
|
|
27
|
+
"LineError",
|
|
28
|
+
"ChromeNotReady",
|
|
29
|
+
"AttachFailed",
|
|
30
|
+
"ExtensionMissing",
|
|
31
|
+
"AppNotReady",
|
|
32
|
+
"LoginRequired",
|
|
33
|
+
"RoomNotFound",
|
|
34
|
+
]
|
line_ext_msg/auth.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Login check: distinguish 'not logged in' from 'selector mismatch'."""
|
|
2
|
+
|
|
3
|
+
from playwright.sync_api import Page
|
|
4
|
+
|
|
5
|
+
from .settings import SELECTORS
|
|
6
|
+
|
|
7
|
+
# Fast login-screen locators (CSS-module hashes change, so match stable parts).
|
|
8
|
+
LOGIN_LOCATORS = [
|
|
9
|
+
"[class*='loginPage']",
|
|
10
|
+
"text=QR code login",
|
|
11
|
+
"text=Log in",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
# Fallback text markers when locators miss (checked on small scope only).
|
|
15
|
+
LOGIN_MARKERS = [
|
|
16
|
+
"qr code", "qr login", "email address",
|
|
17
|
+
"เข้าสู่ระบบ", "ล็อกอิน",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _has_login_ui(page: Page) -> bool:
|
|
22
|
+
"""True if any login marker is visible (cheap locator checks)."""
|
|
23
|
+
for sel in LOGIN_LOCATORS:
|
|
24
|
+
try:
|
|
25
|
+
if page.locator(sel).count() > 0:
|
|
26
|
+
return True
|
|
27
|
+
except Exception:
|
|
28
|
+
continue
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_login(page: Page, timeout_ms: int = 10000) -> tuple[bool, str]:
|
|
33
|
+
"""Poll chat UI vs login UI; return as soon as either appears.
|
|
34
|
+
|
|
35
|
+
Return (logged_in, reason): 'chat' / 'login' / 'unknown'.
|
|
36
|
+
Caller must ensure the app finished rendering (wait_for_app_ready).
|
|
37
|
+
"""
|
|
38
|
+
# Poll room rows (not the container: it may not exist as one element).
|
|
39
|
+
rows = [s.strip() for s in SELECTORS["room_item"].split(",")]
|
|
40
|
+
# Poll every 500ms; worst case only when page genuinely stuck.
|
|
41
|
+
for _ in range(max(1, timeout_ms // 500)):
|
|
42
|
+
for sel in rows:
|
|
43
|
+
try:
|
|
44
|
+
if page.locator(sel).count() > 0:
|
|
45
|
+
return True, "chat"
|
|
46
|
+
except Exception:
|
|
47
|
+
continue
|
|
48
|
+
if _has_login_ui(page):
|
|
49
|
+
return False, "login"
|
|
50
|
+
page.wait_for_timeout(500)
|
|
51
|
+
# Final text fallback on rendered body before giving up.
|
|
52
|
+
try:
|
|
53
|
+
text = (page.inner_text("body") or "").lower()
|
|
54
|
+
except Exception:
|
|
55
|
+
text = ""
|
|
56
|
+
if any(m in text for m in LOGIN_MARKERS):
|
|
57
|
+
return False, "login"
|
|
58
|
+
return False, "unknown"
|
line_ext_msg/chrome.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Chrome lifecycle: ensure a debuggable instance on the isolated profile."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from .settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def expand(path: str) -> str:
|
|
12
|
+
"""Expand %VAR% on Windows and ~ on all platforms."""
|
|
13
|
+
return os.path.expandvars(os.path.expanduser(path))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_chrome_exe() -> str | None:
|
|
17
|
+
"""Search common install locations, return exe path or None."""
|
|
18
|
+
candidates = [
|
|
19
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
20
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
21
|
+
expand(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
|
22
|
+
]
|
|
23
|
+
for path in candidates:
|
|
24
|
+
if path and os.path.isfile(path):
|
|
25
|
+
return path
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_debug_ready(settings: Settings, timeout_sec: int = 2) -> bool:
|
|
30
|
+
"""Check CDP endpoint responds with valid Browser field."""
|
|
31
|
+
try:
|
|
32
|
+
with urllib.request.urlopen(f"{settings.cdp_endpoint}/json/version", timeout=timeout_sec) as res:
|
|
33
|
+
return "Browser" in res.read().decode("utf-8", errors="ignore")
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_headless(settings: Settings, timeout_sec: int = 2) -> bool:
|
|
39
|
+
"""Detect a headless instance squatting the debug port (no visible window)."""
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(f"{settings.cdp_endpoint}/json/version", timeout=timeout_sec) as res:
|
|
42
|
+
return "Headless" in res.read().decode("utf-8", errors="ignore")
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_profile_locked(data_dir: str) -> bool:
|
|
48
|
+
"""Detect SingletonLock (profile in use by a normal window)."""
|
|
49
|
+
return any(
|
|
50
|
+
os.path.exists(os.path.join(data_dir, name))
|
|
51
|
+
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie")
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def start_chrome_debug(settings: Settings) -> None:
|
|
56
|
+
"""Start detached Chrome on the isolated profile.
|
|
57
|
+
|
|
58
|
+
Chrome 136+ ignores --remote-debugging-port on the default profile,
|
|
59
|
+
hence the isolated dir. Login + install persist there after first setup.
|
|
60
|
+
"""
|
|
61
|
+
from .errors import ChromeNotReady
|
|
62
|
+
|
|
63
|
+
exe = find_chrome_exe()
|
|
64
|
+
if exe is None:
|
|
65
|
+
raise ChromeNotReady("ไม่พบ chrome.exe กรุณาติดตั้ง Chrome ก่อน")
|
|
66
|
+
data_dir = expand(settings.profile_dir)
|
|
67
|
+
if _is_profile_locked(data_dir):
|
|
68
|
+
raise ChromeNotReady("โปรไฟล์ Chrome ถูกใช้อยู่ ปิดหน้าต่าง debug เก่าก่อนแล้วรันใหม่")
|
|
69
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
70
|
+
subprocess.Popen(
|
|
71
|
+
[exe, f"--remote-debugging-port={settings.port}", f"--user-data-dir={data_dir}"],
|
|
72
|
+
stdout=subprocess.DEVNULL,
|
|
73
|
+
stderr=subprocess.DEVNULL,
|
|
74
|
+
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0),
|
|
75
|
+
)
|
|
76
|
+
for _ in range(30):
|
|
77
|
+
if is_debug_ready(settings, timeout_sec=1):
|
|
78
|
+
return
|
|
79
|
+
time.sleep(0.5)
|
|
80
|
+
raise ChromeNotReady("เปิด Chrome แล้วแต่พอร์ต debug ไม่ตอบใน 15 วิ")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure_chrome(settings: Settings) -> None:
|
|
84
|
+
"""Ensure headed debug Chrome is running; start it if the port is closed."""
|
|
85
|
+
from .errors import ChromeNotReady
|
|
86
|
+
|
|
87
|
+
if is_debug_ready(settings):
|
|
88
|
+
if is_headless(settings):
|
|
89
|
+
raise ChromeNotReady(
|
|
90
|
+
"พอร์ต debug ถูกโปรแกรมอื่น (headless) ใช้อยู่ ไม่มีหน้าต่างให้เห็น "
|
|
91
|
+
"รัน: Get-Process chrome | Stop-Process -Force แล้วรันใหม่"
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
start_chrome_debug(settings)
|
line_ext_msg/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Thin CLI over LineClient: parse args, print results."""
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from .client import LineClient
|
|
5
|
+
from .errors import LineError
|
|
6
|
+
from .models import Room
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _display(m) -> str:
|
|
10
|
+
"""Readable line: text, or media label when the bubble has no text."""
|
|
11
|
+
if m.text:
|
|
12
|
+
return m.text[:80]
|
|
13
|
+
if m.type == "image":
|
|
14
|
+
return f"[รูปภาพ: {m.media or 'โหลดไม่สำเร็จ'}]"
|
|
15
|
+
if m.type == "sticker":
|
|
16
|
+
return "[สติกเกอร์]"
|
|
17
|
+
if m.type == "system":
|
|
18
|
+
return m.text
|
|
19
|
+
return f"[{m.type}]"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pick_room(rooms: list[Room]) -> Room:
|
|
23
|
+
"""Show rooms in terminal and return user choice."""
|
|
24
|
+
print("\nห้องแชททั้งหมด:")
|
|
25
|
+
for r in rooms:
|
|
26
|
+
unread = f" ({r.unread} ไม่อ่าน)" if r.unread else ""
|
|
27
|
+
print(f" [{r.index}] {r.name}{unread}")
|
|
28
|
+
while True:
|
|
29
|
+
raw = input("พิมพ์เลขห้อง > ").strip()
|
|
30
|
+
if raw.isdigit() and any(r.index == int(raw) for r in rooms):
|
|
31
|
+
return next(r for r in rooms if r.index == int(raw))
|
|
32
|
+
print("เลขไม่ถูกต้อง ลองใหม่")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
parser = argparse.ArgumentParser(description="ดึงข้อความล่าสุดจาก LINE Extension")
|
|
37
|
+
parser.add_argument("--limit", type=int, default=5, help="จำนวนข้อความล่าสุด")
|
|
38
|
+
parser.add_argument("--date", default=None, help="กรองเฉพาะวันที่ YYYY-MM-DD")
|
|
39
|
+
parser.add_argument("--date-from", default=None, help="ตั้งแต่วันที่ YYYY-MM-DD")
|
|
40
|
+
parser.add_argument("--date-to", default=None, help="ถึงวันที่ YYYY-MM-DD")
|
|
41
|
+
parser.add_argument("--time-from", default=None, help="ตั้งแต่เวลา HH:MM")
|
|
42
|
+
parser.add_argument("--time-to", default=None, help="ถึงเวลา HH:MM")
|
|
43
|
+
parser.add_argument("--sender", default=None, help="กรองชื่อคนส่ง (บางส่วน)")
|
|
44
|
+
parser.add_argument("--keyword", default=None, help="ค้นคำในข้อความ")
|
|
45
|
+
parser.add_argument("--search", default=None, metavar="KEYWORD",
|
|
46
|
+
help="ค้นทุกห้อง แล้วสรุปห้องที่เจอ")
|
|
47
|
+
parser.add_argument("--unread", action="store_true", help="แสดงเฉพาะห้องที่ไม่อ่าน")
|
|
48
|
+
parser.add_argument("--save", action="store_true", help="เขียน rooms.json + messages ลงไฟล์ (default พิมพ์จออย่างเดียว)")
|
|
49
|
+
parser.add_argument("--dump", action="store_true", help="บันทึก DOM ดิบเพื่อจูน selector")
|
|
50
|
+
parser.add_argument("--dump-room", type=int, default=None, metavar="INDEX",
|
|
51
|
+
help="เปิดห้องลำดับ INDEX แล้วบันทึก DOM เพื่อจูน selector ข้อความ")
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with LineClient() as line:
|
|
56
|
+
if args.dump:
|
|
57
|
+
state = line.dump_page()
|
|
58
|
+
print(f"บันทึก line_dom.html แล้ว (state={state}) ส่งไฟล์นี้มาเพื่อจูน selector")
|
|
59
|
+
return
|
|
60
|
+
line.status()
|
|
61
|
+
if args.dump_room is not None:
|
|
62
|
+
room = line.dump_room(args.dump_room)
|
|
63
|
+
print(f"บันทึก line_room.html แล้ว (ห้อง {room.name}) ส่งไฟล์นี้มาเพื่อจูน selector ข้อความ")
|
|
64
|
+
return
|
|
65
|
+
rooms = line.list_rooms(unread_only=args.unread)
|
|
66
|
+
if not rooms:
|
|
67
|
+
print("อ่านรายชื่อห้องไม่ได้ รัน --dump แล้วส่ง line_dom.html มา")
|
|
68
|
+
return
|
|
69
|
+
if args.save:
|
|
70
|
+
path = line.save_rooms(unread_only=args.unread)
|
|
71
|
+
print(f"บันทึก {path} ({len(rooms)} ห้อง)")
|
|
72
|
+
if args.search:
|
|
73
|
+
for hit in line.search_all(args.search, date_from=args.date_from,
|
|
74
|
+
date_to=args.date_to or args.date):
|
|
75
|
+
print(f"\n== {hit['room']['name']} ({len(hit['messages'])} ข้อความ) ==")
|
|
76
|
+
for m in hit["messages"]:
|
|
77
|
+
print(f"[{m['date']} {m['ts']}] {m['sender']}: {m['text'][:80]}")
|
|
78
|
+
return
|
|
79
|
+
chosen = pick_room(rooms)
|
|
80
|
+
filt = dict(limit=args.limit, date=args.date, date_from=args.date_from,
|
|
81
|
+
date_to=args.date_to, time_from=args.time_from, time_to=args.time_to,
|
|
82
|
+
sender=args.sender, keyword=args.keyword, media_dir="media")
|
|
83
|
+
if args.save:
|
|
84
|
+
out = line.save_messages(chosen, **filt)
|
|
85
|
+
print(f"บันทึก {out}")
|
|
86
|
+
print(f"กำลังเปิดห้อง {chosen.name} ...", flush=True)
|
|
87
|
+
msgs = line.get_messages(chosen, **filt)
|
|
88
|
+
if not msgs:
|
|
89
|
+
print("เปิดห้องได้แต่อ่านข้อความไม่ได้")
|
|
90
|
+
print(f"รัน uv run line-ext-msg --dump-room {chosen.index} แล้วส่ง line_room.html มา")
|
|
91
|
+
return
|
|
92
|
+
for m in msgs:
|
|
93
|
+
print(f"[{m.date} {m.ts}] {m.sender}: {_display(m)}")
|
|
94
|
+
except LineError as e:
|
|
95
|
+
print(e)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
line_ext_msg/client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""LineClient: single facade owning the whole lifecycle.
|
|
2
|
+
|
|
3
|
+
Typical use (also the shape MCP tools wrap)::
|
|
4
|
+
|
|
5
|
+
with LineClient() as line:
|
|
6
|
+
rooms = line.list_rooms(unread_only=True)
|
|
7
|
+
msgs = line.get_messages("ครอบครัว", limit=5)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
|
|
12
|
+
from . import auth
|
|
13
|
+
from . import chrome as _chrome
|
|
14
|
+
from . import messages as _messages
|
|
15
|
+
from . import rooms as _rooms
|
|
16
|
+
from . import session as _session
|
|
17
|
+
from . import storage as _storage
|
|
18
|
+
from .errors import (
|
|
19
|
+
AppNotReady,
|
|
20
|
+
AttachFailed,
|
|
21
|
+
ChromeNotReady,
|
|
22
|
+
ExtensionMissing,
|
|
23
|
+
LineError,
|
|
24
|
+
LoginRequired,
|
|
25
|
+
)
|
|
26
|
+
from .models import Message, Room, StepResult
|
|
27
|
+
from .progress import Steps
|
|
28
|
+
from .settings import Settings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LineClient:
|
|
32
|
+
"""Owns ensure-chrome, attach, page, and readiness. Use as context manager."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, settings: Settings | None = None, **overrides):
|
|
35
|
+
self.settings = settings or Settings(**overrides)
|
|
36
|
+
self._steps = Steps(5, quiet=self.settings.quiet)
|
|
37
|
+
self._pw = None
|
|
38
|
+
self._browser = None
|
|
39
|
+
self._context = None
|
|
40
|
+
self._page = None
|
|
41
|
+
|
|
42
|
+
# -- lifecycle ----------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "LineClient":
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, *exc) -> None:
|
|
48
|
+
self.close()
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
if self._pw is not None:
|
|
52
|
+
try:
|
|
53
|
+
self._pw.stop()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
self._pw = None
|
|
57
|
+
self._browser = self._context = self._page = None
|
|
58
|
+
|
|
59
|
+
# -- startup checklist (also serves MCP line_status) --------------
|
|
60
|
+
|
|
61
|
+
def status(self) -> list[StepResult]:
|
|
62
|
+
"""Run the 5 readiness checks. Raises typed LineError on first failure."""
|
|
63
|
+
steps = self._steps
|
|
64
|
+
if not self.settings.quiet:
|
|
65
|
+
print("[0/5] เริ่มตรวจเงื่อนไข...", flush=True)
|
|
66
|
+
results: list[StepResult] = []
|
|
67
|
+
|
|
68
|
+
def record(name: str, passed: bool, detail: str = "", hint: str = "") -> StepResult:
|
|
69
|
+
if detail:
|
|
70
|
+
steps.detail(detail)
|
|
71
|
+
steps.check(name, passed, "" if passed else hint)
|
|
72
|
+
result = StepResult(name=name, passed=passed, detail=detail or hint)
|
|
73
|
+
results.append(result)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
_chrome.ensure_chrome(self.settings)
|
|
78
|
+
except ChromeNotReady as e:
|
|
79
|
+
record("Chrome debug พร้อม", False, str(e))
|
|
80
|
+
steps.skip_rest("หยุดก่อน")
|
|
81
|
+
raise
|
|
82
|
+
record("Chrome debug พร้อม", True)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
self._pw, self._browser, self._context = _session.connect(self.settings)
|
|
86
|
+
except AttachFailed as e:
|
|
87
|
+
record("เกาะเบราว์เซอร์", False, str(e))
|
|
88
|
+
steps.skip_rest("หยุดก่อน")
|
|
89
|
+
raise
|
|
90
|
+
record("เกาะเบราว์เซอร์", True)
|
|
91
|
+
|
|
92
|
+
installed, detail = _session.check_installed(self._context, self.settings, self._browser)
|
|
93
|
+
if not record("Extension ติดตั้ง", installed,
|
|
94
|
+
detail=detail, hint=f"ติดตั้งเองจาก: {self.settings.webstore_url}").passed:
|
|
95
|
+
_session.open_store_page(self._context, self.settings)
|
|
96
|
+
steps.skip_rest("รอติดตั้งก่อน")
|
|
97
|
+
raise ExtensionMissing(f"ติดตั้งเองจาก: {self.settings.webstore_url}")
|
|
98
|
+
|
|
99
|
+
self._page = _session.ensure_line_page(self._context, self.settings, self._browser)
|
|
100
|
+
state = _session.wait_ready(self._page, self.settings)
|
|
101
|
+
if not record("หน้า LINE พร้อม", state == "ready",
|
|
102
|
+
detail=f"state={state}", hint="แอปโหลดไม่เสร็จในเวลาที่กำหนด ลองรันใหม่").passed:
|
|
103
|
+
steps.skip_rest("หยุดก่อน")
|
|
104
|
+
raise AppNotReady("แอปโหลดไม่เสร็จในเวลาที่กำหนด ลองรันใหม่")
|
|
105
|
+
|
|
106
|
+
logged_in, reason = auth.check_login(self._page, self.settings.login_poll_ms)
|
|
107
|
+
detail = "โครงหน้าเว็บไม่ตรง selector ที่รู้จัก" if reason == "unknown" and not logged_in else ""
|
|
108
|
+
if not record("ล็อกอินแล้ว", logged_in,
|
|
109
|
+
detail=detail, hint="เปิดแท็บ LINE ล็อกอินด้วย QR/อีเมลก่อน แล้วรันใหม่").passed:
|
|
110
|
+
steps.skip_rest("รอ login ก่อน")
|
|
111
|
+
raise LoginRequired("ยังไม่ล็อกอิน LINE")
|
|
112
|
+
return results
|
|
113
|
+
|
|
114
|
+
def _ready_page(self):
|
|
115
|
+
"""Page guaranteed ready; runs status() once, then reuses the session."""
|
|
116
|
+
if self._page is None:
|
|
117
|
+
self.status()
|
|
118
|
+
assert self._page is not None
|
|
119
|
+
return self._page
|
|
120
|
+
|
|
121
|
+
# -- domain API ---------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def list_rooms(self, unread_only: bool = False, query: str | None = None) -> list[Room]:
|
|
124
|
+
"""Rooms from the chats view, optionally filtered."""
|
|
125
|
+
rooms = _rooms.list_rooms(self._ready_page(), self.settings)
|
|
126
|
+
if unread_only:
|
|
127
|
+
rooms = [r for r in rooms if r.unread > 0]
|
|
128
|
+
if query:
|
|
129
|
+
rooms = [r for r in rooms if query in r.name]
|
|
130
|
+
return rooms
|
|
131
|
+
|
|
132
|
+
def open_room(self, ref: int | str | Room) -> Room:
|
|
133
|
+
"""Open a room by index, data-mid, Room, or name substring."""
|
|
134
|
+
rooms = self.list_rooms()
|
|
135
|
+
return _rooms.open_room(self._ready_page(), ref, rooms, self.settings)
|
|
136
|
+
|
|
137
|
+
def get_messages(
|
|
138
|
+
self,
|
|
139
|
+
room: int | str | Room | None = None,
|
|
140
|
+
limit: int = 5,
|
|
141
|
+
date: str | None = None,
|
|
142
|
+
date_from: str | None = None,
|
|
143
|
+
date_to: str | None = None,
|
|
144
|
+
time_from: str | None = None,
|
|
145
|
+
time_to: str | None = None,
|
|
146
|
+
sender: str | None = None,
|
|
147
|
+
keyword: str | None = None,
|
|
148
|
+
media_dir: str | None = None,
|
|
149
|
+
include_media_data: bool = False,
|
|
150
|
+
) -> list[Message]:
|
|
151
|
+
"""Latest messages, optionally opening a room first and filtering.
|
|
152
|
+
|
|
153
|
+
Only rendered rows are visible (virtualized list); wide ranges
|
|
154
|
+
still return just what the DOM holds. media_dir=None keeps the
|
|
155
|
+
call side-effect free; set it to download image bubbles.
|
|
156
|
+
include_media_data embeds data URIs (for MCP/AI, off by default).
|
|
157
|
+
"""
|
|
158
|
+
if room is not None:
|
|
159
|
+
rooms = self.list_rooms()
|
|
160
|
+
_rooms.open_room(self._ready_page(), room, rooms, self.settings)
|
|
161
|
+
return _messages.get_messages(
|
|
162
|
+
self._ready_page(), self.settings, limit=limit, date=date,
|
|
163
|
+
date_from=date_from, date_to=date_to, time_from=time_from,
|
|
164
|
+
time_to=time_to, sender=sender, keyword=keyword, media_dir=media_dir,
|
|
165
|
+
include_media_data=include_media_data,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def unread_digest(self) -> list[dict]:
|
|
169
|
+
"""Rooms with unread>0 plus latest preview: answers 'what is unread' in one call."""
|
|
170
|
+
return [asdict(r) for r in self.list_rooms(unread_only=True)]
|
|
171
|
+
|
|
172
|
+
def unread_full(self, date: str | None = None, limit_per_room: int = 20) -> list[dict]:
|
|
173
|
+
"""Unread rooms with their messages. date=None means today (local)."""
|
|
174
|
+
from datetime import date as _date
|
|
175
|
+
day = date or _date.today().isoformat()
|
|
176
|
+
out = []
|
|
177
|
+
for i, room in enumerate(self.list_rooms(unread_only=True)):
|
|
178
|
+
if not self.settings.quiet:
|
|
179
|
+
print(f" ... ห้อง {i + 1}: {room.name}", flush=True)
|
|
180
|
+
rooms = self.list_rooms()
|
|
181
|
+
_rooms.open_room(self._ready_page(), room, rooms, self.settings)
|
|
182
|
+
msgs = self.get_messages(limit=limit_per_room, date=day)
|
|
183
|
+
out.append({"room": asdict(room), "messages": [asdict(m) for m in msgs]})
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
def search_all(
|
|
187
|
+
self,
|
|
188
|
+
keyword: str,
|
|
189
|
+
date_from: str | None = None,
|
|
190
|
+
date_to: str | None = None,
|
|
191
|
+
rooms: list[int | str | Room] | None = None,
|
|
192
|
+
limit_per_room: int = 100,
|
|
193
|
+
) -> list[dict]:
|
|
194
|
+
"""Search keyword across rooms. Returns only rooms with matches.
|
|
195
|
+
|
|
196
|
+
Sequential (one room at a time); cost ~ seconds per room.
|
|
197
|
+
"""
|
|
198
|
+
targets = self.list_rooms()
|
|
199
|
+
if rooms is not None:
|
|
200
|
+
wanted = []
|
|
201
|
+
for ref in rooms:
|
|
202
|
+
try:
|
|
203
|
+
wanted.append(_rooms.resolve_ref(ref, targets))
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
targets = wanted
|
|
207
|
+
out = []
|
|
208
|
+
for i, room in enumerate(targets):
|
|
209
|
+
if not self.settings.quiet:
|
|
210
|
+
print(f" ... ค้นห้อง {i + 1}/{len(targets)}: {room.name}", flush=True)
|
|
211
|
+
_rooms.open_room(self._ready_page(), room, self.list_rooms(), self.settings)
|
|
212
|
+
msgs = self.get_messages(
|
|
213
|
+
limit=limit_per_room, date_from=date_from, date_to=date_to, keyword=keyword,
|
|
214
|
+
)
|
|
215
|
+
if msgs:
|
|
216
|
+
out.append({"room": asdict(room), "messages": [asdict(m) for m in msgs]})
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
# -- debugging helpers --------------------------------------------
|
|
220
|
+
|
|
221
|
+
def dump_page(self, path: str = "line_dom.html") -> str:
|
|
222
|
+
"""Save current chats DOM for selector tuning. No login required."""
|
|
223
|
+
from . import chrome as _c
|
|
224
|
+
|
|
225
|
+
_c.ensure_chrome(self.settings)
|
|
226
|
+
if self._pw is None:
|
|
227
|
+
self._pw, self._browser, self._context = _session.connect(self.settings)
|
|
228
|
+
assert self._context is not None
|
|
229
|
+
page = _session.ensure_line_page(self._context, self.settings, self._browser)
|
|
230
|
+
state = _session.wait_ready(page, self.settings)
|
|
231
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
232
|
+
f.write(page.content())
|
|
233
|
+
return state
|
|
234
|
+
|
|
235
|
+
def dump_room(self, ref: int | str | Room, path: str = "line_room.html") -> Room:
|
|
236
|
+
"""Open a room then save its DOM for message-selector tuning."""
|
|
237
|
+
room = self.open_room(ref)
|
|
238
|
+
assert self._page is not None
|
|
239
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
240
|
+
f.write(self._page.content())
|
|
241
|
+
return room
|
|
242
|
+
|
|
243
|
+
def save_rooms(
|
|
244
|
+
self,
|
|
245
|
+
path: str = "rooms.json",
|
|
246
|
+
unread_only: bool = False,
|
|
247
|
+
query: str | None = None,
|
|
248
|
+
) -> str:
|
|
249
|
+
"""Fetch rooms and save JSON. The only room method that writes files."""
|
|
250
|
+
rooms = self.list_rooms(unread_only=unread_only, query=query)
|
|
251
|
+
_storage.save_json(path, {"rooms": [asdict(r) for r in rooms]})
|
|
252
|
+
return path
|
|
253
|
+
|
|
254
|
+
def save_messages(
|
|
255
|
+
self,
|
|
256
|
+
ref: int | str | Room,
|
|
257
|
+
limit: int = 5,
|
|
258
|
+
date: str | None = None,
|
|
259
|
+
date_from: str | None = None,
|
|
260
|
+
date_to: str | None = None,
|
|
261
|
+
time_from: str | None = None,
|
|
262
|
+
time_to: str | None = None,
|
|
263
|
+
sender: str | None = None,
|
|
264
|
+
keyword: str | None = None,
|
|
265
|
+
media_dir: str | None = None,
|
|
266
|
+
include_media_data: bool = False,
|
|
267
|
+
path: str | None = None,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Open room, fetch messages, save JSON. The only message method that writes files."""
|
|
270
|
+
from datetime import datetime, timezone
|
|
271
|
+
|
|
272
|
+
room = self.open_room(ref)
|
|
273
|
+
msgs = self.get_messages(
|
|
274
|
+
limit=limit, date=date, date_from=date_from, date_to=date_to,
|
|
275
|
+
time_from=time_from, time_to=time_to, sender=sender, keyword=keyword,
|
|
276
|
+
media_dir=media_dir, include_media_data=include_media_data,
|
|
277
|
+
)
|
|
278
|
+
out = path or f"messages_{room.index}.json"
|
|
279
|
+
_storage.save_json(out, {
|
|
280
|
+
"room": asdict(room),
|
|
281
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
282
|
+
"messages": [asdict(m) for m in msgs],
|
|
283
|
+
})
|
|
284
|
+
return out
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
__all__ = ["LineClient", "LineError"]
|
line_ext_msg/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Typed errors so callers (CLI, MCP) branch on kind, not message text."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LineError(Exception):
|
|
5
|
+
"""Base for all line-ext-msg failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChromeNotReady(LineError):
|
|
9
|
+
"""Debug Chrome could not be started or reached."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AttachFailed(LineError):
|
|
13
|
+
"""CDP attach to the running Chrome failed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExtensionMissing(LineError):
|
|
17
|
+
"""LINE Extension is not installed in the debug profile."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AppNotReady(LineError):
|
|
21
|
+
"""LINE SPA did not finish rendering in time."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoginRequired(LineError):
|
|
25
|
+
"""Login screen is showing; user must log in first."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RoomNotFound(LineError):
|
|
29
|
+
"""No visible room matches the given reference."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, ref: object, available: list[str]):
|
|
32
|
+
self.ref = ref
|
|
33
|
+
self.available = available
|
|
34
|
+
super().__init__(f"ไม่เจอห้อง '{ref}' (มี: {', '.join(available) or '—'})")
|