line-ext-msg
Pull messages (time + sender + text + date + read count) from the LINE Chrome Extension through Playwright. Ships as a Python library (LineClient) and a thin CLI. It attaches to your real Chrome and your own account, read-only.
Install
pip install line-ext-msg
Google Chrome must be installed. You do not need playwright install: the library attaches to the running Chrome over CDP and never uses the bundled chromium. Windows only for now.
Quick start
from line_ext_msg import LineClient
with LineClient(quiet=True) as line:
line.status(wait_for_login=True) # shows the QR dialog if a login is needed
for room in line.list_rooms(unread_only=True):
print(room.name, room.unread)
for m in line.get_messages("Family", limit=20, keyword="invoice"):
print(m.date, m.sender, m.text)
LineClient is a context manager. On first use it starts (or reuses) the debug Chrome on an isolated profile, and on exit it only detaches: Chrome keeps running so the session survives for the next run.
LineClient at a glance
| Method | Returns | Notes |
|---|---|---|
status(wait_for_login=None, login_timeout_ms=None) |
list[StepResult] |
runs the 5 readiness checks; raises a typed LineError on the first failure |
list_rooms(unread_only=False, query=None) |
Rooms |
list[Room]; query matches the room name substring |
open_room(ref) |
Room |
ref is an index, a data-mid, a name substring, or a Room |
get_messages(room=None, limit=5, ...) |
Messages |
list[Message]; date/time, sender, and keyword filters |
unread_digest() |
Report |
rooms with unread > 0, as room dicts |
unread_full(date=None, limit_per_room=20) |
Report |
unread rooms with their messages (date=None means today) |
search_all(keyword, date_from=None, date_to=None, rooms=None, limit_per_room=100) |
Report |
every room that matched, one room at a time |
dump_page() |
Dom |
raw chats DOM for selector tuning |
dump_room(ref) |
Dom |
opens the room and returns its DOM |
probe_session() |
Probe |
redacted storage probe (key names and lengths only) |
clear_session(backup=True) |
dict |
wipes the LINE session, keeps the extension |
close() |
None |
detaches CDP; Chrome keeps running |
get_messages also accepts date, date_from, date_to, time_from, time_to, sender, keyword, with_media, and scroll.
Results and saving
Queries never touch disk. They return a small result type that subclasses the plain builtin (Rooms and Messages are lists, Report is a list of dicts, Probe is a dict, Dom is a str) and adds .save(path), which writes the file and returns the path. Messages also carries .room and a .download_media(dir) helper.
rooms = line.list_rooms(unread_only=True) # no I/O
rooms.save("session/rooms.json") # writes {"rooms": [...]}
msgs = line.get_messages("Family", with_media=True) # images in memory
msgs = msgs.download_media("session/media") # writes the image files
msgs.save("session/messages.json") # writes room + fetched_at + messages
Upgrading from 1.x: the old save_rooms, save_messages, and save_probe methods are gone, and get_messages no longer takes media_dir or include_media_data. Call .save(path) on the result instead, and move image fetching to with_media=True plus download_media(dir).
Recipes
Filter by date and keyword, then count senders:
from line_ext_msg import LineClient, sender_stats
with LineClient(quiet=True) as line:
line.status()
msgs = line.get_messages("Work", date_from="2026-09-01", keyword="invoice")
for row in sender_stats(msgs):
print(row["sender"], row["count"])
Download image bubbles and embed them as data URIs (useful for AI pipelines):
with LineClient(quiet=True) as line:
line.status()
msgs = line.get_messages("Family", limit=50, with_media=True)
msgs = msgs.download_media("media", include_data=True)
for m in msgs:
if m.type == "image":
print(m.media, len(m.media_data)) # local path and data URI
Save results to JSON (.save is the only step with side effects on disk):
with LineClient(quiet=True) as line:
line.status()
rooms_path = line.list_rooms(unread_only=True).save("session/rooms.json")
msgs_path = line.get_messages("Family", limit=100).save("session/messages.json")
print(rooms_path, msgs_path)
Handle failures with typed errors:
from line_ext_msg import LineClient, LineError, LoginRequired, RoomNotFound
with LineClient(quiet=True) as line:
try:
line.status()
room = line.open_room("Family")
except LoginRequired:
... # not logged in; see Authentication and session
except RoomNotFound as e:
print(e.available) # the room names that were visible
except LineError as e:
... # any other typed failure
Authentication and session
status() runs five checks (Chrome, CDP attach, extension, page ready, login) and raises a typed error on the first failure: ChromeNotReady, AttachFailed, ExtensionMissing, AppNotReady, LoginRequired, or QrDialogFailed.
- With
quiet=True(recommended for a service) it does not block: if no one is logged in it raisesLoginRequiredright away. - With
status(wait_for_login=True)it shows the QR in a small Tk dialog on the machine, waits until you scan or close it, and asks for the PIN code on the phone when LINE requires it. Uselogin_timeout_msto bound the wait.
The dialog is currently the only login UI: there is no callback yet for an embedding app to fetch the QR image or the PIN and render it itself. A headless service should either keep a logged-in Chrome running, or run once interactively to log in and then reuse that instance.
The LINE session is tied to the running Chrome process, not to disk. The token stays in Local Storage (lcs_secure_<mid>, about 3.2 KB), but the key that decrypts it lives in the extension's sandboxed ltsmSandbox.html, which has no persistent storage, so a fresh Chrome asks for the QR again. The library therefore never restarts a running Chrome just to match a preferred mode: a live instance is reused and stays logged in, so you scan the QR once per Chrome lifetime. Closing Chrome or rebooting requires a new scan. clear_session() wipes the session on purpose.
Logging
The library emits nothing on import (it carries a NullHandler). To see progress and diagnostics, configure the package logger:
import logging
from line_ext_msg.output.logging import configure
configure(logging.INFO) # attaches a stderr handler to the "line_ext_msg" logger
Or attach your own handlers to the line_ext_msg logger. The CLI configures it for you and adds --verbose (DEBUG), --quiet-log (WARNING and above), --log-level {debug,info,warning,error}, and --log-file PATH. Results go to stdout; logs go to stderr.
Models and JSON
Room (frozen dataclass): index, id (data-mid), name, unread, last_preview, last_time.
Message (frozen dataclass): id, date (YYYY-MM-DD), ts (ISO 8601), sender, from_me, type (text / sticker / image / system / file), text, read_count, media (local path), media_data (data URI, opt-in).
StepResult (frozen dataclass): name, passed, detail. Both models serialize with dataclasses.asdict.
Messages.save writes this shape (room and fetched_at only when the result knows its room):
{
"room": { "index": 0, "id": "CXX...", "name": "Family", "unread": 3,
"last_preview": "did you eat", "last_time": "8:13 AM" },
"fetched_at": "2026-09-12T01:00:00+00:00",
"messages": [
{ "id": "0178...", "date": "2026-09-12", "ts": "2026-09-12T08:13:00",
"sender": "Mom", "from_me": false, "type": "text",
"text": "did you eat", "read_count": null }
]
}
unread_digest, unread_full, and search_all return a Report (a list of dicts: room dicts, and {"room": ..., "messages": [...]} for the latter two), not models.
Settings
All knobs live in the frozen Settings dataclass and can be set in code or through environment variables.
from line_ext_msg import LineClient, Settings
settings = Settings(headless=True, qr_zoom=3, quiet=True)
with LineClient(settings) as line:
...
| Environment variable | Default | Meaning |
|---|---|---|
LINE_EXT_MSG_PROFILE |
%LOCALAPPDATA%\line-chrome-debug |
isolated debug profile |
LINE_EXT_MSG_PORT |
9222 |
CDP debug port |
LINE_EXT_MSG_HEADLESS |
1 |
run Chrome without a window |
LINE_EXT_MSG_QUIET |
off | do not wait, keep the checklist silent |
LINE_EXT_MSG_QR_ZOOM |
2 |
QR dialog zoom (1-4) |
LINE_EXT_MSG_DIALOG_TITLE |
LINE |
window title and header text of the QR dialog |
LINE_EXT_MSG_QR_READY_MS |
20000 |
how long to wait for the QR canvas |
LINE_EXT_MSG_ROOMS_SCROLL_MS |
4000 |
room list scroll budget |
LINE_EXT_MSG_MSGS_SCROLL_MS |
8000 |
message backfill scroll budget |
LINE_EXT_MSG_LOGIN_WAIT_MS |
300000 |
login wait for the headed fallback |
LINE_EXT_MSG_READY_MS |
30000 |
app render timeout |
LINE_EXT_MSG_SELECTOR_MS |
15000 |
selector wait timeout |
LINE_EXT_MSG_OPEN_MS |
3000 |
fallback wait after opening a room |
LINE_EXT_MSG_STOP_GRACEFUL_MS |
3000 |
how long a graceful Chrome close may take before the force kill |
LINE_EXT_MSG_READY_SETTLE_MS |
200 |
extra settle after the app looks ready |
CLI
The same client is exposed as a thin CLI. Results print to stdout and progress goes to stderr.
line-ext-msg # pick a room in the terminal, print to screen only
line-ext-msg --save # also write session/rooms.json and session/messages_<index>.json
line-ext-msg --unread # only rooms with unread messages
line-ext-msg --search "invoice" # search every room and summarise the hits
line-ext-msg --status # check Chrome and login, then stop (keepalive)
line-ext-msg --verbose # DEBUG logs
line-ext-msg --help # full flag list
On first run the CLI starts Chrome on the isolated profile. If the LINE extension is missing it opens a headed window at the Web Store, waits until the extension is installed, then returns to headless by itself. When a QR scan is needed it captures the QR and shows it in a centered LINE dialog (zoom via LINE_EXT_MSG_QR_ZOOM), waits until you scan or close it, and shows the PIN code when LINE asks for one. --clear-session wipes the session (asks for confirmation).
Limitations
- Room and message lists are virtualized; the library scrolls to load more within a time budget. Disable with
scroll=Falseor set the scroll budget to 0. - Only what the UI renders is readable; there is no per-message read API.
unread_digest,unread_full, andsearch_allreturn aReport(list of dicts), not models.download_mediaonly writes whatwith_media=Truealready fetched; it does not re-open the room.- The QR dialog is the only login UI; there is no callback for an app to render the QR itself.
- Debug Chrome starts with
--disable-notificationsand--hide-crash-restore-bubble, so web and push notifications stay off and the restore bubble never appears after a force kill. Toasts an extension raises throughchrome.notificationsare not covered. - The session is tied to the running Chrome process, so keep Chrome alive to avoid scanning again.
from_meis a heuristic (no username means your own message) and is not yet confirmed with a dump that contains your own messages.- Windows only for now: Chrome discovery and process control use Windows paths and PowerShell.
Project layout
The package is split into layers with one-way imports and no cycles.
src/line_ext_msg/
config/ settings, constants, and default paths (no I/O)
domain/ models, typed errors, pure filters (no browser)
browser/ everything that touches Chrome/Playwright: process, CDP, login, mode, JS
scraper/ DOM to models: scroll, extract, media, rooms, messages
output/ JSON and text storage, checklist logger, logging setup
results.py savable query results: Rooms, Messages, Report, Probe, Dom
service/ LineClient facade, readiness, diagnostics, QR dialog
cli.py entry point
Each layer may import only the ones listed below. domain is the shared
vocabulary (models and typed errors), so every layer may import it.
| Layer | May import |
|---|---|
config |
nothing |
domain |
nothing |
output |
config, domain |
results |
domain, output |
browser |
config, domain |
scraper |
config, domain, browser, results |
service |
every layer above |
cli |
service, config, domain, output |
results is the savable result layer, so scraper reaches output (the file
writers) through it.
The public API is only line_ext_msg/__init__.py (LineClient, Room, Message, StepResult, Rooms, Messages, Report, Probe, Dom, Settings, sender_stats, and the typed errors). Subpackages are implementation details and may change without notice.
Development
uv sync --extra dev
uv run ruff check src tests
uv run mypy
uv run pytest
uv build
Troubleshooting
If rooms cannot be read after a LINE UI update, save the DOM and send it to tune the selectors:
line-ext-msg --dump # chats DOM -> session/dumps/line_dom.html
line-ext-msg --dump-room 0 # room DOM -> session/dumps/line_room.html
# selectors live in src/line_ext_msg/config/selectors.py
Add --debug-qr when the QR capture fails; it logs redacted login-page diagnostics.
Metadata
Release files for line-ext-msg 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| line_ext_msg-2.0.0.tar.gz | 122.9 kB | Without provenance | Details for line_ext_msg-2.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| line_ext_msg-2.0.0-py3-none-any.whl | Python 3 | none | any | Details for line_ext_msg-2.0.0-py3-none-any.whl |
Total release size: 188.3 kB
Release files / line_ext_msg-2.0.0.tar.gz
| Download URL | line_ext_msg-2.0.0.tar.gz |
|---|---|
| Size | 122.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d1c523d4b18ef30b3f678c0f1db844fd249bf14068e502f6e3df28dcf7a04346
|
|
BLAKE2b-256 checksum How to use checksums |
8c4e326b0930b8f631325565093283a521e5ffdd3f54470de38c68dc023f4c60
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / line_ext_msg-2.0.0-py3-none-any.whl
| Download URL | line_ext_msg-2.0.0-py3-none-any.whl |
|---|---|
| Size | 65.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aa94969e7abc5b6a4e2e7a61c3af23f2f09827570cedc8c6b1cf7b5737774a98
|
|
BLAKE2b-256 checksum How to use checksums |
9f3910f7fc827cd1435c8ec2298b8ce234682fb73308e4c4a12721f8304f6fba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.18 {"installer":{"name":"uv","version":"0.11.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for line-ext-msg 2.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.