A URL is always 23 characters, no matter how long it is
Shipped
I shipped ghostwriter-x, an X (Twitter) sibling to my LinkedIn ghostwriter skill: same
voice-first draft-approve-publish flow, retargeted at X’s format. X’s own API dropped
its free tier, so it publishes through Typefully instead. One of the core engines
underneath it is x_len.py: a from-scratch implementation of X’s weighted
character-counting rules, the thing that decides whether a draft actually fits in 280
characters before it ever reaches the API. The rules turned out to be more interesting,
and more of a trap, than “count the characters.”
What “280 characters” actually means
X doesn’t count characters the way len() does. Per X’s own documentation on counting
characters, most characters (Latin
letters, punctuation, common symbols) cost 1 point; CJK characters and emoji cost 2; a
URL costs a flat 23 no matter how long it actually is. The reference implementation for
this is Twitter’s open-source twitter-text library. Its config
README explains
the shape: a scale divisor, a defaultWeight applied to every code point unless a
listed range overrides it, and its
weighted-length config
spells out the exact numbers: a scale of 100, a defaultWeight of 200, and a short
list of Unicode code point ranges that get a weight of 100 instead. Divide by the
scale and you get the effective weights: 1 for anything in a listed range, 2 for
everything else. transformedURLLength is 23, matching the t.co wrapper every URL
gets rewritten through.
The listed ranges are narrow: code points 0–4351 (Latin, Cyrillic, Greek, Hebrew, Arabic, general punctuation), 8192–8205 (a small punctuation/space block that includes the zero-width joiner), 8208–8223, and 8242–8247. Everything outside those four ranges, including every emoji and every CJK character, costs 2.
Building the weight function
Start with the per-code-point weight, straight from the ranges above:
_LIGHT_RANGES = (
(0x0000, 0x10FF),
(0x2000, 0x200D),
(0x2010, 0x201F),
(0x2032, 0x2037),
)
def _codepoint_weight(cp: int) -> int:
for lo, hi in _LIGHT_RANGES:
if lo <= cp <= hi:
return 1
return 2
def _text_weight(text: str) -> int:
return sum(_codepoint_weight(ord(ch)) for ch in text)
Before weighing anything, normalize the input. X’s docs specify Unicode Normalization
Form C: per the Unicode Standard Annex #15
definition, NFC is canonical decomposition followed by canonical composition, and it
matters here because a base letter plus a combining accent (two code points) and the
single precomposed character look identical on screen but are different strings to
ord():
import unicodedata
combining = "e" + "́" # e + combining acute accent: 2 code points
precomposed = "é" # the single precomposed code point for é
print(len(combining), len(precomposed))
print(unicodedata.normalize("NFC", combining) == precomposed)
That prints 2 1 and then True: two visually identical strings, two different code
point counts, until NFC folds them together. Every real weight function has to
normalize before it counts, or the same character costs a different amount depending on
which keyboard or app produced it.
Charging URLs a flat 23
This is the part that actually breaks if you write it naively. A tweet can contain a
proper https:// URL, a bare www. domain, and a bare domain with no scheme at all
(example.com), and X linkifies all three. The bare-domain case is the trap: a regex
match for example.com/some/path might fully contain a real https:// URL inside its
own path segment (foo.com/redirect?u=https://bar.io/page), and if you charge both
matches you double-count.
The fix is to collect every match as a (start, end, weight) span, find schemed URLs
first, then bare domains only where they don’t already overlap a schemed match, and
walk the spans in order:
import re
_SCHEMED_URL_RE = re.compile(r"(?:https?://|www\.)[^\s<>]+", re.IGNORECASE)
_BARE_DOMAIN_RE = re.compile(
r"\b[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)*"
r"\.(?:com|net|org|io|dev|ai|co|app|me|sh)"
r"(?:/[^\s<>]*)?",
re.IGNORECASE,
)
URL_WEIGHT = 23
def weighted_length(text: str) -> int:
text = unicodedata.normalize("NFC", text)
spans: list[tuple[int, int, int]] = []
for m in _SCHEMED_URL_RE.finditer(text):
spans.append((m.start(), m.end(), URL_WEIGHT))
for m in _BARE_DOMAIN_RE.finditer(text):
if any(s <= m.start() < e for s, e, _ in spans):
continue
spans.append((m.start(), m.end(), max(URL_WEIGHT, _text_weight(m.group()))))
total = 0
cursor = 0
for start, end, weight in sorted(spans):
if start < cursor:
continue
total += _text_weight(text[cursor:start]) + weight
cursor = end
total += _text_weight(text[cursor:])
return total
The max(URL_WEIGHT, _text_weight(m.group())) on the bare-domain branch matters too: a
short bare domain like example.com (11 characters) still costs the full 23, because X
still linkifies it. A long one, like a bare domain with a long path, costs whichever is
bigger.
Using it, and verifying the numbers
Run it against a handful of real inputs:
samples = [
"hello",
"日本語",
"🔥",
"Check out https://example.com/a/very/long/path?x=1 today",
"go.example.com/path",
"foo.com/redirect?u=https://bar.io/page",
]
for s in samples:
print(f"{s!r:60} -> {weighted_length(s)}")
Running that produces:
'hello' -> 5
'日本語' -> 6
'🔥' -> 2
'Check out https://example.com/a/very/long/path?x=1 today' -> 39
'go.example.com/path' -> 23
'foo.com/redirect?u=https://bar.io/page' -> 38
hello costs 5 (1 per character, plain ASCII). 日本語 costs 6, 2 per character. 🔥
costs 2. The URL sentence costs 39: “Check out " (10) plus the flat 23 for the URL plus
" today” (6). go.example.com/path costs 23 flat, the short-domain case. The last one,
a bare domain whose path embeds a real https:// URL, costs 38, its own text weight,
not 23 plus another 23 for the URL hiding inside it.
Gotchas
Emoji sequences joined by a zero-width joiner over-count, on purpose. A combined
emoji like 👩💻 is three code points (woman, ZWJ, laptop) under the hood.
twitter-text treats the whole joined sequence as a single emoji, weight 2. Counting
each code point separately gives 5 instead. That’s a real gap between this
implementation and the exact spec, and it’s deliberate: every place this counter
diverges from the real rules, it over-counts, never under-counts. A tweet that passes
this checker is guaranteed to fit on X; the rare heavily-emoji’d draft might get flagged
as over when it would have actually fit. Getting that wrong in the other direction, a
counter that says a draft fits when X will actually reject it, is the failure mode that
matters.
A bare-domain match can swallow a real URL whole, and charging both is the bug. The
foo.com/redirect?u=https://bar.io/page case above isn’t a contrived edge case, it’s
the shape of any link-shortener or redirect URL with the destination in a query string.
Drop the if any(s <= m.start() < e for s, e, _ in spans): continue guard and sum every
matched span’s weight independently instead, and that same string comes out to 61: 23
for the schemed URL plus 38 for the bare-domain match that separately covers the whole
string, including the URL inside it. The fix isn’t “detect redirects”, it’s the boring
one: never add a bare-domain span whose start already falls inside a span you already
have.
The regex for “does this look like a domain” needs a real TLD allowlist, not \.\w+
at the end of a word. Matching any trailing dot-plus-letters as a domain flags things
like “v2.1” or “step.1” as URLs and charges them 23. Anchoring the bare-domain pattern
to a fixed list of common TLDs (.com, .io, .dev, and so on) cuts that down; it will
never be exhaustive, but it’s the same conservative trade-off as everything else here:
false negatives (missing a real bare domain) just mean a slightly stricter count, never
a draft that silently ships over the limit.
Sources
- Counting characters — X’s own weighted-counting rules: 1/2-point character weights, the flat 23-point URL cost, NFC normalization
- twitter-text config README — the
scale/defaultWeight/rangesstructure behind the weighted-length algorithm - twitter-text v3.json — the exact code point ranges and weights that back this implementation
- Unicode Standard Annex #15 — the definition of Normalization Form C (canonical decomposition, then canonical composition)
Changelog
- feat: ghostwriter-x — X (Twitter) ghostwriter skill v0.1.0 (#88) (c8357b8)