Skip to main content

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, waits for the QR by default, then makes sure the chats view is the one rendered; 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)
logout(backup=True) dict logs out of LINE, wipes the 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.

  • status() waits by default: when no one is logged in 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. Use login_timeout_ms to bound the wait.
  • With status(wait_for_login=False) it does not block: if no one is logged in it raises LoginRequired right away.

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 pass wait_for_login=False, so a missing login surfaces as LoginRequired instead of a wait, or keep a logged-in Chrome running.

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. logout() 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": [...], "truncated": ...} for the latter two), not models.

Partial scans

A filtered query has no target row count, so the backfill scroll runs until the oldest message, a date boundary, or the time budget. When the budget runs out first the result is partial, and the library says so instead of letting an empty list read as "no match":

  • search_all and unread_full entries carry truncated: true for those rooms, and a truncated room is listed even when it matched nothing
  • line-ext-msg --search prints which rooms were partial and how to dig deeper
  • Messages.scroll_stop carries the raw reason: need, top, date, budget, disabled, or detached

Raise search_scroll_ms (or pass --search-scroll-budget-s) when a room keeps coming back truncated. It is a lower bound, so raising it past limit * 1000ms (capped at 300s) has no effect; raise limit_per_room for a bigger scan.

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 keep the checklist silent
LINE_EXT_MSG_QR_ZOOM 2 QR dialog zoom (1-4)
LINE_EXT_MSG_DIALOG_TITLE LINE window title 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 8000 room list scroll budget
LINE_EXT_MSG_CHATS_ENSURE_MS 10000 how long to wait for the chat list after switching to the chats view
LINE_EXT_MSG_MSGS_SCROLL_MS 8000 message backfill scroll budget
LINE_EXT_MSG_SEARCH_SCROLL_MS 60000 lower bound for the per-room scroll budget when a filter is set (keyword, sender, date); the limit raises it further, so 0 does not disable scrolling
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
LINE_EXT_MSG_DEBUG_ROOMS off log the ids of room rows skipped for having no name

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 --search "invoice" --search-scroll-budget-s 180   # dig deeper per room
line-ext-msg --status               # check Chrome and login, then stop (keepalive)
line-ext-msg --verbose              # DEBUG logs
line-ext-msg logout                 # log out of LINE and wipe the session
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. The logout subcommand wipes the session and asks for confirmation; logout --yes skips the prompt.

Limitations

  • Room and message lists are virtualized; the library scrolls to load more within a time budget. Disable with scroll=False or LINE_EXT_MSG_MSGS_SCROLL_MS=0; those are the only off switches, since a filtered query raises its own budget from search_scroll_ms and the limit.
  • A filtered query stops at the oldest message, a date, or its budget, whichever comes first. Its budget is max(search_scroll_ms, min(limit * 1000s, 300s)), so it is never shorter than the unfiltered one. A search over a long history can report truncated rather than pretending it reached the beginning.
  • Only the chats view is read. The friends and groups view is never used as a room list, so a LINE build that only offers that view raises ChatsViewMissing instead of returning names that are not conversations.
  • Only what the UI renders is readable; there is no per-message read API.
  • unread_digest, unread_full, and search_all return a Report (list of dicts), not models.
  • download_media only writes what with_media=True already 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-notifications and --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 through chrome.notifications are not covered.
  • The session is tied to the running Chrome process, so keep Chrome alive to avoid scanning again.
  • from_me is 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 the room list shows names that are people or groups rather than conversations, or comes back empty, the LINE tab is not on the chats view. status() logs which view it reached and the URL it saw (chats view: navigated, url=...); a ChatsViewMissing error means neither the chats URL nor the nav button produced a chat list. The friends view is never read as a room list, so that mix-up fails instead of returning wrong data.

If the login log repeats login state: unknown after the PIN step, the QR scan succeeded but the page landed on a view the library does not read. The login flow retries the chats route twice on its own and raises AppNotReady pointing at --dump if that does not help, so a run never sits there waiting.

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 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for line-ext-msg 3.0.0
File Size Uploaded Provenance Actions
line_ext_msg-3.0.0.tar.gz 142.5 kB Without provenance Details for line_ext_msg-3.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for line-ext-msg 3.0.0
File Interpreter ABI Platform Actions
line_ext_msg-3.0.0-py3-none-any.whl Python 3 none any Details for line_ext_msg-3.0.0-py3-none-any.whl

Total release size: 214.5 kB

Release files / line_ext_msg-3.0.0.tar.gz

Download URL line_ext_msg-3.0.0.tar.gz
Size 142.5 kB
Tags Source
SHA-256 checksum
How to use checksums
9b347c1e22e1aa8360af80c8c948b7d9d62841f92b1c895fc852b542fa7a062d
BLAKE2b-256 checksum
How to use checksums
bb66f1bedea7724c761ae35b2d75173c8d8b7886633acee3ff4b0c30e3c530fd
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-3.0.0-py3-none-any.whl

Download URL line_ext_msg-3.0.0-py3-none-any.whl
Size 72.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d07cd0a86e6a88fb068687d58cefbe09783367cd3aafd81a1d3fba3a7ecdbbde
BLAKE2b-256 checksum
How to use checksums
11848ea59b72de494bfa6a4e3394dbef4f2e1b6f6c2d60a688a5938cb98d7115
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 3.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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page