tesseract-agent 0.11.37 → 0.11.38

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.
@@ -174,7 +174,8 @@ class HttpConn:
174
174
 
175
175
  def _post(self, msg: dict, headers: dict | None, timeout: float) -> tuple[int, dict, bytes]:
176
176
  data = json.dumps(msg).encode("utf-8")
177
- h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **self.auth_headers}
177
+ # Our own User-Agent: a server behind Cloudflare refuses Python's default ("Python-urllib", error 1010).
178
+ h = {"User-Agent": "tesseract-mcp-check/1.0", "Content-Type": "application/json", "Accept": "application/json, text/event-stream", **self.auth_headers}
178
179
  if self.session_id:
179
180
  h["Mcp-Session-Id"] = self.session_id
180
181
  h.update(headers or {})
@@ -232,7 +233,7 @@ class HttpConn:
232
233
  def close(self) -> None:
233
234
  if self.session_id:
234
235
  req = urllib.request.Request(self.url, method="DELETE",
235
- headers={"Mcp-Session-Id": self.session_id, **self.auth_headers})
236
+ headers={"User-Agent": "tesseract-mcp-check/1.0", "Mcp-Session-Id": self.session_id, **self.auth_headers})
236
237
  try:
237
238
  urllib.request.urlopen(req, timeout=5).close()
238
239
  except (urllib.error.URLError, OSError):
@@ -295,7 +295,8 @@ def cmd_install(args) -> int:
295
295
  print(f"already installed: {target}")
296
296
  return 0
297
297
  try:
298
- with urllib.request.urlopen(RELEASES + name, timeout=120) as resp:
298
+ req = urllib.request.Request(RELEASES + name, headers={"User-Agent": "tesseract-google-workspace/1.0"})
299
+ with urllib.request.urlopen(req, timeout=120) as resp:
299
300
  data = resp.read()
300
301
  except (urllib.error.URLError, TimeoutError) as e:
301
302
  return fail(f"download failed: {e}")
@@ -47,7 +47,8 @@ def request(method: str, path: str, body: dict | None = None) -> dict:
47
47
  raise ApiError("LAMBDA_API_KEY is not set — ask for it with secret_request and run this with run_with_secrets")
48
48
  req = urllib.request.Request(API + path, method=method, data=json.dumps(body).encode() if body is not None else None,
49
49
  headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json",
50
- "Accept": "application/json"})
50
+ # Lambda's API is behind Cloudflare, which refuses Python's default User-Agent (error 1010).
51
+ "Accept": "application/json", "User-Agent": "tesseract-local-models/1.0"})
51
52
  try:
52
53
  with urllib.request.urlopen(req, timeout=60) as r: # noqa: S310 — Lambda's own API
53
54
  return json.loads(r.read().decode("utf-8") or "{}")
@@ -69,7 +69,7 @@ def payload(model: str, messages: list[dict], args: argparse.Namespace) -> dict:
69
69
 
70
70
  def post(url: str, body: dict, key: str | None, timeout: float) -> dict:
71
71
  req = urllib.request.Request(url, data=json.dumps(body).encode(), method="POST",
72
- headers={"Content-Type": "application/json",
72
+ headers={"Content-Type": "application/json", "User-Agent": "tesseract-local-models/1.0",
73
73
  "Authorization": f"Bearer {key or 'local'}"})
74
74
  with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 — the person's own endpoint
75
75
  return json.loads(r.read().decode("utf-8"))
@@ -48,6 +48,31 @@ manual and grows nobody.
48
48
  | YouTube Shorts | — | 1080×1920, ≤ 3 min | title ≤ 100, `#Shorts` | 3–5 a week |
49
49
  | Stories | `--size story` 1080×1920 | — | — | most days |
50
50
 
51
+ ## Hashtags
52
+
53
+ Hashtags only sort a post into a topic; the words of the caption, the hook and how long people watch decide its
54
+ reach. So: few, specific, in the post's own caption (never a first comment), and different from post to post.
55
+
56
+ | Where | How many | Where they go |
57
+ |---|---|---|
58
+ | Instagram | 3–5 — five at most, Instagram refuses a sixth | end of the caption; the caption's own words carry the keywords people search |
59
+ | Facebook Page | 1–3 | end of the caption |
60
+ | LinkedIn Page | 1–3 | the last line |
61
+ | TikTok | 3–5 | after a caption whose first sentence names the topic in plain words (TikTok searches captions) |
62
+ | YouTube Short | `#Shorts` + 2 | the description's last line; the first three show above the title; over 15 and YouTube ignores them all |
63
+ | YouTube video | 2–3 | the description's last line |
64
+ | X | 0–2 | inline, only when they read naturally |
65
+
66
+ - **Specific before broad.** Tag what THIS post is: the joke's own subject first (`#browsertabs`, `#scopecreep`,
67
+ `#rubberduckdebugging`), then the kind of humour or audience (`#officehumor`, `#devhumor`), then at most one category
68
+ tag (`#aiagents`, `#aitools`). Tags that only say "AI", "work" or "relatable" sink in feeds of millions — a post with
69
+ nothing else is under-tagged.
70
+ - **Never the same set twice in a row.** Rotate by topic; pass the last posts' captions to `check_post.py --recent`.
71
+ - **No dead tags**: `#fyp`, `#foryou`, `#viral`, `#explore`, `#trending`, `#like4like`, `#followforfollow` — no reach,
72
+ and they read as spam.
73
+ - **A brand's own tag** only when nobody else uses it — look at the tag's page first (a product name is often taken).
74
+ - channels.md may add the account's own rules (a tag never to use, a campaign tag).
75
+
51
76
  ## A picture post
