linkdigest-mcp 0.2.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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ .pytest_cache/
6
+ dist/
7
+ build/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LinkDigest
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,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: linkdigest-mcp
3
+ Version: 0.2.0
4
+ Summary: 抖音、小红书、TikTok、YouTube、X 链接转 AI 可读文本 — Python SDK, CLI and stdio MCP server for the hosted LinkDigest API
5
+ Project-URL: Homepage, https://linkdigest.dev
6
+ Project-URL: Documentation, https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.zh-CN.md
7
+ Project-URL: Repository, https://github.com/jcaiagent7143-ui/linkdigest-mcp
8
+ Project-URL: Issues, https://github.com/jcaiagent7143-ui/linkdigest-mcp/issues
9
+ Author-email: LinkDigest <support@linkdigest.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: douyin,mcp,mcp-server,ocr,rednote,tiktok,transcript,xiaohongshu,youtube,小红书,抖音,文案提取,爆款拆解
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Natural Language :: Chinese (Simplified)
17
+ Classifier: Natural Language :: English
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Multimedia :: Video
27
+ Classifier: Topic :: Text Processing
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.10
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7; extra == 'test'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # linkdigest-mcp (Python)
35
+
36
+ <!-- mcp-name: dev.linkdigest/linkdigest -->
37
+
38
+ 把抖音、小红书、TikTok、YouTube、X 链接变成 AI 能读的文本:逐字稿、带大致时间的画面文字、
39
+ 每张图的描述和 OCR、要点,可选爆款拆解(breakdown)和翻译。
40
+
41
+ Turn a Douyin, Xiaohongshu, TikTok, YouTube or X link into text an LLM can read.
42
+
43
+ This package is a thin client for the hosted service at [linkdigest.dev](https://linkdigest.dev):
44
+ nothing is downloaded or transcribed on your machine, and it has **no dependencies**
45
+ (Python 3.10+, standard library only). It installs three things:
46
+
47
+ | | |
48
+ |---|---|
49
+ | `from linkdigest import LinkDigest` | Python SDK — waits for long videos for you (`GET /api/v1/digest/{jobId}?wait=20`) and raises typed errors (402 carries a pay link) |
50
+ | `linkdigest <url>` | command line — Markdown to stdout, `--json`, `--breakdown`, `--translate en` |
51
+ | `linkdigest-mcp` | stdio MCP server — the `digest_url` tool, forwarded to `https://linkdigest.dev/mcp` with your key |
52
+
53
+ Install from this repo (the package is **not on PyPI yet**; a `linkdigest-mcp` package on PyPI
54
+ is not ours until this README says so):
55
+
56
+ ```bash
57
+ pip install "git+https://github.com/jcaiagent7143-ui/linkdigest-mcp#subdirectory=python"
58
+ ```
59
+
60
+ All three read the API key from `LINKDIGEST_API_KEY`. Issue one at
61
+ [linkdigest.dev/app/keys](https://linkdigest.dev/app/keys) — 10 free credits on sign-up, no card.
62
+
63
+ ```python
64
+ from linkdigest import LinkDigest
65
+
66
+ ld = LinkDigest() # LINKDIGEST_API_KEY
67
+ d = ld.digest("https://v.douyin.com/xxxx/", breakdown=True)
68
+ print(d.title, d.credits)
69
+ print(d.markdown)
70
+ ```
71
+
72
+ ```bash
73
+ linkdigest "https://xhslink.com/o/xxxx" --breakdown
74
+ ```
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "linkdigest": {
80
+ "command": "uvx",
81
+ "args": ["--from", "git+https://github.com/jcaiagent7143-ui/linkdigest-mcp#subdirectory=python", "linkdigest-mcp"],
82
+ "env": { "LINKDIGEST_API_KEY": "ld_live_..." }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Full documentation (中文): [README.zh-CN.md](https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.zh-CN.md)
89
+ · English: [README.md](https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.md)
90
+
91
+ MIT licensed.
@@ -0,0 +1,58 @@
1
+ # linkdigest-mcp (Python)
2
+
3
+ <!-- mcp-name: dev.linkdigest/linkdigest -->
4
+
5
+ 把抖音、小红书、TikTok、YouTube、X 链接变成 AI 能读的文本:逐字稿、带大致时间的画面文字、
6
+ 每张图的描述和 OCR、要点,可选爆款拆解(breakdown)和翻译。
7
+
8
+ Turn a Douyin, Xiaohongshu, TikTok, YouTube or X link into text an LLM can read.
9
+
10
+ This package is a thin client for the hosted service at [linkdigest.dev](https://linkdigest.dev):
11
+ nothing is downloaded or transcribed on your machine, and it has **no dependencies**
12
+ (Python 3.10+, standard library only). It installs three things:
13
+
14
+ | | |
15
+ |---|---|
16
+ | `from linkdigest import LinkDigest` | Python SDK — waits for long videos for you (`GET /api/v1/digest/{jobId}?wait=20`) and raises typed errors (402 carries a pay link) |
17
+ | `linkdigest <url>` | command line — Markdown to stdout, `--json`, `--breakdown`, `--translate en` |
18
+ | `linkdigest-mcp` | stdio MCP server — the `digest_url` tool, forwarded to `https://linkdigest.dev/mcp` with your key |
19
+
20
+ Install from this repo (the package is **not on PyPI yet**; a `linkdigest-mcp` package on PyPI
21
+ is not ours until this README says so):
22
+
23
+ ```bash
24
+ pip install "git+https://github.com/jcaiagent7143-ui/linkdigest-mcp#subdirectory=python"
25
+ ```
26
+
27
+ All three read the API key from `LINKDIGEST_API_KEY`. Issue one at
28
+ [linkdigest.dev/app/keys](https://linkdigest.dev/app/keys) — 10 free credits on sign-up, no card.
29
+
30
+ ```python
31
+ from linkdigest import LinkDigest
32
+
33
+ ld = LinkDigest() # LINKDIGEST_API_KEY
34
+ d = ld.digest("https://v.douyin.com/xxxx/", breakdown=True)
35
+ print(d.title, d.credits)
36
+ print(d.markdown)
37
+ ```
38
+
39
+ ```bash
40
+ linkdigest "https://xhslink.com/o/xxxx" --breakdown
41
+ ```
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "linkdigest": {
47
+ "command": "uvx",
48
+ "args": ["--from", "git+https://github.com/jcaiagent7143-ui/linkdigest-mcp#subdirectory=python", "linkdigest-mcp"],
49
+ "env": { "LINKDIGEST_API_KEY": "ld_live_..." }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Full documentation (中文): [README.zh-CN.md](https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.zh-CN.md)
56
+ · English: [README.md](https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.md)
57
+
58
+ MIT licensed.
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # The distribution is `linkdigest-mcp` so that, once it is published on PyPI,
7
+ # `uvx linkdigest-mcp` resolves to it. Until then install from git:
8
+ # uvx --from "git+https://github.com/jcaiagent7143-ui/linkdigest-mcp#subdirectory=python" linkdigest-mcp
9
+ # The import package is `linkdigest` (`from linkdigest import LinkDigest`).
10
+ name = "linkdigest-mcp"
11
+ dynamic = ["version"]
12
+ description = "抖音、小红书、TikTok、YouTube、X 链接转 AI 可读文本 — Python SDK, CLI and stdio MCP server for the hosted LinkDigest API"
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ license = "MIT"
16
+ license-files = ["LICENSE"]
17
+ authors = [{ name = "LinkDigest", email = "support@linkdigest.dev" }]
18
+ # No runtime dependencies: HTTP is the standard library's urllib.
19
+ dependencies = []
20
+ keywords = [
21
+ "mcp",
22
+ "mcp-server",
23
+ "xiaohongshu",
24
+ "rednote",
25
+ "douyin",
26
+ "tiktok",
27
+ "youtube",
28
+ "transcript",
29
+ "ocr",
30
+ "小红书",
31
+ "抖音",
32
+ "文案提取",
33
+ "爆款拆解",
34
+ ]
35
+ classifiers = [
36
+ "Development Status :: 4 - Beta",
37
+ "Environment :: Console",
38
+ "Intended Audience :: Developers",
39
+ "Natural Language :: Chinese (Simplified)",
40
+ "Natural Language :: English",
41
+ "Operating System :: OS Independent",
42
+ "Programming Language :: Python :: 3",
43
+ "Programming Language :: Python :: 3 :: Only",
44
+ "Programming Language :: Python :: 3.10",
45
+ "Programming Language :: Python :: 3.11",
46
+ "Programming Language :: Python :: 3.12",
47
+ "Programming Language :: Python :: 3.13",
48
+ "Topic :: Internet :: WWW/HTTP",
49
+ "Topic :: Multimedia :: Video",
50
+ "Topic :: Text Processing",
51
+ "Typing :: Typed",
52
+ ]
53
+
54
+ [project.urls]
55
+ Homepage = "https://linkdigest.dev"
56
+ Documentation = "https://github.com/jcaiagent7143-ui/linkdigest-mcp/blob/main/README.zh-CN.md"
57
+ Repository = "https://github.com/jcaiagent7143-ui/linkdigest-mcp"
58
+ Issues = "https://github.com/jcaiagent7143-ui/linkdigest-mcp/issues"
59
+
60
+ [project.scripts]
61
+ linkdigest = "linkdigest.cli:main"
62
+ linkdigest-mcp = "linkdigest.mcp_server:main"
63
+
64
+ [project.optional-dependencies]
65
+ test = ["pytest>=7"]
66
+
67
+ [tool.hatch.version]
68
+ path = "src/linkdigest/_version.py"
69
+
70
+ [tool.hatch.build.targets.wheel]
71
+ packages = ["src/linkdigest"]
72
+
73
+ [tool.hatch.build.targets.sdist]
74
+ include = ["src/linkdigest", "tests", "README.md", "LICENSE", "pyproject.toml"]
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+ markers = [
79
+ "live: calls the real https://linkdigest.dev/mcp — tools/list only, no API key, no credits. Skip offline with -m 'not live'.",
80
+ ]
@@ -0,0 +1,40 @@
1
+ """LinkDigest: social links (抖音, 小红书, TikTok, YouTube, X) as text an LLM can read.
2
+
3
+ from linkdigest import LinkDigest
4
+
5
+ ld = LinkDigest() # reads LINKDIGEST_API_KEY; get one at https://linkdigest.dev/app/keys
6
+ d = ld.digest("https://v.douyin.com/xxxx/", breakdown=True)
7
+ print(d.title, d.credits)
8
+ print(d.markdown)
9
+
10
+ Also installed: the `linkdigest` command line and the `linkdigest-mcp` stdio MCP server.
11
+ """
12
+
13
+ from ._version import __version__
14
+ from .client import Digest, LinkDigest
15
+ from .errors import (
16
+ AuthenticationError,
17
+ InvalidRequestError,
18
+ JobPendingError,
19
+ LinkDigestError,
20
+ NetworkError,
21
+ PaymentRequiredError,
22
+ RateLimitError,
23
+ ServerError,
24
+ UnreadableLinkError,
25
+ )
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ "LinkDigest",
30
+ "Digest",
31
+ "LinkDigestError",
32
+ "AuthenticationError",
33
+ "InvalidRequestError",
34
+ "JobPendingError",
35
+ "NetworkError",
36
+ "PaymentRequiredError",
37
+ "RateLimitError",
38
+ "ServerError",
39
+ "UnreadableLinkError",
40
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -0,0 +1,123 @@
1
+ """The one place this package touches the network.
2
+
3
+ Everything else talks to a `Transport`: a callable that takes a `Request` and
4
+ returns a `Response`. The default one is the standard library's urllib, so the
5
+ package installs with no dependencies; tests pass a fake one instead.
6
+
7
+ urllib honours HTTPS_PROXY / HTTP_PROXY / NO_PROXY from the environment, so a
8
+ proxy works without configuration here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import socket
16
+ import ssl
17
+ import urllib.error
18
+ import urllib.request
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Callable, Dict, Optional
21
+
22
+ from ._version import __version__
23
+ from .errors import NetworkError
24
+
25
+ USER_AGENT = f"linkdigest-python/{__version__} (+https://github.com/jcaiagent7143-ui/linkdigest-mcp)"
26
+
27
+
28
+ @dataclass
29
+ class Request:
30
+ method: str
31
+ url: str
32
+ headers: Dict[str, str] = field(default_factory=dict)
33
+ body: Optional[bytes] = None
34
+ timeout: float = 60.0
35
+
36
+
37
+ @dataclass
38
+ class Response:
39
+ status: int
40
+ # Header names are lower-cased so lookups do not depend on the server's casing.
41
+ headers: Dict[str, str] = field(default_factory=dict)
42
+ body: bytes = b""
43
+
44
+ @property
45
+ def text(self) -> str:
46
+ return self.body.decode("utf-8", errors="replace")
47
+
48
+ def json(self) -> Any:
49
+ return json.loads(self.body.decode("utf-8"))
50
+
51
+ def header(self, name: str, default: str = "") -> str:
52
+ return self.headers.get(name.lower(), default)
53
+
54
+
55
+ Transport = Callable[[Request], Response]
56
+
57
+ # Where operating systems keep their CA bundle, for a Python that ships without
58
+ # one. The python.org installer on macOS is the common case: until "Install
59
+ # Certificates.command" is run, every HTTPS request fails with
60
+ # CERTIFICATE_VERIFY_FAILED, and urllib has no certifi to fall back on.
61
+ _CA_BUNDLES = (
62
+ "/etc/ssl/cert.pem", # macOS, Alpine, Arch
63
+ "/etc/ssl/certs/ca-certificates.crt", # Debian, Ubuntu
64
+ "/etc/pki/tls/certs/ca-bundle.crt", # Fedora, RHEL
65
+ "/etc/ssl/ca-bundle.pem", # openSUSE
66
+ "/opt/homebrew/etc/ca-certificates/cert.pem",
67
+ "/usr/local/etc/ca-certificates/cert.pem",
68
+ )
69
+ _ssl_context: Optional[ssl.SSLContext] = None
70
+
71
+
72
+ def ssl_context() -> ssl.SSLContext:
73
+ """The default verifying context, plus a CA bundle if Python found none of its own."""
74
+ global _ssl_context
75
+ if _ssl_context is None:
76
+ ctx = ssl.create_default_context()
77
+ if not ctx.cert_store_stats().get("x509_ca"):
78
+ candidates = []
79
+ try:
80
+ import certifi # type: ignore[import-not-found] # optional, used when present
81
+
82
+ candidates.append(certifi.where())
83
+ except ImportError:
84
+ pass
85
+ candidates.extend(_CA_BUNDLES)
86
+ for path in candidates:
87
+ if os.path.isfile(path):
88
+ try:
89
+ ctx.load_verify_locations(cafile=path)
90
+ break
91
+ except (OSError, ssl.SSLError):
92
+ continue
93
+ _ssl_context = ctx
94
+ return _ssl_context
95
+
96
+
97
+ def urllib_transport(req: Request) -> Response:
98
+ """Send one request with urllib. 4xx/5xx are returned, not raised."""
99
+ headers = {"User-Agent": USER_AGENT, **req.headers}
100
+ r = urllib.request.Request(req.url, data=req.body, headers=headers, method=req.method)
101
+ try:
102
+ context = ssl_context() if req.url.startswith("https:") else None
103
+ with urllib.request.urlopen(r, timeout=req.timeout, context=context) as resp:
104
+ return Response(
105
+ status=resp.status,
106
+ headers={k.lower(): v for k, v in resp.headers.items()},
107
+ body=resp.read(),
108
+ )
109
+ except urllib.error.HTTPError as e:
110
+ # An HTTP error status is an answer from the server, not a failure to
111
+ # reach it: hand it back so the caller can read the JSON error body.
112
+ try:
113
+ body = e.read()
114
+ except OSError:
115
+ body = b""
116
+ return Response(
117
+ status=e.code,
118
+ headers={k.lower(): v for k, v in (e.headers or {}).items()},
119
+ body=body,
120
+ )
121
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError, OSError) as e:
122
+ reason = getattr(e, "reason", None) or e
123
+ raise NetworkError(f"could not reach {req.url}: {reason}") from e
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,144 @@
1
+ """linkdigest — read a social link from the terminal.
2
+
3
+ export LINKDIGEST_API_KEY=ld_live_...
4
+ linkdigest "https://v.douyin.com/xxxx/" # Markdown to stdout
5
+ linkdigest "https://xhslink.com/o/xxxx" --breakdown # + 爆款拆解 (+1 credit)
6
+ linkdigest "<link>" --translate en --json > post.json # structured JSON
7
+ pbpaste | linkdigest - # the whole share text works too
8
+ linkdigest --job <job_id> # collect a job that was still running
9
+
10
+ Status (credits spent, cache hits, a job still running) goes to stderr, so
11
+ stdout stays clean for pipes.
12
+
13
+ Exit codes: 0 ok · 1 error · 2 usage or no key · 3 out of credits (prints the
14
+ pay link) · 4 still running after --max-wait (prints the job id).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from typing import List, Optional, TextIO
23
+
24
+ from ._version import __version__
25
+ from .client import API_KEY_ENV, DEFAULT_MAX_WAIT, LinkDigest
26
+ from .errors import (
27
+ KEYS_URL,
28
+ AuthenticationError,
29
+ JobPendingError,
30
+ LinkDigestError,
31
+ PaymentRequiredError,
32
+ )
33
+
34
+ EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_PAYMENT, EXIT_PENDING = 0, 1, 2, 3, 4
35
+
36
+
37
+ def _parser() -> argparse.ArgumentParser:
38
+ p = argparse.ArgumentParser(
39
+ prog="linkdigest",
40
+ description=(
41
+ "Turn a Xiaohongshu, Douyin, TikTok, YouTube or X link into text: transcript, "
42
+ "on-screen text, image descriptions and OCR, key points; optionally a viral "
43
+ "breakdown (爆款拆解) and a translation. Uses the hosted API at linkdigest.dev "
44
+ f"with the key in ${API_KEY_ENV}."
45
+ ),
46
+ epilog=f"Get a key: {KEYS_URL}",
47
+ )
48
+ p.add_argument("url", nargs="?", help="the link, or the whole share text containing it; '-' reads stdin")
49
+ p.add_argument("--breakdown", action="store_true", help="also take the post apart: hook, timed beats, template (+1 credit)")
50
+ p.add_argument("--translate", metavar="LANG", help="also translate, e.g. en, ja, zh-CN (+1 credit)")
51
+ p.add_argument("--json", action="store_true", help="print the full JSON instead of Markdown")
52
+ p.add_argument("--partial-ok", action="store_true", help="read the opening minutes of a video that is too long, instead of refusing it")
53
+ p.add_argument("--depth", choices=["full", "transcript"], default="full", help="transcript = a long video in full as a transcript, 1 credit per 2 minutes (not YouTube)")
54
+ p.add_argument("--max-credits", type=int, metavar="N", help="refuse (free of charge) if this link would cost more than N credits")
55
+ p.add_argument("--job", metavar="JOB_ID", help="collect a digest that was still running, instead of reading a url")
56
+ p.add_argument("--max-wait", type=float, default=DEFAULT_MAX_WAIT, metavar="SECONDS", help=f"how long to wait for a long job (default {DEFAULT_MAX_WAIT:.0f})")
57
+ p.add_argument("--check-key", action="store_true", help="validate the API key without spending a credit, then exit")
58
+ p.add_argument("--api-key", help=f"API key (default: ${API_KEY_ENV}; prefer the env var, arguments show up in shell history)")
59
+ p.add_argument("--base-url", help="API base URL (default https://linkdigest.dev)")
60
+ p.add_argument("-q", "--quiet", action="store_true", help="no status lines on stderr")
61
+ p.add_argument("--version", action="version", version=f"linkdigest {__version__}")
62
+ return p
63
+
64
+
65
+ def _utf8(stream: TextIO) -> None:
66
+ """Chinese text must survive a Windows pipe (cp936 / cp1252)."""
67
+ enc = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
68
+ if enc != "utf8" and hasattr(stream, "reconfigure"):
69
+ try:
70
+ stream.reconfigure(encoding="utf-8", errors="replace")
71
+ except (ValueError, OSError):
72
+ pass
73
+
74
+
75
+ def main(argv: Optional[List[str]] = None, stdin: Optional[TextIO] = None) -> int:
76
+ args = _parser().parse_args(argv)
77
+ out, err = sys.stdout, sys.stderr
78
+ _utf8(out)
79
+
80
+ def status(line: str) -> None:
81
+ if not args.quiet:
82
+ print(line, file=err, flush=True)
83
+
84
+ url = args.url
85
+ if url == "-":
86
+ url = (stdin or sys.stdin).read()
87
+ if not args.check_key and not args.job and not (url and url.strip()):
88
+ _parser().print_usage(err)
89
+ print("linkdigest: give a link (or '-' to read one from stdin), or --job JOB_ID", file=err)
90
+ return EXIT_USAGE
91
+
92
+ try:
93
+ client = LinkDigest(args.api_key, base_url=args.base_url, max_wait=args.max_wait)
94
+ except AuthenticationError as e:
95
+ print(f"linkdigest: {e}", file=err)
96
+ return EXIT_USAGE
97
+
98
+ def on_pending(job_id: str, stage: Optional[str]) -> None:
99
+ status(f"… still reading (job {job_id}{', ' + stage if stage else ''})")
100
+
101
+ fmt = "json" # always ask for JSON: it carries credits and cached, and raw_markdown
102
+ try:
103
+ if args.check_key:
104
+ info = client.check_key()
105
+ key = info.get("key") or {}
106
+ print(f"key ok: {key.get('label') or ''} {key.get('hint') or ''}".rstrip(), file=out)
107
+ return EXIT_OK
108
+ if args.job:
109
+ digest = client.collect(args.job, fmt, on_pending=on_pending)
110
+ else:
111
+ digest = client.digest(
112
+ url,
113
+ fmt,
114
+ breakdown=args.breakdown,
115
+ translate_to=args.translate,
116
+ partial_ok=args.partial_ok,
117
+ depth=args.depth,
118
+ max_credits=args.max_credits,
119
+ on_pending=on_pending,
120
+ )
121
+ except PaymentRequiredError as e:
122
+ print(f"linkdigest: {e}", file=err)
123
+ return EXIT_PAYMENT
124
+ except JobPendingError as e:
125
+ print(f"linkdigest: {e}", file=err)
126
+ print(f"collect it with: linkdigest --job {e.job_id}", file=err)
127
+ return EXIT_PENDING
128
+ except LinkDigestError as e:
129
+ print(f"linkdigest: {e}", file=err)
130
+ return EXIT_ERROR
131
+
132
+ if args.json:
133
+ print(json.dumps(digest.data, ensure_ascii=False, indent=2), file=out)
134
+ else:
135
+ print(digest.markdown.rstrip("\n"), file=out)
136
+ cost = "cached, 0 credits" if digest.cached else f"{digest.credits} credit(s)"
137
+ status("[linkdigest] " + " · ".join(p for p in (digest.platform, cost) if p))
138
+ for note in digest.degraded:
139
+ status(f"[linkdigest] degraded: {note}")
140
+ return EXIT_OK
141
+
142
+
143
+ if __name__ == "__main__": # pragma: no cover
144
+ sys.exit(main())