mannerism 0.1.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.
- mannerism/__init__.py +3 -0
- mannerism/__main__.py +3 -0
- mannerism/adapters/__init__.py +86 -0
- mannerism/adapters/_http.py +134 -0
- mannerism/adapters/_social.py +229 -0
- mannerism/adapters/_util.py +539 -0
- mannerism/adapters/aider.py +303 -0
- mannerism/adapters/bluesky.py +343 -0
- mannerism/adapters/chatgpt.py +143 -0
- mannerism/adapters/claude_code.py +242 -0
- mannerism/adapters/cline.py +410 -0
- mannerism/adapters/codex.py +272 -0
- mannerism/adapters/continue_dev.py +181 -0
- mannerism/adapters/crush.py +205 -0
- mannerism/adapters/files.py +537 -0
- mannerism/adapters/gh.py +127 -0
- mannerism/adapters/git.py +203 -0
- mannerism/adapters/goose.py +205 -0
- mannerism/adapters/hermes.py +249 -0
- mannerism/adapters/imessage.py +132 -0
- mannerism/adapters/kilo.py +255 -0
- mannerism/adapters/markdown.py +213 -0
- mannerism/adapters/mastodon.py +370 -0
- mannerism/adapters/mbox.py +210 -0
- mannerism/adapters/opencode.py +273 -0
- mannerism/adapters/pi.py +162 -0
- mannerism/adapters/qwen_code.py +189 -0
- mannerism/adapters/slack.py +553 -0
- mannerism/adapters/telegram.py +162 -0
- mannerism/adapters/whatsapp.py +234 -0
- mannerism/adapters/whatsapp_db.py +238 -0
- mannerism/adapters/wispr.py +124 -0
- mannerism/adapters/x_api.py +313 -0
- mannerism/adapters/x_archive.py +199 -0
- mannerism/api.py +502 -0
- mannerism/backfill.py +93 -0
- mannerism/bank.py +383 -0
- mannerism/blind.py +370 -0
- mannerism/capture.py +420 -0
- mannerism/cards.py +270 -0
- mannerism/cli.py +1172 -0
- mannerism/config.py +256 -0
- mannerism/connect.py +823 -0
- mannerism/core.py +313 -0
- mannerism/dashboard.py +656 -0
- mannerism/drift.py +91 -0
- mannerism/evaluate.py +53 -0
- mannerism/exclude.py +686 -0
- mannerism/groups.py +266 -0
- mannerism/hookroots.py +213 -0
- mannerism/hooks.py +218 -0
- mannerism/humanness.py +523 -0
- mannerism/intake.py +79 -0
- mannerism/legacy.py +418 -0
- mannerism/lint.py +173 -0
- mannerism/mcp_server.py +239 -0
- mannerism/packets.py +329 -0
- mannerism/plugins.py +170 -0
- mannerism/privacy.py +101 -0
- mannerism/public.py +226 -0
- mannerism/public_server.py +84 -0
- mannerism/published.py +301 -0
- mannerism/py.typed +0 -0
- mannerism/refresh.py +50 -0
- mannerism/registers.py +60 -0
- mannerism/remote.py +95 -0
- mannerism/rules.py +250 -0
- mannerism/scan.py +501 -0
- mannerism/schedule.py +110 -0
- mannerism/skills/mannerism/SKILL.md +59 -0
- mannerism/stats.py +273 -0
- mannerism/store.py +710 -0
- mannerism/stylometrics.py +130 -0
- mannerism/sync.py +620 -0
- mannerism/tells.py +207 -0
- mannerism/validate.py +56 -0
- mannerism/web.py +359 -0
- mannerism/webui/app.css +198 -0
- mannerism/webui/app.js +594 -0
- mannerism/webui/index.html +30 -0
- mannerism-0.1.0.dist-info/METADATA +372 -0
- mannerism-0.1.0.dist-info/RECORD +86 -0
- mannerism-0.1.0.dist-info/WHEEL +4 -0
- mannerism-0.1.0.dist-info/entry_points.txt +2 -0
- mannerism-0.1.0.dist-info/licenses/LICENSE +176 -0
- mannerism-0.1.0.dist-info/licenses/NOTICE +64 -0
mannerism/__init__.py
ADDED
mannerism/__main__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Source adapters. One module per source; each defines a class implementing `mannerism.core.Source`.
|
|
2
|
+
|
|
3
|
+
Adapters keep only text the user wrote. Other people's text goes only into Unit.context, masked
|
|
4
|
+
with `core.context_line`, at most `core.CONTEXT_LINES` lines. Adapters use the standard library only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
|
|
11
|
+
from ..core import Source
|
|
12
|
+
|
|
13
|
+
# name -> "module:Class". Order is the order `init` shows sources in.
|
|
14
|
+
REGISTRY = {
|
|
15
|
+
"claude_code": "claude_code:ClaudeCode",
|
|
16
|
+
"codex": "codex:Codex",
|
|
17
|
+
"git": "git:Git",
|
|
18
|
+
"gh": "gh:GitHub",
|
|
19
|
+
"bluesky": "bluesky:Bluesky",
|
|
20
|
+
"mastodon": "mastodon:Mastodon",
|
|
21
|
+
"x": "x_api:XApi",
|
|
22
|
+
"slack": "slack:Slack",
|
|
23
|
+
"markdown": "markdown:Markdown",
|
|
24
|
+
"files": "files:Files",
|
|
25
|
+
"chatgpt": "chatgpt:ChatGPT",
|
|
26
|
+
"telegram": "telegram:Telegram",
|
|
27
|
+
"whatsapp": "whatsapp:WhatsApp",
|
|
28
|
+
"whatsapp_db": "whatsapp_db:WhatsAppDB",
|
|
29
|
+
"hermes": "hermes:Hermes",
|
|
30
|
+
"x_archive": "x_archive:XArchive",
|
|
31
|
+
"mbox": "mbox:Mbox",
|
|
32
|
+
"imessage": "imessage:IMessage",
|
|
33
|
+
"wispr": "wispr:Wispr",
|
|
34
|
+
"opencode": "opencode:OpenCode",
|
|
35
|
+
"qwen_code": "qwen_code:QwenCode",
|
|
36
|
+
"goose": "goose:Goose",
|
|
37
|
+
"aider": "aider:Aider",
|
|
38
|
+
"crush": "crush:Crush",
|
|
39
|
+
"pi": "pi:Pi",
|
|
40
|
+
"continue": "continue_dev:Continue",
|
|
41
|
+
"cline": "cline:Cline",
|
|
42
|
+
"kilo": "kilo:Kilo",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Built-in adapters that can be configured as several named instances: `[sources.<instance>]` with
|
|
47
|
+
# `adapter = "<key>"`. The instance name becomes the source name (units, cursors and `forget --source`
|
|
48
|
+
# all use it), so an instance name must not equal a built-in source or an installed plugin.
|
|
49
|
+
INSTANCE_ADAPTERS = {"files": "files:Files"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load(name: str) -> Source:
|
|
53
|
+
mod, cls = REGISTRY[name].split(":")
|
|
54
|
+
return getattr(import_module(f"{__name__}.{mod}"), cls)()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_for(name: str, entry: dict | None = None, *, warnings: list[str] | None = None) -> Source:
|
|
58
|
+
"""The source for config entry `[sources.<name>]`: a built-in, an instance of one, or an enabled plugin.
|
|
59
|
+
|
|
60
|
+
Plain built-ins go through `load(name)`. A plugin is imported here, so callers that must never run plugin
|
|
61
|
+
code (`sync --quick`) check `entry.get("plugin")` before calling."""
|
|
62
|
+
entry = entry or {}
|
|
63
|
+
if entry.get("plugin"):
|
|
64
|
+
from .. import plugins
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return plugins.load(name, entry, warnings=warnings)
|
|
68
|
+
except plugins.PluginError as e:
|
|
69
|
+
raise ImportError(str(e)) from None
|
|
70
|
+
kind = entry.get("adapter")
|
|
71
|
+
if kind:
|
|
72
|
+
if kind not in INSTANCE_ADAPTERS:
|
|
73
|
+
raise KeyError(f"adapter {kind!r} cannot be configured as an instance")
|
|
74
|
+
mod, cls = INSTANCE_ADAPTERS[kind].split(":")
|
|
75
|
+
return getattr(import_module(f"{__name__}.{mod}"), cls)(name)
|
|
76
|
+
return load(name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def all_sources() -> list[Source]:
|
|
80
|
+
out = []
|
|
81
|
+
for name in REGISTRY:
|
|
82
|
+
try:
|
|
83
|
+
out.append(load(name))
|
|
84
|
+
except (ImportError, AttributeError):
|
|
85
|
+
continue # not implemented yet
|
|
86
|
+
return out
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Minimal, strict HTTPS GET for the network sources (standard library only).
|
|
2
|
+
|
|
3
|
+
Rules every network source gets from here: https only, a finite timeout, a `mannerism/<version>` User-Agent, a cap on
|
|
4
|
+
the JSON body, redirects followed only inside the original origin (never to another host: credentials stay put), and
|
|
5
|
+
errors reduced to a `NetError` that carries a kind, a status and a retry hint. Error text never contains headers,
|
|
6
|
+
bodies or query strings, so a credential cannot leak into a log or a stored cursor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
|
|
19
|
+
from .. import __version__
|
|
20
|
+
|
|
21
|
+
TIMEOUT_S = 10.0
|
|
22
|
+
MAX_JSON_BYTES = 8 * 1024 * 1024
|
|
23
|
+
MAX_REDIRECTS = 3
|
|
24
|
+
DEFAULT_RETRY_S = 900 # a 429 without any reset header
|
|
25
|
+
MAX_RETRY_S = 24 * 3600
|
|
26
|
+
USER_AGENT = f"mannerism/{__version__}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NetError(Exception):
|
|
30
|
+
"""A failed request. `kind`: timeout | network | http | rate_limit | redirect | too_large | bad_json | url."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, kind: str, message: str, status: int | None = None, retry_after_s: float | None = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.kind = kind
|
|
35
|
+
self.status = status
|
|
36
|
+
self.retry_after_s = retry_after_s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
40
|
+
def redirect_request(self, *args, **kwargs): # noqa: ARG002 - a 3xx surfaces as an HTTPError; get_json decides
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def urlopen(req: urllib.request.Request, timeout: float):
|
|
48
|
+
"""The single place a socket is opened (tests replace this function)."""
|
|
49
|
+
return _OPENER.open(req, timeout=timeout)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def retry_after_s(headers: Mapping | None, now: datetime | None = None) -> float:
|
|
53
|
+
"""Seconds to wait, from Retry-After, x-rate-limit-reset (X, epoch), ratelimit-reset (Bluesky, epoch) or
|
|
54
|
+
X-RateLimit-Reset (Mastodon, ISO-8601). Falls back to DEFAULT_RETRY_S; always within [1, MAX_RETRY_S]."""
|
|
55
|
+
now = now or datetime.now(UTC)
|
|
56
|
+
get = (lambda k: headers.get(k)) if headers is not None else (lambda k: None)
|
|
57
|
+
for key in ("retry-after", "x-rate-limit-reset", "ratelimit-reset", "x-ratelimit-reset"):
|
|
58
|
+
raw = get(key)
|
|
59
|
+
if raw is None:
|
|
60
|
+
continue
|
|
61
|
+
raw = str(raw).strip()
|
|
62
|
+
try:
|
|
63
|
+
x = float(raw)
|
|
64
|
+
wait = x - now.timestamp() if x > 1e9 else x
|
|
65
|
+
except ValueError:
|
|
66
|
+
try:
|
|
67
|
+
dt = parsedate_to_datetime(raw) if not raw[:4].isdigit() else datetime.fromisoformat(raw)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
continue
|
|
70
|
+
wait = (dt if dt.tzinfo else dt.replace(tzinfo=UTC)).timestamp() - now.timestamp()
|
|
71
|
+
return min(max(wait, 1.0), float(MAX_RETRY_S))
|
|
72
|
+
return float(DEFAULT_RETRY_S)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
76
|
+
p = urllib.parse.urlsplit(url)
|
|
77
|
+
return p.scheme, (p.hostname or "").lower(), p.port
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_url(host: str, path: str, params: Mapping[str, str | int | list] | None = None) -> str:
|
|
81
|
+
"""https://host/path?query. A list value repeats the parameter (`uris=a&uris=b`); None values are left out."""
|
|
82
|
+
q = urllib.parse.urlencode(
|
|
83
|
+
{k: ([str(x) for x in v] if isinstance(v, list) else str(v)) for k, v in (params or {}).items() if v is not None},
|
|
84
|
+
safe=",", doseq=True)
|
|
85
|
+
return f"https://{host}{path}" + (f"?{q}" if q else "")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_json(url: str, headers: Mapping[str, str] | None = None, *, timeout: float = TIMEOUT_S,
|
|
89
|
+
max_bytes: int = MAX_JSON_BYTES) -> tuple[object, Mapping]:
|
|
90
|
+
"""GET `url` (https only) and return (parsed JSON, response headers). Raises NetError."""
|
|
91
|
+
start = _origin(url)
|
|
92
|
+
if start[0] != "https" or not start[1]:
|
|
93
|
+
raise NetError("url", "refusing a non-https URL")
|
|
94
|
+
hdrs = {"User-Agent": USER_AGENT, "Accept": "application/json", **(headers or {})}
|
|
95
|
+
cur = url
|
|
96
|
+
for _hop in range(MAX_REDIRECTS + 1):
|
|
97
|
+
host = _origin(cur)[1]
|
|
98
|
+
try:
|
|
99
|
+
with urlopen(urllib.request.Request(cur, headers=hdrs, method="GET"), timeout) as resp:
|
|
100
|
+
body = resp.read(max_bytes + 1)
|
|
101
|
+
status = getattr(resp, "status", 200)
|
|
102
|
+
resp_headers = resp.headers
|
|
103
|
+
except urllib.error.HTTPError as e:
|
|
104
|
+
if e.code in (301, 302, 303, 307, 308):
|
|
105
|
+
target = urllib.parse.urljoin(cur, (e.headers.get("Location") if e.headers else None) or "")
|
|
106
|
+
if _origin(target) != start:
|
|
107
|
+
raise NetError("redirect", f"{host}: refused a redirect to another origin", e.code) from None
|
|
108
|
+
cur = target
|
|
109
|
+
continue
|
|
110
|
+
if e.code == 429:
|
|
111
|
+
raise NetError("rate_limit", f"{host}: HTTP 429 (rate limited)", 429,
|
|
112
|
+
retry_after_s(e.headers)) from None
|
|
113
|
+
raise NetError("http", f"{host}: HTTP {e.code}", e.code) from None
|
|
114
|
+
except TimeoutError:
|
|
115
|
+
raise NetError("timeout", f"{host}: timed out") from None
|
|
116
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
117
|
+
raise NetError("network", f"{host}: {type(e).__name__}") from None
|
|
118
|
+
if status in (301, 302, 303, 307, 308): # an opener that did not raise for a redirect
|
|
119
|
+
target = urllib.parse.urljoin(cur, resp_headers.get("Location") or "")
|
|
120
|
+
if _origin(target) != start:
|
|
121
|
+
raise NetError("redirect", f"{host}: refused a redirect to another origin", status)
|
|
122
|
+
cur = target
|
|
123
|
+
continue
|
|
124
|
+
if status == 429:
|
|
125
|
+
raise NetError("rate_limit", f"{host}: HTTP 429 (rate limited)", 429, retry_after_s(resp_headers))
|
|
126
|
+
if status >= 400:
|
|
127
|
+
raise NetError("http", f"{host}: HTTP {status}", status)
|
|
128
|
+
if len(body) > max_bytes:
|
|
129
|
+
raise NetError("too_large", f"{host}: response over {max_bytes} bytes")
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(body.decode("utf-8")), resp_headers
|
|
132
|
+
except (ValueError, UnicodeDecodeError):
|
|
133
|
+
raise NetError("bad_json", f"{host}: response is not JSON") from None
|
|
134
|
+
raise NetError("redirect", "too many redirects")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Shared machinery for the continuous social-post sources (Bluesky, Mastodon, X): config gate, status in the cursor,
|
|
2
|
+
text hygiene and the paged newest-first scan.
|
|
3
|
+
|
|
4
|
+
The scan keeps two marks apart. `high_water` is the newest item a COMPLETED scan has seen. While a scan runs, its
|
|
5
|
+
state lives in `scan` = {token, candidate, fetched}: the continuation token for the next page, the newest item seen
|
|
6
|
+
so far (it becomes the high-water mark only when the scan finishes) and how many items were fetched. A timeout or
|
|
7
|
+
error in the middle returns the units gathered so far together with the `scan` continuation, so the next read resumes
|
|
8
|
+
at the same page: nothing is lost (the units and the cursor are stored in one transaction) and nothing is read twice.
|
|
9
|
+
A first run (no high-water mark yet) stops after `initial` items instead of reading a whole history.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
|
|
20
|
+
from ..core import Unit
|
|
21
|
+
from ._http import NetError
|
|
22
|
+
|
|
23
|
+
CURSOR_VERSION = 1
|
|
24
|
+
MAX_PAGES_PER_READ = 5 # a read never makes more page requests than this; the rest continues next time
|
|
25
|
+
DEFAULT_INITIAL_POSTS = 200 # free sources: how much history the first run reads
|
|
26
|
+
DEFAULT_RECONCILE_INTERVAL_S = 7 * 24 * 3600 # free sources: how often deletes and edits are looked for
|
|
27
|
+
RECONCILE_ITEMS = 200 # the recent window a reconcile re-reads
|
|
28
|
+
RECONCILE_CHECKS = 50 # at most this many vanished-looking posts are confirmed one by one per reconcile
|
|
29
|
+
MAX_SEEN = 400 # refs remembered in the cursor (newest first): enough to cover the reconcile window
|
|
30
|
+
REJECTED_TOKEN_STATUSES = (400, 404, 410) # a stored continuation token the service no longer accepts
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def utcnow() -> datetime:
|
|
34
|
+
return datetime.now(UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def network_enabled(config: dict | None) -> bool:
|
|
38
|
+
"""`allow_network` must be exactly true; anything else (missing, "yes", 1) keeps the source off."""
|
|
39
|
+
return isinstance(config, dict) and config.get("allow_network") is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def int_option(config: dict | None, key: str, default: int, low: int = 1) -> int:
|
|
43
|
+
try:
|
|
44
|
+
v = int((config or {}).get(key, default))
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return default
|
|
47
|
+
return v if v >= low else default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- status in the cursor -----------------------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def with_status(cursor: dict, message: str, retry_after_s: float | None = None) -> dict:
|
|
54
|
+
"""`cursor` plus a `status` entry; everything else in the cursor is untouched. The engine stores it as is, so the
|
|
55
|
+
scheduler can read `status.error` / `status.retry_after` to back off."""
|
|
56
|
+
now = utcnow()
|
|
57
|
+
status = {"error": message[:300], "at": now.isoformat(timespec="seconds")}
|
|
58
|
+
if retry_after_s is not None:
|
|
59
|
+
status["retry_after"] = (now + timedelta(seconds=retry_after_s)).isoformat(timespec="seconds")
|
|
60
|
+
return {**cursor, "status": status}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def backoff_active(cursor: dict) -> bool:
|
|
64
|
+
"""True while a stored `status.retry_after` is still in the future (no request should be made)."""
|
|
65
|
+
st = cursor.get("status")
|
|
66
|
+
if not isinstance(st, dict) or not st.get("retry_after"):
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
return datetime.fromisoformat(st["retry_after"]) > utcnow()
|
|
70
|
+
except ValueError:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clean_cursor(cursor: dict | None) -> dict:
|
|
75
|
+
"""The cursor if it has this version's shape, else a fresh one (an unknown shape is re-read from scratch; units
|
|
76
|
+
dedupe by id). Always carries `v`."""
|
|
77
|
+
if isinstance(cursor, dict) and cursor.get("v") == CURSOR_VERSION:
|
|
78
|
+
return dict(cursor)
|
|
79
|
+
return {"v": CURSOR_VERSION}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- text hygiene -------------------------------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
_HANDLE = r"@\w+(?:[.-]\w+)*(?:@\w+(?:[.-]\w+)*)?"
|
|
85
|
+
_LEAD_MENTIONS = re.compile(rf"^(?:\s*{_HANDLE})+\s*")
|
|
86
|
+
_MENTION = re.compile(rf"(?<![\w/@]){_HANDLE}")
|
|
87
|
+
MENTION_MARK = "" # an adapter-inserted placeholder for a structured mention (facet / mention link)
|
|
88
|
+
_LEAD_MARKS = re.compile(rf"^(?:\s*(?:{MENTION_MARK}|{_HANDLE}))+\s*")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def finish_text(text: str, reply: bool) -> str:
|
|
92
|
+
"""Reply: drop the leading @mentions. Everywhere else a mention of another person becomes `@[user]`, so a third
|
|
93
|
+
party's handle is not stored. Collapses stray spacing."""
|
|
94
|
+
if reply:
|
|
95
|
+
text = _LEAD_MARKS.sub("", text)
|
|
96
|
+
text = text.replace(MENTION_MARK, "@[user]")
|
|
97
|
+
text = _MENTION.sub("@[user]", text)
|
|
98
|
+
return re.sub(r"[ \t]+\n", "\n", re.sub(r"[ \t]{2,}", " ", text)).strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def context_text(text: str) -> str:
|
|
102
|
+
"""Another person's text on its way into one masked context line: handles masked, spacing collapsed."""
|
|
103
|
+
return _MENTION.sub("@[user]", " ".join(str(text).replace(MENTION_MARK, "@[user]").split()))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def text_hash(text: str) -> str:
|
|
107
|
+
"""Short fingerprint of the cleaned text (a reconcile compares it to see an edit)."""
|
|
108
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:10]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reconcile_due(cursor: dict, interval_s: int, now: datetime | None = None) -> bool:
|
|
112
|
+
"""True when the last reconcile is at least `interval_s` old. A cursor that never had one is NOT due: the first
|
|
113
|
+
completed scan only stamps `reconciled_at` (it has just read the recent window anyway)."""
|
|
114
|
+
raw = cursor.get("reconciled_at")
|
|
115
|
+
try:
|
|
116
|
+
last = datetime.fromisoformat(raw) if isinstance(raw, str) else None
|
|
117
|
+
except ValueError:
|
|
118
|
+
last = None
|
|
119
|
+
return last is not None and ((now or utcnow()) - last).total_seconds() >= interval_s
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def month_key(now: datetime) -> str:
|
|
123
|
+
return now.strftime("%Y-%m")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def next_month_start(now: datetime) -> datetime:
|
|
127
|
+
y, m = (now.year + 1, 1) if now.month == 12 else (now.year, now.month + 1)
|
|
128
|
+
return datetime(y, m, 1, tzinfo=UTC)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- the scan -----------------------------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ScanResult:
|
|
136
|
+
units: list[Unit]
|
|
137
|
+
cursor: dict # the state fields (high_water / scan) advanced; no status set
|
|
138
|
+
error: NetError | None = None
|
|
139
|
+
completed: bool = False
|
|
140
|
+
fetched: int = 0
|
|
141
|
+
dropped: bool = False # a stored continuation token was rejected: the scan state was dropped
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scan(
|
|
145
|
+
cursor: dict,
|
|
146
|
+
*,
|
|
147
|
+
fetch: Callable[[str | None, int], tuple[list, str | None]],
|
|
148
|
+
marker: Callable[[object], object | None],
|
|
149
|
+
at_high_water: Callable[[object, object], bool],
|
|
150
|
+
convert: Callable[[object], list[Unit]],
|
|
151
|
+
page_size: int,
|
|
152
|
+
initial: int,
|
|
153
|
+
max_pages: int | None = None,
|
|
154
|
+
promote_on_drop: bool = False,
|
|
155
|
+
) -> ScanResult:
|
|
156
|
+
"""Walk a newest-first feed page by page.
|
|
157
|
+
|
|
158
|
+
`fetch(token, limit)` -> (items, next_token) or raises NetError. `marker(item)` -> the item's ordering mark, or
|
|
159
|
+
None for items that carry none (a pinned post). `at_high_water(item, hw)` -> True when the item is not newer than the
|
|
160
|
+
stored high-water mark (the scan is complete). `convert(item)` -> units. With no stored high-water mark the scan
|
|
161
|
+
stops after `initial` items.
|
|
162
|
+
|
|
163
|
+
A fetch that fails with 400/404/410 while it carried a continuation token means the service no longer accepts that
|
|
164
|
+
token: the `scan` state is dropped (the `high_water` mark is kept) so the next read starts a fresh scan instead of
|
|
165
|
+
retrying the same token forever. `promote_on_drop` (the paid source): on a first run that already read something,
|
|
166
|
+
the newest item seen becomes the high-water mark, so a restart does not read, and pay for, the same posts again."""
|
|
167
|
+
hw = cursor.get("high_water")
|
|
168
|
+
state = cursor.get("scan") if isinstance(cursor.get("scan"), dict) else {}
|
|
169
|
+
token = state.get("token")
|
|
170
|
+
candidate = state.get("candidate")
|
|
171
|
+
fetched = int(state.get("fetched") or 0)
|
|
172
|
+
max_pages = MAX_PAGES_PER_READ if max_pages is None else max_pages
|
|
173
|
+
units: list[Unit] = []
|
|
174
|
+
error: NetError | None = None
|
|
175
|
+
complete = False
|
|
176
|
+
dropped = False
|
|
177
|
+
pages = 0
|
|
178
|
+
while not complete:
|
|
179
|
+
if pages >= max_pages:
|
|
180
|
+
break
|
|
181
|
+
limit = page_size
|
|
182
|
+
if hw is None:
|
|
183
|
+
if fetched >= initial:
|
|
184
|
+
complete = True
|
|
185
|
+
break
|
|
186
|
+
limit = min(page_size, initial - fetched)
|
|
187
|
+
try:
|
|
188
|
+
items, nxt = fetch(token, limit)
|
|
189
|
+
except NetError as e:
|
|
190
|
+
error = e
|
|
191
|
+
dropped = token is not None and e.kind == "http" and e.status in REJECTED_TOKEN_STATUSES
|
|
192
|
+
break
|
|
193
|
+
pages += 1
|
|
194
|
+
for item in items:
|
|
195
|
+
m = marker(item)
|
|
196
|
+
if m is not None and hw is not None and at_high_water(item, hw):
|
|
197
|
+
complete = True
|
|
198
|
+
break
|
|
199
|
+
if hw is None and fetched >= initial:
|
|
200
|
+
complete = True
|
|
201
|
+
break
|
|
202
|
+
fetched += 1
|
|
203
|
+
if m is not None and candidate is None:
|
|
204
|
+
candidate = m
|
|
205
|
+
units.extend(convert(item))
|
|
206
|
+
if complete:
|
|
207
|
+
break
|
|
208
|
+
if not items or nxt is None:
|
|
209
|
+
complete = True
|
|
210
|
+
break
|
|
211
|
+
token = nxt
|
|
212
|
+
new = {k: v for k, v in cursor.items() if k not in ("scan", "status")}
|
|
213
|
+
if complete:
|
|
214
|
+
if candidate is not None:
|
|
215
|
+
new["high_water"] = candidate
|
|
216
|
+
elif dropped:
|
|
217
|
+
if promote_on_drop and hw is None and candidate is not None:
|
|
218
|
+
new["high_water"] = candidate
|
|
219
|
+
elif token is not None or candidate is not None or fetched:
|
|
220
|
+
new["scan"] = {"token": token, "candidate": candidate, "fetched": fetched}
|
|
221
|
+
return ScanResult(units, new, error, complete, fetched, dropped)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cap_seen(seen: dict, order_key: Callable[[str, object], object], limit: int = MAX_SEEN) -> dict:
|
|
225
|
+
"""The `limit` newest entries of a {ref: value} map, newest by `order_key(ref, value)`."""
|
|
226
|
+
if len(seen) <= limit:
|
|
227
|
+
return seen
|
|
228
|
+
keep = sorted(seen.items(), key=lambda kv: order_key(*kv), reverse=True)[:limit]
|
|
229
|
+
return dict(keep)
|