52
77
 
53
78
  1. **The picture** — `generate_image`, 3:4 or 1:1: one striking, slightly surreal object that IS the joke, alone on a
@@ -58,8 +83,8 @@ manual and grows nobody.
58
83
  2. **The card** — `card.py post --image pic.png --head "47 tabs open." --sub "0 thoughts in my head." --mark logo.png
59
84
  --size ig [--theme dark] --out card.jpg`. `--width 0.8` when the picture crowds the headline.
60
85
  3. **Look at it** — `image_inspect` the card: the headline clear of the picture, nothing cut, the mark visible.
61
- 4. **The words** — the caption carries a question people answer ("How many are open right now?"), then the hashtags;
62
- `check_post.py --platform instagram --never "…"` until it passes.
86
+ 4. **The words** — the caption carries a question people answer ("How many are open right now?"), then the hashtags
87
+ (above); `check_post.py --platform instagram --never "…" --recent last-posts.txt` until it passes.
63
88
 
64
89
  The same picture makes the other sizes: `--size li` for LinkedIn, `--size story` for a story.
65
90
 
@@ -94,6 +119,9 @@ muted.
94
119
  into LinkedIn's and TikTok's own schedulers from their computer (LinkedIn's composer schedules up to three months
95
120
  ahead, TikTok Studio on the web ten days). When they say a batch is in, mark those lines "scheduled in LinkedIn" /
96
121
  "scheduled in TikTok" in calendar.md.
122
+ - **TikTok, once connected** — the tiktok-account skill sends each finished video to the account's TikTok inbox as a
123
+ draft (after TikTok approves the app); the words and the AI label still go to them in the batch, since the draft
124
+ carries only the video.
97
125
  - **X** — the x-account skill.
98
126
 
99
127
  ## When they change the plan
@@ -2,15 +2,19 @@
2
2
  """Check a post's words against the platform's rules and the account's own, before it goes out or into a scheduler.
3
3
 
4
4
  check_post.py --platform instagram|facebook|linkedin|tiktok|youtube|x [--file CAPTION.txt | -] [--never "Name,Other"]
5
+ [--recent LAST_POSTS.txt] [--short]
5
6
 
6
7
  Reads the caption from --file or stdin. Prints one line per finding — FAIL (fix it first), WARN (worth a look),
7
8
  OK — and exits 1 on any FAIL.
8
9
 
9
10
  FAIL: a "made with AI" / "AI-generated" note in the words (the picture speaks for itself; a platform's own AI label is
10
11
  a setting, never text); a name the account must never show (--never); over the platform's length limit; Instagram
11
- over 5 hashtags. WARN: outside the length that does best (LinkedIn 900–1,300 characters); more hashtags than the
12
- platform rewards; a link where it is not clickable or costs reach (Instagram captions, LinkedIn's body — put it on
13
- the page's button or in a comment).
12
+ over 5 hashtags; YouTube over 15 (it then ignores them all). WARN: outside the length that does best (LinkedIn
13
+ 900–1,300 characters); a hashtag count outside what the platform rewards (Instagram 3–5, Facebook 1–3, LinkedIn 1–3,
14
+ TikTok 3–5, a YouTube Short #Shorts + 2, X 0–2) or none at all; only broad tags ("#ai", "#worklife", "#relatable"…)
15
+ with nothing specific to the post; dead tags (#fyp, #viral, #explore…); the same set as one of the posts in --recent
16
+ (a file of the last captions, one per paragraph); a link where it is not clickable or costs reach (Instagram captions,
17
+ LinkedIn's body — put it on the page's button or in a comment).
14
18
  Standard library only (Python 3.9+; `python` on Windows).
15
19
  """
