activity-frames 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.
@@ -0,0 +1,416 @@
1
+ """Deterministic URL -> page reference parsing.
2
+
3
+ Turns raw browser URLs into typed page references an agent can read at
4
+ a glance: "profile: najmuzzaman" instead of a 120-char URL. Pure string
5
+ parsing, no network, no AI, no guessing about intent.
6
+
7
+ Resolution order (all deterministic):
8
+ 1. a site-specific parser (exact host, then apex domain),
9
+ 2. a generic search-parameter detector (?q=/?query=),
10
+ 3. subdomain/path heuristics (dashboard, sign-in, email, calendar,
11
+ meeting) that type common infrastructure pages without a bespoke
12
+ parser,
13
+ 4. a total generic fallback ({kind: "page", domain, path}).
14
+
15
+ Every URL maps to something; typing is never lossy.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from urllib.parse import parse_qs, unquote, urlsplit
21
+
22
+
23
+ @dataclass
24
+ class PageRef:
25
+ kind: str # e.g. "profile", "search", "repo", "video", "page"
26
+ domain: str # normalized host, no "www."
27
+ entity: str | None = None # the human-relevant identifier, if any
28
+ extra: dict = field(default_factory=dict)
29
+
30
+ def __post_init__(self):
31
+ if self.entity is not None:
32
+ # collapse whitespace runs and trim; drop empties
33
+ self.entity = " ".join(str(self.entity).split()) or None
34
+
35
+ def label(self) -> str:
36
+ if self.entity:
37
+ return f"{self.kind}: {self.entity}"
38
+ return self.kind
39
+
40
+
41
+ def _host(url: str) -> str:
42
+ try:
43
+ host = urlsplit(url).hostname or ""
44
+ except ValueError:
45
+ return ""
46
+ return host[4:] if host.startswith("www.") else host
47
+
48
+
49
+ def _parts(url: str) -> list[str]:
50
+ try:
51
+ return [p for p in urlsplit(url).path.split("/") if p]
52
+ except ValueError:
53
+ return []
54
+
55
+
56
+ def _query(url: str) -> dict[str, list[str]]:
57
+ try:
58
+ return parse_qs(urlsplit(url).query)
59
+ except ValueError:
60
+ return {}
61
+
62
+
63
+ def _apex(domain: str) -> str:
64
+ bits = domain.split(".")
65
+ return ".".join(bits[-2:]) if len(bits) > 2 else domain
66
+
67
+
68
+ def _slug(s: str) -> str:
69
+ """Turn a URL slug into readable text: 'my-page-x9f' -> 'my page x9f'."""
70
+ return unquote(s).replace("-", " ").replace("_", " ").strip()
71
+
72
+
73
+ def parse_url(url: str) -> PageRef:
74
+ """Parse a URL into a PageRef. Never raises; never returns None."""
75
+ domain = _host(url)
76
+ if not domain:
77
+ return PageRef(kind="page", domain="", entity=None)
78
+ parts = _parts(url)
79
+ q = _query(url)
80
+
81
+ parser = _SITE_PARSERS.get(domain) or _SITE_PARSERS.get(_apex(domain))
82
+ if parser:
83
+ ref = parser(domain, parts, q)
84
+ if ref:
85
+ return ref
86
+
87
+ # Generic search detection (?q= / ?query=): meaningful on any site.
88
+ for key in ("q", "query", "search_query"):
89
+ if key in q and q[key] and q[key][0].strip():
90
+ return PageRef(kind="search", domain=domain, entity=unquote(q[key][0]).strip())
91
+
92
+ # Subdomain / path heuristics: type common infrastructure pages.
93
+ heur = _heuristic(domain, parts)
94
+ if heur:
95
+ return heur
96
+
97
+ # Generic fallback: first path segment as context.
98
+ entity = parts[0] if parts else None
99
+ return PageRef(kind="page", domain=domain, entity=entity)
100
+
101
+
102
+ # ---- Heuristic layer (applies to any site without a bespoke parser) ----
103
+
104
+ _SIGNIN_HINTS = frozenset(
105
+ {"login", "signin", "sign-in", "sign_in", "auth", "oauth", "sso", "logout"}
106
+ )
107
+
108
+
109
+ def _heuristic(domain: str, parts: list[str]) -> PageRef | None:
110
+ first = parts[0].lower() if parts else ""
111
+ # Whole path segments only: "auth" must not match "/authors",
112
+ # "sso" must not match "/lessons".
113
+ segs = {p.lower() for p in parts[:3]}
114
+
115
+ # Authentication pages, by host or by path segment.
116
+ if (
117
+ domain.startswith(("accounts.", "login.", "auth.", "signin."))
118
+ or segs & _SIGNIN_HINTS
119
+ ):
120
+ return PageRef(kind="sign_in", domain=domain)
121
+ # Operational surfaces exposed on conventional subdomains.
122
+ if domain.startswith(("dashboard.", "app.", "console.", "admin.", "portal.")):
123
+ return PageRef(kind="dashboard", domain=domain,
124
+ entity=parts[0] if parts else None)
125
+ if first in ("dashboard", "admin", "console", "settings"):
126
+ return PageRef(kind="dashboard", domain=domain, entity=first)
127
+ if domain.startswith("mail.") or first in ("mail", "inbox"):
128
+ return PageRef(kind="email", domain=domain)
129
+ if domain.startswith("calendar.") or first == "calendar":
130
+ return PageRef(kind="calendar", domain=domain)
131
+ if domain.startswith(("meet.", "zoom.")) or "zoom.us" in domain:
132
+ return PageRef(kind="meeting", domain=domain)
133
+ return None
134
+
135
+
136
+ # ---- Site parsers (each returns PageRef or None to fall through) ----
137
+
138
+ def _linkedin(domain, parts, q):
139
+ if not parts:
140
+ return PageRef(kind="feed", domain=domain)
141
+ head = parts[0]
142
+ if head == "in" and len(parts) > 1:
143
+ return PageRef(kind="profile", domain=domain, entity=unquote(parts[1]))
144
+ if head == "company" and len(parts) > 1:
145
+ return PageRef(kind="company", domain=domain, entity=unquote(parts[1]))
146
+ if head == "search":
147
+ kw = q.get("keywords", [""])[0]
148
+ what = parts[2] if len(parts) > 2 else "results"
149
+ return PageRef(kind=f"{what}_search", domain=domain, entity=unquote(kw) or None)
150
+ if head == "jobs":
151
+ return PageRef(kind="jobs", domain=domain)
152
+ if head == "feed":
153
+ return PageRef(kind="feed", domain=domain)
154
+ if head == "messaging":
155
+ return PageRef(kind="messaging", domain=domain)
156
+ if head == "mynetwork":
157
+ return PageRef(kind="network", domain=domain)
158
+ if head == "notifications":
159
+ return PageRef(kind="notifications", domain=domain)
160
+ if head == "posts" and len(parts) > 1:
161
+ return PageRef(kind="post", domain=domain, entity=_slug(parts[1])[:50] or None)
162
+ if head in ("uas", "login", "checkpoint"):
163
+ return PageRef(kind="sign_in", domain=domain)
164
+ return None
165
+
166
+
167
+ def _github(domain, parts, q):
168
+ if not parts:
169
+ return PageRef(kind="home", domain=domain)
170
+ if len(parts) == 1:
171
+ return PageRef(kind="user", domain=domain, entity=parts[0])
172
+ repo = f"{parts[0]}/{parts[1]}"
173
+ if len(parts) >= 4 and parts[2] == "pull":
174
+ return PageRef(kind="pull_request", domain=domain, entity=f"{repo}#{parts[3]}")
175
+ if len(parts) >= 4 and parts[2] == "issues":
176
+ return PageRef(kind="issue", domain=domain, entity=f"{repo}#{parts[3]}")
177
+ if len(parts) >= 3 and parts[2] in ("commits", "commit"):
178
+ return PageRef(kind="commits", domain=domain, entity=repo)
179
+ if len(parts) >= 3 and parts[2] in ("blob", "tree"):
180
+ return PageRef(kind="code", domain=domain, entity=repo)
181
+ return PageRef(kind="repo", domain=domain, entity=repo)
182
+
183
+
184
+ def _google(domain, parts, q):
185
+ if "q" in q and q["q"] and q["q"][0].strip():
186
+ return PageRef(kind="search", domain=domain, entity=unquote(q["q"][0]).strip())
187
+ if parts and parts[0] == "maps":
188
+ if len(parts) >= 2 and parts[1] == "place":
189
+ return PageRef(kind="map_place", domain=domain,
190
+ entity=_slug(parts[2]) if len(parts) > 2 else None)
191
+ if len(parts) >= 2 and parts[1] == "dir":
192
+ return PageRef(kind="map_directions", domain=domain)
193
+ return PageRef(kind="map", domain=domain)
194
+ if parts and parts[0] == "search":
195
+ return None # let search-param detector handle
196
+ return None
197
+
198
+
199
+ def _google_docs(domain, parts, q):
200
+ kinds = {"document": "doc", "spreadsheets": "sheet", "presentation": "slides", "forms": "form"}
201
+ if parts and parts[0] in kinds:
202
+ return PageRef(kind=kinds[parts[0]], domain=domain)
203
+ return None
204
+
205
+
206
+ def _gmail(domain, parts, q):
207
+ if parts and parts[0] == "mail":
208
+ return PageRef(kind="email", domain=domain)
209
+ return None
210
+
211
+
212
+ def _youtube(domain, parts, q):
213
+ if parts and parts[0] == "watch":
214
+ return PageRef(kind="video", domain=domain, entity=q.get("v", [None])[0])
215
+ if parts and parts[0] == "results":
216
+ return PageRef(kind="search", domain=domain,
217
+ entity=unquote(q.get("search_query", [""])[0]) or None)
218
+ if parts and parts[0].startswith("@"):
219
+ return PageRef(kind="channel", domain=domain, entity=parts[0])
220
+ if parts and parts[0] == "shorts" and len(parts) > 1:
221
+ return PageRef(kind="video", domain=domain, entity=parts[1])
222
+ return None
223
+
224
+
225
+ def _x(domain, parts, q):
226
+ if not parts:
227
+ return PageRef(kind="feed", domain=domain)
228
+ if parts[0] in ("home", "explore"):
229
+ return PageRef(kind="feed", domain=domain)
230
+ if parts[0] == "notifications":
231
+ return PageRef(kind="notifications", domain=domain)
232
+ if parts[0] == "messages":
233
+ return PageRef(kind="messages", domain=domain)
234
+ if parts[0] == "search":
235
+ return PageRef(kind="search", domain=domain,
236
+ entity=unquote(q.get("q", [""])[0]) or None)
237
+ if parts[0] == "i":
238
+ sub = parts[1] if len(parts) > 1 else ""
239
+ if sub == "chat":
240
+ return PageRef(kind="messages", domain=domain)
241
+ if sub == "flow":
242
+ return PageRef(kind="sign_in", domain=domain)
243
+ return PageRef(kind="page", domain=domain, entity=sub or "i")
244
+ if parts[0] in ("settings", "compose", "jobs", "logout"):
245
+ return PageRef(kind="page", domain=domain, entity=parts[0])
246
+ if len(parts) >= 3 and parts[1] == "status":
247
+ return PageRef(kind="post", domain=domain, entity=parts[0])
248
+ return PageRef(kind="profile", domain=domain, entity=parts[0])
249
+
250
+
251
+ def _instagram(domain, parts, q):
252
+ if not parts:
253
+ return PageRef(kind="feed", domain=domain)
254
+ head = parts[0]
255
+ if head == "p" and len(parts) > 1:
256
+ return PageRef(kind="post", domain=domain, entity=parts[1])
257
+ if head == "reel" and len(parts) > 1:
258
+ return PageRef(kind="reel", domain=domain, entity=parts[1])
259
+ if head == "reels":
260
+ return PageRef(kind="reels", domain=domain)
261
+ if head == "stories" and len(parts) > 1:
262
+ return PageRef(kind="story", domain=domain, entity=parts[1])
263
+ if head == "explore":
264
+ return PageRef(kind="explore", domain=domain)
265
+ if head == "direct":
266
+ return PageRef(kind="messages", domain=domain)
267
+ if head in ("accounts", "emails"):
268
+ return PageRef(kind="sign_in", domain=domain)
269
+ return PageRef(kind="profile", domain=domain, entity=head)
270
+
271
+
272
+ def _reddit(domain, parts, q):
273
+ if not parts:
274
+ return PageRef(kind="feed", domain=domain)
275
+ head = parts[0]
276
+ if head == "r" and len(parts) >= 2:
277
+ if len(parts) >= 5 and parts[2] == "comments":
278
+ title = _slug(parts[4])[:50] if len(parts) > 4 else None
279
+ return PageRef(kind="post", domain=domain, entity=title or f"r/{parts[1]}")
280
+ return PageRef(kind="subreddit", domain=domain, entity=f"r/{parts[1]}")
281
+ if head in ("user", "u") and len(parts) >= 2:
282
+ return PageRef(kind="profile", domain=domain, entity=f"u/{parts[1]}")
283
+ return None
284
+
285
+
286
+ def _stackoverflow(domain, parts, q):
287
+ if len(parts) >= 2 and parts[0] == "questions":
288
+ return PageRef(kind="question", domain=domain,
289
+ entity=parts[2].replace("-", " ") if len(parts) > 2 else parts[1])
290
+ return None
291
+
292
+
293
+ def _calendly(domain, parts, q):
294
+ if parts:
295
+ return PageRef(kind="booking", domain=domain, entity="/".join(parts[:2]))
296
+ return None
297
+
298
+
299
+ def _luma(domain, parts, q):
300
+ if not parts:
301
+ return PageRef(kind="home", domain=domain)
302
+ if parts[0] in ("home", "discover", "explore", "signin", "user", "settings"):
303
+ return PageRef(kind=parts[0] if parts[0] != "signin" else "sign_in", domain=domain)
304
+ # lu.ma/<eventid> is a short event slug
305
+ return PageRef(kind="event", domain=domain, entity=parts[0])
306
+
307
+
308
+ def _partiful(domain, parts, q):
309
+ if parts and parts[0] == "e" and len(parts) > 1:
310
+ return PageRef(kind="event", domain=domain, entity=parts[1])
311
+ if parts:
312
+ return PageRef(kind="event", domain=domain, entity=parts[0])
313
+ return PageRef(kind="home", domain=domain)
314
+
315
+
316
+ def _producthunt(domain, parts, q):
317
+ if len(parts) >= 2 and parts[0] in ("posts", "products"):
318
+ return PageRef(kind="product", domain=domain, entity=_slug(parts[1]))
319
+ if len(parts) >= 2 and parts[0] == "@":
320
+ return PageRef(kind="profile", domain=domain, entity=parts[1])
321
+ return None
322
+
323
+
324
+ def _google_meet(domain, parts, q):
325
+ return PageRef(kind="meeting", domain=domain, entity=parts[0] if parts else None)
326
+
327
+
328
+ def _google_calendar(domain, parts, q):
329
+ return PageRef(kind="calendar", domain=domain)
330
+
331
+
332
+ def _vercel(domain, parts, q):
333
+ if not parts:
334
+ return PageRef(kind="dashboard", domain=domain)
335
+ if len(parts) >= 2:
336
+ return PageRef(kind="project", domain=domain, entity=f"{parts[0]}/{parts[1]}")
337
+ return PageRef(kind="dashboard", domain=domain, entity=parts[0])
338
+
339
+
340
+ def _supabase(domain, parts, q):
341
+ if len(parts) >= 3 and parts[0] == "dashboard" and parts[1] == "project":
342
+ return PageRef(kind="project", domain=domain, entity=parts[2])
343
+ if parts and parts[0] == "dashboard":
344
+ return PageRef(kind="dashboard", domain=domain)
345
+ return None
346
+
347
+
348
+ def _stripe(domain, parts, q):
349
+ return PageRef(kind="dashboard", domain=domain,
350
+ entity=parts[0] if parts else None)
351
+
352
+
353
+ def _discord(domain, parts, q):
354
+ if len(parts) >= 2 and parts[0] == "channels":
355
+ if parts[1] == "@me":
356
+ return PageRef(kind="messages", domain=domain)
357
+ return PageRef(kind="channel", domain=domain, entity=parts[1])
358
+ return PageRef(kind="app", domain=domain)
359
+
360
+
361
+ def _notion(domain, parts, q):
362
+ if parts:
363
+ # Notion slugs end with the page id; the readable part is the prefix.
364
+ last = parts[-1]
365
+ slug = last.rsplit("-", 1)[0] if "-" in last else last
366
+ return PageRef(kind="doc", domain=domain, entity=_slug(slug) or None)
367
+ return PageRef(kind="home", domain=domain)
368
+
369
+
370
+ def _figma(domain, parts, q):
371
+ if len(parts) >= 3 and parts[0] in ("file", "design", "board"):
372
+ return PageRef(kind="design", domain=domain, entity=_slug(parts[2]))
373
+ return None
374
+
375
+
376
+ def _chat_ai(domain, parts, q):
377
+ return PageRef(kind="ai_chat", domain=domain)
378
+
379
+
380
+ def _localhost(domain, parts, q):
381
+ return PageRef(kind="local_dev", domain=domain, entity="/".join(parts[:2]) or None)
382
+
383
+
384
+ _SITE_PARSERS = {
385
+ "linkedin.com": _linkedin,
386
+ "github.com": _github,
387
+ "google.com": _google,
388
+ "docs.google.com": _google_docs,
389
+ "mail.google.com": _gmail,
390
+ "meet.google.com": _google_meet,
391
+ "calendar.google.com": _google_calendar,
392
+ "youtube.com": _youtube,
393
+ "x.com": _x,
394
+ "twitter.com": _x,
395
+ "instagram.com": _instagram,
396
+ "reddit.com": _reddit,
397
+ "stackoverflow.com": _stackoverflow,
398
+ "calendly.com": _calendly,
399
+ "luma.com": _luma,
400
+ "lu.ma": _luma,
401
+ "partiful.com": _partiful,
402
+ "producthunt.com": _producthunt,
403
+ "vercel.com": _vercel,
404
+ "supabase.com": _supabase,
405
+ "dashboard.stripe.com": _stripe,
406
+ "discord.com": _discord,
407
+ "notion.so": _notion,
408
+ "notion.com": _notion,
409
+ "app.notion.com": _notion,
410
+ "figma.com": _figma,
411
+ "chatgpt.com": _chat_ai,
412
+ "chat.openai.com": _chat_ai,
413
+ "claude.ai": _chat_ai,
414
+ "localhost": _localhost,
415
+ "127.0.0.1": _localhost,
416
+ }