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.
- linkdigest_mcp-0.2.0/.gitignore +7 -0
- linkdigest_mcp-0.2.0/LICENSE +21 -0
- linkdigest_mcp-0.2.0/PKG-INFO +91 -0
- linkdigest_mcp-0.2.0/README.md +58 -0
- linkdigest_mcp-0.2.0/pyproject.toml +80 -0
- linkdigest_mcp-0.2.0/src/linkdigest/__init__.py +40 -0
- linkdigest_mcp-0.2.0/src/linkdigest/__main__.py +4 -0
- linkdigest_mcp-0.2.0/src/linkdigest/_http.py +123 -0
- linkdigest_mcp-0.2.0/src/linkdigest/_version.py +1 -0
- linkdigest_mcp-0.2.0/src/linkdigest/cli.py +144 -0
- linkdigest_mcp-0.2.0/src/linkdigest/client.py +403 -0
- linkdigest_mcp-0.2.0/src/linkdigest/errors.py +116 -0
- linkdigest_mcp-0.2.0/src/linkdigest/mcp_server.py +317 -0
- linkdigest_mcp-0.2.0/src/linkdigest/py.typed +0 -0
- linkdigest_mcp-0.2.0/src/linkdigest/tool.py +139 -0
- linkdigest_mcp-0.2.0/tests/conftest.py +86 -0
- linkdigest_mcp-0.2.0/tests/test_cli.py +104 -0
- linkdigest_mcp-0.2.0/tests/test_client.py +348 -0
- linkdigest_mcp-0.2.0/tests/test_live.py +28 -0
- linkdigest_mcp-0.2.0/tests/test_mcp_server.py +273 -0
|
@@ -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,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())
|