16
20
  from __future__ import annotations
@@ -20,7 +24,14 @@ import re
20
24
  import sys
21
25
 
22
26
  LIMITS = {"instagram": 2200, "facebook": 63206, "linkedin": 3000, "tiktok": 4000, "youtube": 5000, "x": 280}
23
- HASHTAGS_WARN = {"instagram": 5, "facebook": 3, "linkedin": 3, "tiktok": 5, "youtube": 3, "x": 2}
27
+ # The count each platform rewards (min, max) — researched 2026-10; a Short is #Shorts + 2.
28
+ HASHTAG_RANGE = {"instagram": (3, 5), "facebook": (1, 3), "linkedin": (1, 3), "tiktok": (3, 5), "youtube": (2, 3), "x": (0, 2)}
29
+ # Tags so wide a post sinks in them; a post may carry one, never only these.
30
+ BROAD = {"#ai", "#tech", "#technology", "#business", "#worklife", "#work", "#productivity", "#relatable", "#motivation",
31
+ "#love", "#instagood", "#photooftheday", "#funny", "#memes", "#life", "#success", "#marketing", "#innovation"}
32
+ # No reach, and they read as spam.
33
+ DEAD = {"#fyp", "#foryou", "#foryoupage", "#viral", "#explore", "#explorepage", "#trending", "#like4like", "#likeforlike",
34
+ "#followforfollow", "#follow4follow", "#f4f", "#l4l", "#followme"}
24
35
 
