linkdigest-mcp 0.2.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.
linkdigest/__init__.py ADDED
@@ -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
+ ]
linkdigest/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
linkdigest/_http.py ADDED
@@ -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
linkdigest/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
linkdigest/cli.py ADDED
@@ -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())