line-ext-msg 1.0.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.
- line_ext_msg-1.0.0/.gitignore +17 -0
- line_ext_msg-1.0.0/.python-version +1 -0
- line_ext_msg-1.0.0/CHANGELOG.md +34 -0
- line_ext_msg-1.0.0/LICENSE +21 -0
- line_ext_msg-1.0.0/PKG-INFO +79 -0
- line_ext_msg-1.0.0/README.md +61 -0
- line_ext_msg-1.0.0/examples/01_list_rooms.py +20 -0
- line_ext_msg-1.0.0/examples/02_latest_messages.py +23 -0
- line_ext_msg-1.0.0/examples/03_mcp_style.py +40 -0
- line_ext_msg-1.0.0/pyproject.toml +34 -0
- line_ext_msg-1.0.0/src/line_ext_msg/__init__.py +34 -0
- line_ext_msg-1.0.0/src/line_ext_msg/auth.py +58 -0
- line_ext_msg-1.0.0/src/line_ext_msg/chrome.py +94 -0
- line_ext_msg-1.0.0/src/line_ext_msg/cli.py +99 -0
- line_ext_msg-1.0.0/src/line_ext_msg/client.py +287 -0
- line_ext_msg-1.0.0/src/line_ext_msg/errors.py +34 -0
- line_ext_msg-1.0.0/src/line_ext_msg/messages.py +278 -0
- line_ext_msg-1.0.0/src/line_ext_msg/models.py +40 -0
- line_ext_msg-1.0.0/src/line_ext_msg/progress.py +36 -0
- line_ext_msg-1.0.0/src/line_ext_msg/rooms.py +88 -0
- line_ext_msg-1.0.0/src/line_ext_msg/session.py +168 -0
- line_ext_msg-1.0.0/src/line_ext_msg/settings.py +79 -0
- line_ext_msg-1.0.0/src/line_ext_msg/storage.py +8 -0
- line_ext_msg-1.0.0/tests/__init__.py +1 -0
- line_ext_msg-1.0.0/tests/test_filters.py +47 -0
- line_ext_msg-1.0.0/tests/test_models.py +45 -0
- line_ext_msg-1.0.0/tests/test_progress.py +30 -0
- line_ext_msg-1.0.0/tests/test_storage.py +13 -0
- line_ext_msg-1.0.0/uv.lock +278 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
First stable release: OOP facade, extended schema, filters, search, media.
|
|
6
|
+
|
|
7
|
+
- `Message.media_data`: opt-in data URI (`include_media_data=True`) for MCP/AI image analysis
|
|
8
|
+
- Download image bubbles to `media/` via in-page blob fetch; `Message.media` holds the path
|
|
9
|
+
- CLI shows `[รูปภาพ: path]` / `[สติกเกอร์]` instead of blank lines
|
|
10
|
+
|
|
11
|
+
- Filters: `date_from/date_to`, `time_from/time_to` (`HH:MM`), `sender`, `keyword` in `get_messages`/`save_messages`/CLI
|
|
12
|
+
- `search_all()` keyword search grouped per room, `unread_full()` unread rooms + today's messages
|
|
13
|
+
- `sender_stats()` pure per-sender counts; `apply_filters()` unit-tested without browser
|
|
14
|
+
|
|
15
|
+
- Explicit save: `get_*`/`list_*` never write files; only `save_rooms`/`save_messages` do
|
|
16
|
+
- CLI `--save` flag (default prints to screen only)
|
|
17
|
+
- Removed `export_room` (use `save_messages`)
|
|
18
|
+
|
|
19
|
+
- OOP rewrite: `LineClient` facade owns lifecycle (context manager)
|
|
20
|
+
- Models: `Room{index,id,name,unread,last_preview,last_time}`,
|
|
21
|
+
`Message{id,date,ts,sender,from_me,type,text,read_count}`, `StepResult`
|
|
22
|
+
- Typed errors: `LoginRequired`, `ExtensionMissing`, `RoomNotFound`, ...
|
|
23
|
+
- `Settings` dataclass (env `LINE_EXT_MSG_*`) replaces config constants
|
|
24
|
+
- `open_room` accepts index, data-mid, name substring, or Room
|
|
25
|
+
- `get_messages(room?, limit, date?)`, `unread_digest()`, `export_room()`
|
|
26
|
+
- CLI flags: `--date`, `--unread`; `Steps` supports `quiet`
|
|
27
|
+
- Removed: `browser.py`, `chrome_launcher.py`, `config.py` (now `session.py`, `chrome.py`, `settings.py`)
|
|
28
|
+
|
|
29
|
+
## 0.1.0
|
|
30
|
+
|
|
31
|
+
- Restructured to `src/line_ext_msg` package with `line-ext-msg` CLI entry point
|
|
32
|
+
- Checklist startup: Chrome debug, CDP attach, extension, render, login
|
|
33
|
+
- Room listing, latest-N message export to JSON (timestamp + sender + text)
|
|
34
|
+
- Library API for MCP reuse: `list_rooms`, `get_latest_messages`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ITC6910120
|
|
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,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: line-ext-msg
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pull messages from LINE Chrome Extension via Playwright CDP (own account, read-only)
|
|
5
|
+
Project-URL: Homepage, https://github.com/onlygummy/line-ext-msg
|
|
6
|
+
Author: ITC6910120
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cdp,chrome-extension,line,playwright
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: playwright>=1.40
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# line-ext-msg
|
|
20
|
+
|
|
21
|
+
ดึงข้อความ (เวลา + คนส่ง + ข้อความ + วันที่ + สถานะ) จาก LINE Chrome Extension ผ่าน Playwright แบบเกาะ Chrome ตัวจริง บัญชีของตัวเอง อ่านอย่างเดียว
|
|
22
|
+
|
|
23
|
+
## ติดตั้ง
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
pip install line-ext-msg
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
ต้องมี Google Chrome ในเครื่อง ไม่ต้องรัน `playwright install` เพราะโปรแกรมเกาะ Chrome ที่มีอยู่ผ่าน CDP ไม่ได้ใช้ bundled chromium
|
|
30
|
+
|
|
31
|
+
## ใช้งาน CLI
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
line-ext-msg # เลือกห้องใน Terminal → พิมพ์จออย่างเดียว ไม่สร้างไฟล์
|
|
35
|
+
line-ext-msg --save # + เขียน rooms.json + messages_{index}.json
|
|
36
|
+
line-ext-msg --unread # แสดงเฉพาะห้องที่ไม่อ่าน
|
|
37
|
+
line-ext-msg --limit 10 --date 2026-09-12
|
|
38
|
+
line-ext-msg --date-from 2026-09-01 --date-to 2026-09-12 --keyword "ใบแจ้งหนี้"
|
|
39
|
+
line-ext-msg --search "ใบแจ้งหนี้" # ค้นทุกห้อง สรุปห้องที่เจอ
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
ครั้งแรก: โปรแกรมเปิด Chrome โปรไฟล์แยก `%LOCALAPPDATA%\line-chrome-debug` ให้เอง ติดตั้ง LINE + ล็อกอินครั้งเดียวในหน้าต่างนั้น หลังจากนั้น auto หมด (Chrome 136+ บล็อก `--remote-debugging-port` บนโปรไฟล์หลักโดยตรง จึงต้องใช้โปรไฟล์แยก)
|
|
43
|
+
|
|
44
|
+
## หน้าตาไฟล์ JSON ที่ได้
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"room": { "index": 0, "id": "CXX...", "name": "ครอบครัว", "unread": 3,
|
|
49
|
+
"last_preview": "กินข้าวยัง", "last_time": "8:13 AM" },
|
|
50
|
+
"fetched_at": "2026-09-12T01:00:00+00:00",
|
|
51
|
+
"messages": [
|
|
52
|
+
{ "id": "0178...", "date": "2026-09-12", "ts": "2026-09-12T08:13:00",
|
|
53
|
+
"sender": "แม่", "from_me": false, "type": "text",
|
|
54
|
+
"text": "กินข้าวยัง", "read_count": null }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## ข้อจำกัดที่ควรรู้
|
|
60
|
+
|
|
61
|
+
- อ่านได้เฉพาะแถวที่ render บนจอ (virtualized list) ประวัติย้อนหลังลึกต้อง scroll เพิ่ม (ยังไม่ทำ)
|
|
62
|
+
- ได้เฉพาะที่ UI แสดง ไม่มี API สถานะอ่านรายข้อความ
|
|
63
|
+
- `from_me` เป็น heuristic (ไม่มี username = เราส่ง) ยังไม่ยืนยันด้วย dump ที่มีข้อความของตัวเอง
|
|
64
|
+
|
|
65
|
+
## dev
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
uv sync
|
|
69
|
+
uv run pytest
|
|
70
|
+
uv build
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## ถ้าอ่านห้องไม่ได้ (LINE อัปเดต UI)
|
|
74
|
+
|
|
75
|
+
```powershell
|
|
76
|
+
line-ext-msg --dump # DOM หน้า chats → line_dom.html
|
|
77
|
+
line-ext-msg --dump-room 0 # DOM ในห้อง → line_room.html
|
|
78
|
+
# ส่งไฟล์มาเพื่อจูน selector ใน src/line_ext_msg/settings.py
|
|
79
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# line-ext-msg
|
|
2
|
+
|
|
3
|
+
ดึงข้อความ (เวลา + คนส่ง + ข้อความ + วันที่ + สถานะ) จาก LINE Chrome Extension ผ่าน Playwright แบบเกาะ Chrome ตัวจริง บัญชีของตัวเอง อ่านอย่างเดียว
|
|
4
|
+
|
|
5
|
+
## ติดตั้ง
|
|
6
|
+
|
|
7
|
+
```powershell
|
|
8
|
+
pip install line-ext-msg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
ต้องมี Google Chrome ในเครื่อง ไม่ต้องรัน `playwright install` เพราะโปรแกรมเกาะ Chrome ที่มีอยู่ผ่าน CDP ไม่ได้ใช้ bundled chromium
|
|
12
|
+
|
|
13
|
+
## ใช้งาน CLI
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
line-ext-msg # เลือกห้องใน Terminal → พิมพ์จออย่างเดียว ไม่สร้างไฟล์
|
|
17
|
+
line-ext-msg --save # + เขียน rooms.json + messages_{index}.json
|
|
18
|
+
line-ext-msg --unread # แสดงเฉพาะห้องที่ไม่อ่าน
|
|
19
|
+
line-ext-msg --limit 10 --date 2026-09-12
|
|
20
|
+
line-ext-msg --date-from 2026-09-01 --date-to 2026-09-12 --keyword "ใบแจ้งหนี้"
|
|
21
|
+
line-ext-msg --search "ใบแจ้งหนี้" # ค้นทุกห้อง สรุปห้องที่เจอ
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
ครั้งแรก: โปรแกรมเปิด Chrome โปรไฟล์แยก `%LOCALAPPDATA%\line-chrome-debug` ให้เอง ติดตั้ง LINE + ล็อกอินครั้งเดียวในหน้าต่างนั้น หลังจากนั้น auto หมด (Chrome 136+ บล็อก `--remote-debugging-port` บนโปรไฟล์หลักโดยตรง จึงต้องใช้โปรไฟล์แยก)
|
|
25
|
+
|
|
26
|
+
## หน้าตาไฟล์ JSON ที่ได้
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"room": { "index": 0, "id": "CXX...", "name": "ครอบครัว", "unread": 3,
|
|
31
|
+
"last_preview": "กินข้าวยัง", "last_time": "8:13 AM" },
|
|
32
|
+
"fetched_at": "2026-09-12T01:00:00+00:00",
|
|
33
|
+
"messages": [
|
|
34
|
+
{ "id": "0178...", "date": "2026-09-12", "ts": "2026-09-12T08:13:00",
|
|
35
|
+
"sender": "แม่", "from_me": false, "type": "text",
|
|
36
|
+
"text": "กินข้าวยัง", "read_count": null }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## ข้อจำกัดที่ควรรู้
|
|
42
|
+
|
|
43
|
+
- อ่านได้เฉพาะแถวที่ render บนจอ (virtualized list) ประวัติย้อนหลังลึกต้อง scroll เพิ่ม (ยังไม่ทำ)
|
|
44
|
+
- ได้เฉพาะที่ UI แสดง ไม่มี API สถานะอ่านรายข้อความ
|
|
45
|
+
- `from_me` เป็น heuristic (ไม่มี username = เราส่ง) ยังไม่ยืนยันด้วย dump ที่มีข้อความของตัวเอง
|
|
46
|
+
|
|
47
|
+
## dev
|
|
48
|
+
|
|
49
|
+
```powershell
|
|
50
|
+
uv sync
|
|
51
|
+
uv run pytest
|
|
52
|
+
uv build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## ถ้าอ่านห้องไม่ได้ (LINE อัปเดต UI)
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
line-ext-msg --dump # DOM หน้า chats → line_dom.html
|
|
59
|
+
line-ext-msg --dump-room 0 # DOM ในห้อง → line_room.html
|
|
60
|
+
# ส่งไฟล์มาเพื่อจูน selector ใน src/line_ext_msg/settings.py
|
|
61
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""01 - List all LINE chat rooms.
|
|
2
|
+
|
|
3
|
+
Prerequisites: Chrome + LINE Extension installed + logged in
|
|
4
|
+
(profile %LOCALAPPDATA%\\line-chrome-debug).
|
|
5
|
+
|
|
6
|
+
Run: uv run python examples/01_list_rooms.py
|
|
7
|
+
"""
|
|
8
|
+
from line_ext_msg import LineClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
with LineClient() as line:
|
|
13
|
+
line.status()
|
|
14
|
+
for room in line.list_rooms():
|
|
15
|
+
unread = f" ({room.unread} ไม่อ่าน)" if room.unread else ""
|
|
16
|
+
print(f"[{room.index}] {room.name}{unread}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""02 - Export latest messages of one room to JSON.
|
|
2
|
+
|
|
3
|
+
Run: uv run python examples/02_latest_messages.py --room "ครอบครัว" --limit 5
|
|
4
|
+
"""
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from line_ext_msg import LineClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser()
|
|
12
|
+
parser.add_argument("--room", required=True, help="Room name substring")
|
|
13
|
+
parser.add_argument("--limit", type=int, default=5)
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
|
|
16
|
+
with LineClient() as line:
|
|
17
|
+
line.status()
|
|
18
|
+
out = line.save_messages(args.room, limit=args.limit)
|
|
19
|
+
print(f"บันทึก {out}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""03 - MCP-style function: no CLI, no input(), returns dict.
|
|
2
|
+
|
|
3
|
+
This is the shape an MCP server (separate repo) can import directly:
|
|
4
|
+
from line_ext_msg import LineClient
|
|
5
|
+
|
|
6
|
+
Run: uv run python examples/03_mcp_style.py
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from line_ext_msg import LineClient
|
|
11
|
+
from line_ext_msg.errors import LineError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_messages(room_name: str, limit: int = 5) -> dict:
|
|
15
|
+
"""Return latest messages of the first room matching room_name."""
|
|
16
|
+
try:
|
|
17
|
+
with LineClient(quiet=True) as line:
|
|
18
|
+
line.status()
|
|
19
|
+
room = line.open_room(room_name)
|
|
20
|
+
msgs = line.get_messages(limit=limit)
|
|
21
|
+
return {"ok": True, "room": asdict(room),
|
|
22
|
+
"messages": [asdict(m) for m in msgs]}
|
|
23
|
+
except LineError as e:
|
|
24
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unread_digest() -> dict:
|
|
28
|
+
"""Rooms with unread messages plus latest preview."""
|
|
29
|
+
try:
|
|
30
|
+
with LineClient(quiet=True) as line:
|
|
31
|
+
line.status()
|
|
32
|
+
return {"ok": True, "rooms": line.unread_digest()}
|
|
33
|
+
except LineError as e:
|
|
34
|
+
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
print(json.dumps(get_messages("ครอบครัว"), ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "line-ext-msg"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Pull messages from LINE Chrome Extension via Playwright CDP (own account, read-only)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "ITC6910120" }]
|
|
9
|
+
keywords = ["line", "chrome-extension", "playwright", "cdp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
]
|
|
15
|
+
dependencies = ["playwright>=1.40"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
line-ext-msg = "line_ext_msg.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/onlygummy/line-ext-msg"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/line_ext_msg"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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"
|
|
@@ -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)
|
|
@@ -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()
|