25
36
  AI_NOTE = re.compile(
26
37
  r"\((?:image|video|photo|picture|clip|art)?\s*(?:made|created|generated)\s+(?:with|by|using)\s+(?:ai|veo|midjourney|dall[- ·]?e|gpt[- ]?image)[^)]*\)"
@@ -33,7 +44,11 @@ URL = re.compile(r"\b(?:https?://|www\.)\S+|\b[a-z0-9-]+\.(?:com|dev|app|io|ai|n
33
44
  HASHTAG = re.compile(r"(?<![\w#])#\w+", re.UNICODE)
34
45
 
35
46
 
36
- def check(platform: str, text: str, never: list[str]) -> list[tuple[str, str]]:
47
+ def tagset(text: str) -> frozenset:
48
+ return frozenset(t.lower() for t in HASHTAG.findall(text))
49
+
50
+
51
+ def check(platform: str, text: str, never: list[str], recent: list[str] | None = None, short: bool = False) -> list[tuple[str, str]]:
37
52
  out: list[tuple[str, str]] = []
38
53
  n = len(text)
39
54
  m = AI_NOTE.search(text)
@@ -45,15 +60,32 @@ def check(platform: str, text: str, never: list[str]) -> list[tuple[str, str]]:
45
60
  if n > LIMITS[platform]:
46
61
  out.append(("FAIL", f"{n} characters — {platform} takes {LIMITS[platform]} at most"))
47
62
  tags = HASHTAG.findall(text)
63
+ low = [t.lower() for t in tags]
64
+ lo, hi = HASHTAG_RANGE[platform]
65
+ if platform == "youtube" and short:
66
+ lo, hi = 3, 3
67
+ if "#shorts" not in low:
68
+ out.append(("WARN", "a Short without #Shorts — keep it, with two tags about the video itself"))
48
69
  if platform == "instagram" and len(tags) > 5:
49
70
  out.append(("FAIL", f"{len(tags)} hashtags — Instagram takes 5 at most"))
50
- elif len(tags) > HASHTAGS_WARN[platform]:
51
- out.append(("WARN", f"{len(tags)} hashtags — {HASHTAGS_WARN[platform]} or fewer does better on {platform}"))
71
+ elif platform == "youtube" and len(tags) > 15:
72
+ out.append(("FAIL", f"{len(tags)} hashtags — over 15 and YouTube ignores every one"))
73
+ elif len(tags) > hi:
74
+ out.append(("WARN", f"{len(tags)} hashtags — {platform} rewards {lo}–{hi}"))
75
+ elif len(tags) < lo:
76
+ out.append(("WARN", f"{'no hashtag' if not tags else f'{len(tags)} hashtag(s)'} — {platform} does best with {lo}–{hi} specific ones"))
77
+ dead = [t for t in tags if t.lower() in DEAD]
78
+ if dead:
79
+ out.append(("WARN", f"{', '.join(dead)}: no reach, and reads as spam — tag what the post is about"))
80
+ topical = [t for t in low if t not in BROAD and t not in DEAD and t != "#shorts"]
81
+ if tags and not topical:
82
+ out.append(("WARN", "only broad tags — add the post's own subject first (what the joke or the topic is), then one category tag"))
83
+ mine = tagset(text)
84
+ if mine and any(mine == tagset(r) for r in (recent or [])):
85
+ out.append(("WARN", "the same hashtags as a recent post — rotate them by topic"))
52
86
  if platform == "linkedin":
53
87
  if n < 900 or n > 1300:
54
88
  out.append(("WARN", f"{n} characters — LinkedIn company posts of 900–1,300 do best"))
55
- if not tags:
56
- out.append(("WARN", "no hashtag — one to three specific ones help a little on LinkedIn"))
57
89
  if URL.search(text):
58
90
  out.append(("WARN", "a link in the body — LinkedIn shows link posts less; use the page's button or a first comment"))
59
91
  if platform == "instagram" and URL.search(text):
@@ -68,9 +100,12 @@ def main() -> None:
68
100
  ap.add_argument("--platform", required=True, choices=sorted(LIMITS))
69
101
  ap.add_argument("--file", default="-", help="the caption file, or - for stdin")
70
102
  ap.add_argument("--never", default="", help="comma-separated names the account must never show")
103
+ ap.add_argument("--recent", help="a file of the account's last captions, one per paragraph (blank line between)")
104
+ ap.add_argument("--short", action="store_true", help="a YouTube Short: #Shorts + 2")
71
105
  a = ap.parse_args()
72
106
  text = sys.stdin.read() if a.file == "-" else open(a.file, encoding="utf-8").read()
73
- findings = check(a.platform, text.strip(), [s.strip() for s in a.never.split(",")])
107
+ recent = [p for p in open(a.recent, encoding="utf-8").read().split("\n\n") if p.strip()] if a.recent else []
108
+ findings = check(a.platform, text.strip(), [s.strip() for s in a.never.split(",")], recent, a.short)
74
109
  for level, msg in findings:
75
110
  print(f"{level:4} {msg}")
76
111
  sys.exit(1 if any(level == "FAIL" for level, _ in findings) else 0)
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: tiktok-account
3
+ description: Send finished vertical videos to the person's own TikTok account through TikTok's official Content Posting API — each arrives in the account's TikTok inbox as a draft, which the person opens in TikTok, checks (caption, sound, the "AI-generated content" label) and posts — after connecting the account once with TikTok's Login Kit. Also shows which account is connected and where an upload is. Use to deliver a week's TikToks to the brand account, or to check the TikTok connection. Needs a TikTok developer app (client key and secret) the owner makes once; until TikTok approves it, only the app's sandbox or a private account can receive videos. Not for making the videos (social-posts, product-videos), scheduling a post for later (TikTok Studio on the web does that, ten days ahead), reading comments or numbers, ads, or driving tiktok.com in a browser.
4
+ license: MIT
5
+ version: 1.0.0
6
+ metadata:
7
+ tesseract.summary: Send finished videos to your TikTok account as drafts, ready to post from the app.
8
+ tesseract.category: writing
9
+ tesseract.examples: "send this week's TikToks to our account | connect our TikTok account | is our TikTok still connected | envía estos vídeos a nuestra cuenta de TikTok como borradores | 把这三个视频发到我们抖音国际版账号的草稿箱"
10
+ tesseract.tools: secret_status, secret_request, run_with_secrets, job_start, job_output, probe_video, channels_send, memory_read, memory_write
11
+ ---
12
+
13
+ # TikTok account
14
+
15
+ Everything goes through `scripts/tiktok.py` (standard-library Python 3.9+, `python` on Windows) and TikTok's
16
+ official APIs. It runs through `run_with_secrets` with `names: ["TIKTOK_CLIENT_KEY", "TIKTOK_CLIENT_SECRET"]` — the
17
+ TikTok app's credentials, from developers.tiktok.com — so you never see them. It prints JSON; TikTok's own error
18
+ (code, message, log_id) and what it means go to stderr.
19
+
20
+ ## Connecting, once
21
+
22
+ 1. `tiktok.py auth-url` prints the address to open. Send it to the person (Atrium, `channels_send`): they open it,
23
+ sign in to the brand account and allow it.
24
+ 2. TikTok sends them to `https://tesseract.dev/oauth/tiktok`, which shows one line — a code and a state. They paste
25
+ it back; run `tiktok.py connect "<the line>"`. The refresh token is kept in a 0600 file (`--state`, by default
26
+ `~/.tesseract/skills-data/tiktok/token.json`) and lasts a year; say so, and put a reminder in your calendar a
27
+ week before it ends.
28
+ 3. `tiktok.py whoami` — check it is the right account before anything is sent.
29
+
30
+ ## Sending a video
31
+
32
+ - A finished 9:16 MP4 (1080×1920, under 10 minutes; `probe_video` it first). `tiktok.py upload video.mp4 --wait`: the
33
+ video goes to the account's TikTok inbox as a draft; `--wait` follows it until TikTok says it is there
34
+ (`SEND_TO_USER_INBOX`). A long upload is a `job_start` job.
35
+ - Then tell them in one message: which video, its caption (from the batch — the API does not carry the words), whether
36
+ to turn on "AI-generated content" (a realistic AI video needs it), the time it was planned for, and that it waits in
37
+ their TikTok inbox. At most five drafts can wait at once — space them, or ask them to post the earlier ones.
38
+ - Until TikTok approves the app, only the sandbox's target accounts (or a private account) receive videos — an
39
+ "unaudited" error says so. Then hand the video over in Atrium instead, as social-posts' LinkedIn and TikTok batch.
40
+
41
+ ## Never
42
+
43
+ Post to anyone else's account, download other people's videos, or keep the access token anywhere but the state
44
+ file. A refresh that hands out a new refresh token replaces the old one there — keep only the newest.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """The person's own TikTok account through TikTok's official APIs: connect it once (Login Kit for Web), see which
3
+ account is connected, and send a finished video to its inbox as a draft (Content Posting API, video.upload) — the
4
+ person opens TikTok, checks it and posts it.
5
+
6
+ tiktok.py auth-url [--redirect URI] [--scopes user.info.basic,video.upload] the address to open, to connect
7
+ tiktok.py connect "CODE STATE" the line the redirect page shows → the refresh token, kept in --state
8
+ tiktok.py whoami the connected account (open id, display name, avatar)
9
+ tiktok.py upload VIDEO.mp4 [--wait] a draft in the account's TikTok inbox; --wait follows it until it lands
10
+ tiktok.py status PUBLISH_ID where an upload is
11
+
12
+ Needs TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET (the app's, from developers.tiktok.com) — run it through
13
+ run_with_secrets. The refresh token lives in a 0600 state file (--state, default ~/.tesseract/skills-data/tiktok/
14
+ token.json): TikTok may hand out a new one on each refresh, and the newest must be kept. A refresh token lasts a year;
15
+ an access token a day, made fresh for every call. Standard library only (Python 3.9+; `python` on Windows). Prints
16
+ JSON; TikTok's own error (code, message, log_id) goes to stderr with what it means.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import secrets
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from pathlib import Path
30
+
31
+ AUTH = os.environ.get("TIKTOK_AUTH_URL", "https://www.tiktok.com/v2/auth/authorize/")
32
+ OPEN = os.environ.get("TIKTOK_OPEN_API", "https://open.tiktokapis.com")
33
+ REDIRECT = "https://tesseract.dev/oauth/tiktok"
34
+ DEFAULT_STATE = Path.home() / ".tesseract" / "skills-data" / "tiktok" / "token.json"
35
+ MIN_CHUNK = 5 * 1024 * 1024
36
+ MAX_CHUNK = 64 * 1024 * 1024
37
+
38
+ HINTS = {
39
+ "access_token_invalid": "The connection expired or was revoked: connect again (auth-url, then connect).",
40
+ "scope_not_authorized": "The account did not grant this scope: connect again and allow it.",
41
+ "spam_risk_too_many_pending_share": "Five drafts already wait in the account's inbox: post or delete some in TikTok first.",
42
+ "spam_risk_too_many_posts": "The account posted its daily limit through apps: try again tomorrow.",
43
+ "rate_limit_exceeded": "Too many requests: wait a minute and try again.",
44
+ "url_ownership_unverified": "The redirect or media URL's domain is not verified on the TikTok app.",
45
+ "unaudited_client_can_only_post_to_private_accounts": "The app is not approved by TikTok yet: until it is, only a private account (or the sandbox) can receive posts.",
46
+ }
47
+
48
+
49
+ def die(msg: str, code: int = 1) -> None:
50
+ print(msg, file=sys.stderr)
51
+ sys.exit(code)
52
+
53
+
54
+ def need(name: str) -> str:
55
+ v = os.environ.get(name, "").strip()
56
+ if not v:
57
+ die(f"{name} is not set — run this through run_with_secrets with names [\"{name}\"].", 2)
58
+ return v
59
+
60
+
61
+ def state_path(a) -> Path:
62
+ return Path(a.state).expanduser() if a.state else DEFAULT_STATE
63
+
64
+
65
+ def save_state(p: Path, data: dict) -> None:
66
+ p.parent.mkdir(parents=True, exist_ok=True)
67
+ tmp = p.with_suffix(".tmp")
68
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
69
+ with os.fdopen(fd, "w") as f:
70
+ json.dump(data, f)
71
+ os.replace(tmp, p)
72
+
73
+
74
+ def load_state(p: Path) -> dict:
75
+ try:
76
+ return json.loads(p.read_text())
77
+ except (OSError, ValueError):
78
+ return {}
79
+
80
+
81
+ def request(method: str, url: str, *, form: dict | None = None, body: dict | None = None, token: str | None = None,
82
+ data: bytes | None = None, headers: dict | None = None) -> dict:
83
+ h = {"User-Agent": "tesseract-tiktok-account/1.0", **(headers or {})} # never Python's default, which Cloudflare refuses
84
+ payload = data
85
+ if form is not None:
86
+ payload = urllib.parse.urlencode(form).encode()
87
+ h["Content-Type"] = "application/x-www-form-urlencoded"
88
+ elif body is not None:
89
+ payload = json.dumps(body).encode()
90
+ h["Content-Type"] = "application/json; charset=UTF-8"
91
+ if token:
92
+ h["Authorization"] = f"Bearer {token}"
93
+ req = urllib.request.Request(url, data=payload, method=method, headers=h)
94
+ try:
95
+ with urllib.request.urlopen(req, timeout=300) as r:
96
+ raw = r.read()
97
+ except urllib.error.HTTPError as e:
98
+ raw = e.read()
99
+ try:
100
+ j = json.loads(raw or b"{}")
101
+ except ValueError:
102
+ die(f"TikTok answered {e.code}: {raw[:300]!r}")
103
+ fail(j, e.code)
104
+ try:
105
+ j = json.loads(raw) if raw else {}
106
+ except ValueError:
107
+ j = {}
108
+ if not isinstance(j, dict): # a chunk PUT answers 201 with an empty body or a bare `null`
109
+ return {}
110
+ err = j.get("error")
111
+ if isinstance(err, dict) and err.get("code") not in (None, "ok"):
112
+ fail(j, 200)
113
+ if isinstance(err, str) and err: # the OAuth endpoint's own shape
114
+ fail(j, 200)
115
+ return j
116
+
117
+
118
+ def fail(j: dict, status: int) -> None:
119
+ err = j.get("error")
120
+ if isinstance(err, dict):
121
+ code, msg, log = err.get("code"), err.get("message"), err.get("log_id")
122
+ else:
123
+ code, msg, log = err, j.get("error_description"), j.get("log_id")
124
+ print(json.dumps({"status": status, "code": code, "message": msg, "log_id": log}), file=sys.stderr)
125
+ if code in HINTS:
126
+ print(HINTS[code], file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+
130
+ def access_token(a) -> str:
131
+ """A fresh access token from the kept refresh token; keeps the new refresh token when TikTok rotates it."""
132
+ p = state_path(a)
133
+ st = load_state(p)
134
+ refresh = st.get("refresh_token") or os.environ.get("TIKTOK_REFRESH_TOKEN", "").strip()
135
+ if not refresh:
136
+ die("Not connected yet: run auth-url, open it, then connect with the line the page shows.", 2)
137
+ j = request("POST", f"{OPEN}/v2/oauth/token/", form={
138
+ "client_key": need("TIKTOK_CLIENT_KEY"), "client_secret": need("TIKTOK_CLIENT_SECRET"),
139
+ "grant_type": "refresh_token", "refresh_token": refresh,
140
+ })
141
+ if j.get("refresh_token") and j["refresh_token"] != refresh:
142
+ st.update({"refresh_token": j["refresh_token"], "refreshed_at": int(time.time())})
143
+ save_state(p, st)
144
+ return j["access_token"]
145
+
146
+
147
+ def cmd_auth_url(a) -> None:
148
+ st_path = state_path(a)
149
+ st = load_state(st_path)
150
+ state = secrets.token_urlsafe(16)
151
+ st.update({"pending_state": state, "redirect_uri": a.redirect})
152
+ save_state(st_path, st)
153
+ q = urllib.parse.urlencode({"client_key": need("TIKTOK_CLIENT_KEY"), "scope": a.scopes, "response_type": "code",
154
+ "redirect_uri": a.redirect, "state": state, "disable_auto_auth": 1})
155
+ print(json.dumps({"url": f"{AUTH}?{q}", "then": "open it, allow, and give the line the page shows to: tiktok.py connect \"CODE STATE\""}))
156
+
157
+
158
+ def cmd_connect(a) -> None:
159
+ parts = a.line.split()
160
+ code = urllib.parse.unquote(parts[0]) if parts else ""
161
+ p = state_path(a)
162
+ st = load_state(p)
163
+ if not code:
164
+ die("Give the line the page shows: the code, then the state.", 2)
165
+ if len(parts) > 1 and st.get("pending_state") and parts[1] != st["pending_state"]:
166
+ die("That code was not asked for by this connection (the state does not match): start again with auth-url.", 2)
167
+ j = request("POST", f"{OPEN}/v2/oauth/token/", form={
168
+ "client_key": need("TIKTOK_CLIENT_KEY"), "client_secret": need("TIKTOK_CLIENT_SECRET"), "code": code,
169
+ "grant_type": "authorization_code", "redirect_uri": st.get("redirect_uri") or REDIRECT,
170
+ })
171
+ st.pop("pending_state", None)
172
+ st.update({"refresh_token": j["refresh_token"], "open_id": j.get("open_id"), "scope": j.get("scope"),
173
+ "refresh_expires_in": j.get("refresh_expires_in"), "connected_at": int(time.time())})
174
+ save_state(p, st)
175
+ print(json.dumps({"connected": True, "open_id": j.get("open_id"), "scope": j.get("scope"), "state_file": str(p)}))
176
+
177
+
178
+ def cmd_whoami(a) -> None:
179
+ j = request("GET", f"{OPEN}/v2/user/info/?fields=open_id,display_name,avatar_url", token=access_token(a))
180
+ print(json.dumps(j.get("data", {}).get("user", {})))
181
+
182
+
183
+ def cmd_upload(a) -> None:
184
+ path = Path(a.video)
185
+ if not path.exists():
186
+ die(f"{path}: no such file.", 2)
187
+ size = path.stat().st_size
188
+ if size > 4 * 1024 ** 3:
189
+ die("TikTok takes videos up to 4 GB.", 3)
190
+ # Under 64 MB: one chunk, the whole file. Larger: 10 MB chunks, the remainder joining the last one.
191
+ chunk = size if size <= MAX_CHUNK else 10 * 1024 * 1024
192
+ count = max(1, size // chunk)
193
+ mime = {".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm"}.get(path.suffix.lower())
194
+ if not mime:
195
+ die("Send an MP4, MOV or WebM.", 3)
196
+ token = access_token(a)
197
+ init = request("POST", f"{OPEN}/v2/post/publish/inbox/video/init/", token=token, body={
198
+ "source_info": {"source": "FILE_UPLOAD", "video_size": size, "chunk_size": chunk, "total_chunk_count": count}})
199
+ data = init["data"]
200
+ url, publish_id = data["upload_url"], data["publish_id"]
201
+ with path.open("rb") as f:
202
+ for i in range(count):
203
+ start = i * chunk
204
+ end = size - 1 if i == count - 1 else start + chunk - 1
205
+ f.seek(start)
206
+ part = f.read(end - start + 1)
207
+ request("PUT", url, data=part, headers={"Content-Type": mime, "Content-Length": str(len(part)),
208
+ "Content-Range": f"bytes {start}-{end}/{size}"})
209
+ out = {"publish_id": publish_id, "bytes": size, "chunks": count}
210
+ if a.wait:
211
+ out["status"] = follow(token, publish_id)
212
+ print(json.dumps(out))
213
+
214
+
215
+ def status_of(token: str, publish_id: str) -> dict:
216
+ return request("POST", f"{OPEN}/v2/post/publish/status/fetch/", token=token, body={"publish_id": publish_id}).get("data", {})
217
+
218
+
219
+ def follow(token: str, publish_id: str) -> dict:
220
+ last = {}
221
+ for _ in range(60):
222
+ last = status_of(token, publish_id)
223
+ if last.get("status") in ("SEND_TO_USER_INBOX", "PUBLISH_COMPLETE", "FAILED"):
224
+ break
225
+ time.sleep(5)
226
+ return last
227
+
228
+
229
+ def cmd_status(a) -> None:
230
+ print(json.dumps(status_of(access_token(a), a.publish_id)))
231
+
232
+
233
+ def main() -> None:
234
+ ap = argparse.ArgumentParser(description="The person's own TikTok account, through TikTok's APIs.")
235
+ ap.add_argument("--state", help="the 0600 file that keeps the refresh token")
236
+ sub = ap.add_subparsers(dest="cmd", required=True)
237
+ p = sub.add_parser("auth-url"); p.add_argument("--redirect", default=REDIRECT); p.add_argument("--scopes", default="user.info.basic,video.upload")
238
+ p = sub.add_parser("connect"); p.add_argument("line")
239
+ sub.add_parser("whoami")
240
+ p = sub.add_parser("upload"); p.add_argument("video"); p.add_argument("--wait", action="store_true")
241
+ p = sub.add_parser("status"); p.add_argument("publish_id")
242
+ a = ap.parse_args()
243
+ {"auth-url": cmd_auth_url, "connect": cmd_connect, "whoami": cmd_whoami, "upload": cmd_upload, "status": cmd_status}[a.cmd](a)
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: tracked-links
3
+ description: Give every link to the product's own site that goes out — in a bio or about section, a post, a reply, a message, a video's description, an email — its own short tag (`?utm_source=ab63az`), registered with where it is published and why, so each placement's visits, downloads and new accounts are counted on their own; then read which links, platforms and other sites bring people. Use before publishing anything that links to the site, after it is posted (to record where), and when asked what is working. Needs the product's marketing key (Tesseract's blog key). Not for shortening links, links to other people's sites, ad campaigns' own tracking, or reading a platform's own statistics (youtube-channel, facebook-instagram, x-account do that).
4
+ license: MIT
5
+ version: 1.0.0
6
+ metadata:
7
+ tesseract.summary: Tag every link you publish, and see which ones bring visits, downloads and sign-ups.
8
+ tesseract.category: writing
9
+ tesseract.examples: "tag the link for our Instagram bio | which of our posts brought the most sign-ups this month | make a tracked link for this Reddit reply | ¿qué enlaces nos trajeron más visitas esta semana? | 给我们的 YouTube 简介链接加一个追踪标签"
10
+ tesseract.tools: secret_status, secret_request, run_with_secrets, channels_send, memory_read, memory_write
11
+ ---
12
+
13
+ # Tracked links
14
+
15
+ Everything goes through `scripts/links.py` (standard-library Python 3.9+, `python` on Windows). It runs through
16
+ `run_with_secrets` with `names: ["BLOG_API_KEY"]` — the marketing key — so you never see it. The API is the product's
17
+ own (`LINKS_API_URL`, or the blog API's `…/marketing` sibling when only `BLOG_API_URL` is set). It prints JSON; the
18
+ API's own message goes to stderr.
19
+
20
+ ## The rule
21
+
22
+ **Every link to the product's site that you publish carries its own tag — one tag per place, never reused.** A bio,
23
+ an about section, a pinned post, each post, each reply, each message, each video description: each gets a new one.
24
+ Links to other sites carry none.
25
+
26
+ ## Before publishing
27
+
28
+ `links.py new --comment "<where and why, in a few words>" --channel <platform> [--target <page>]`
29
+
30
+ - `--comment`: what a person reading the report needs — "instagram bio url", "reddit reply in r/LocalLLaMA about
31
+ local agents", "youtube short 'Browser tabs' description".
32
+ - `--channel`: instagram, facebook, youtube, tiktok, linkedin, x, reddit, hn, github, email, newsletter, other.
33
+ - `--target`: the page it opens (the home page by default; a blog post's own address when the post links to it).
34
+
35
+ Publish the `link` it prints, exactly. `links.py link URL TAG` adds a tag to another address without asking the API.
36
+
37
+ ## After publishing
38
+
39
+ Once the post, reply or profile has its own address: `links.py set TAG --url <that address>`. A link whose place has
40
+ no address of its own (a bio field) gets the profile's address. If something was never posted, `links.py delete TAG`.
41
+
42
+ Where a platform shows no link in a post (TikTok, Instagram captions), the tag goes in the bio's website field, and
43
+ the post says "link in bio".
44
+
45
+ ## Reading what works
46
+
47
+ `links.py report --days 30`: totals and the span before, by platform (`channels`), by link (`sources`: each of our tags
48
+ with its comment and address, and every other site), downloads by kind, sign-ups by method, countries, systems,
49
+ pages. When asked what is working — or in a weekly summary — say it plainly: which placements brought visits,
50
+ downloads and accounts, which brought nothing, and what to do more or less of. `links.py list --channel reddit` and
51
+ `links.py get TAG` show one tag's numbers.
52
+
53
+ ## Never
54
+
55
+ Put a tag on a link to someone else's site, reuse a tag in a second place, or invent numbers when the report says
56
+ `configured: false` (nothing is being counted — say so).