Files

193 lines
9.2 KiB
Python

"""Find a site's compose URL by reading the links the site already publishes.
The compose-URL table in `compose_entry` is worth 92.9% composer reachability on the five hosts it
knows and exactly 0% everywhere else, measured live over 8 logged-in sites. Two attempts to close
that gap by making the aux navigator click better were falsified in the same session (0/8 both
times, at roughly double the wall time), so the lever is not better clicking, it is getting the URL.
The generalizable source is the page itself. A site that has a composer links to it: "Start a post",
"Ask Question", "Create", "New story". Reading those anchors is precise where guessing paths is not,
because it is the site's own navigation rather than a list of shapes we hope it matches, and it
needs no per-site knowledge. It also degrades honestly: no matching anchor means no candidate, and
the caller runs exactly as it does today.
The ranking here is deliberately pure, so the part that decides where to send someone's browser is
testable without a browser. The page-reading half is one expression; the judgement half is below it.
"""
import json
import os
import re
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from typeguard import typechecked
from backend.apps.agents.browser import compose_entry
# How many links to bring back. Enough to cover a nav bar plus a sidebar; a page with more than
# this has a hundred feed links and the composer is not going to be number 401.
MAX_LINKS = 400
# How many candidates the caller may actually navigate to. Each miss costs a real page load, and
# past two the aux loop is the cheaper remaining option.
MAX_CANDIDATES = 2
# Longest a control's label can be before it is prose rather than a button. "Start a post" is 3.
MAX_LABEL_WORDS = 4
# Path segments that mean "start something new" across platforms rather than on one host: /submit
# is reddit-shaped, /questions/ask is every StackExchange, /new-story is medium-shaped, /new/text is
# tumblr-shaped, /compose is mail and chat.
#
# A WHOLE segment, never a substring. Substring matching was tried and produced garbage on the
# first live read: "post" matched inside `/explore/top-posts` and "new" inside a permalink
# `/actuallysara/823.../new-photo-of-connor...`, so the tier proposed navigating to a random blog
# post. `post` itself is deliberately absent even as a segment, because permalinks are `/post/<id>`
# on half the web and a false candidate costs a real page load.
P_PATH_HINTS: Tuple[str, ...] = (
"submit", "compose", "new", "create", "ask", "publish", "write", "share",
"new-story", "new-post", "new-thread", "new-story",
)
# What the site calls the control. Labels are the stronger signal of the two: a path can be
# incidental ("/new-york-times"), but a link a human reads as "Start a post" is one.
P_LABEL_RE = re.compile(
r"\b(start a post|create a post|new post|create post|write a post|"
r"ask (a )?question|new story|write a story|start writing|new thread|"
r"compose|create new|new message|submit a? ?(post|link|text)?|publish)\b", re.I)
# Never a composer, and several are actively destructive to wander into mid-run. Checked against
# the whole URL plus the label, because "sign out" hides behind /logout as often as it is written.
P_NEVER_RE = re.compile(
r"\b(log ?out|sign ?out|log ?in|sign ?in|sign ?up|register|settings|preferences|account|"
r"billing|subscribe|upgrade|premium|checkout|cart|delete|privacy|terms|cookie|legal|"
r"about|careers|jobs|advertise|press|help|support|download|api|developer)\b", re.I)
@typechecked
def enabled() -> bool:
"""OFF by default, unlike the compose-URL table it backs up.
The mechanism is proven: pointed at StackOverflow it returned `/questions/ask` as its first
candidate, which is exactly right. What is NOT proven is that it helps anyone end to end, and
on the eight-site sweep it did not, for reasons outside itself: three of five create-sites
publish no compose link at all (their composer is a button opening a modal with no route),
StackOverflow was behind a sign-in wall, and GitHub never armed at an upstream gate. Shipping a
tier default-on because its parts work, while its measured contribution is zero, is how latency
accretes. It turns on when a site set exists where it can win and the number says it did."""
return os.environ.get("OSW_COMPOSE_DISCOVERY", "0") != "0"
@typechecked
def discovery_expression() -> str:
"""One page read returning every link the site publishes, with the text a human would read.
Deliberately read-only: it collects hrefs and labels and touches nothing, so it is safe to run
on any page in any state, including one the user is looking at."""
return (
"(() => {"
" const out = []; const seen = new Set();"
" for (const a of document.querySelectorAll('a[href]')) {"
" const href = a.href || '';"
" if (!href || seen.has(href)) continue;"
" if (!/^https?:/i.test(href)) continue;"
" seen.add(href);"
" const label = (a.getAttribute('aria-label') || a.innerText || a.title || '')"
" .replace(/\\s+/g, ' ').trim().slice(0, 80);"
" out.push({href: href, label: label});"
f" if (out.length >= {MAX_LINKS}) break;"
" }"
" return {url: location.href, links: out};"
"})()"
)
@typechecked
def p_link_score(href: str, label: str, host: str, current_url: str) -> int:
"""How much this link looks like the way in to this site's composer. 0 means never navigate.
Scored rather than matched so the front-door compose link outranks a deeper one that merely
shares a word, which is what stops "/submit-a-tip" from beating "/submit"."""
parsed = urlparse(href)
found = compose_entry.registrable_host(parsed.netloc)
want = compose_entry.registrable_host(host)
# Off-host links go to a different company; a "share to X" button must not hijack a tumblr post.
if not found or (found != want and not found.endswith("." + want)):
return 0
path = parsed.path.strip("/").lower()
if not path:
return 0
if P_NEVER_RE.search(href) or P_NEVER_RE.search(label or ""):
return 0
# Already here. Re-navigating would remount the page and throw away whatever is on it.
if href.rstrip("/") == (current_url or "").rstrip("/"):
return 0
score = 0
# A compose control is labelled like a button, not like an article. Length is what separates
# them, and it separates them on every site at once: StackOverflow offered a QUESTION titled
# "Compose preview different from emulator" as a compose link, because a Q&A site is full of
# titles containing the word. Buttons say "Ask Question" or "Start a post".
if len((label or "").split()) <= MAX_LABEL_WORDS and P_LABEL_RE.search(label or ""):
score += 10
segments = [s for s in path.split("/") if s]
if any(seg in P_PATH_HINTS for seg in segments):
score += 6
if not score:
return 0
# A shallow path is the site's own front door to composing; a deep one is usually a specific
# item that happens to share a word. Never lets a match drop to zero.
score += max(0, 3 - len(segments))
return score
@typechecked
def rank_candidates(payload: Optional[Dict[str, object]], host: str) -> List[str]:
"""The URLs worth trying, best first, capped. Empty when the page publishes nothing composer-ish.
Takes the raw page read so the whole decision is one pure function over data, which is the only
reason this is testable without driving a browser."""
if not isinstance(payload, dict):
return []
links = payload.get("links")
if not isinstance(links, list):
return []
current = str(payload.get("url") or "")
scored: List[Tuple[int, str]] = []
seen: set = set()
for row in links:
if not isinstance(row, dict):
continue
href = str(row.get("href") or "")
label = str(row.get("label") or "")
if not href or href in seen:
continue
seen.add(href)
score = p_link_score(href, label, host, current)
if score > 0:
scored.append((score, href))
# Sort by score, then by URL so a tie is deterministic across runs rather than DOM-order luck.
scored.sort(key=lambda pair: (-pair[0], pair[1]))
return [href for _, href in scored[:MAX_CANDIDATES]]
@typechecked
def parse_page_read(raw: object) -> Optional[Dict[str, object]]:
"""The evaluate result, whatever shape the bridge handed back.
BrowserEvaluate returns the value directly on some paths and JSON in a text field on others, and
a discovery tier that silently sees nothing looks identical to a site with no compose link."""
if isinstance(raw, dict) and "links" in raw:
return raw
if isinstance(raw, dict):
for key in ("result", "value", "text"):
inner = raw.get(key)
if isinstance(inner, dict) and "links" in inner:
return inner
if isinstance(inner, str) and inner.strip().startswith("{"):
try:
parsed = json.loads(inner)
except ValueError:
continue
if isinstance(parsed, dict) and "links" in parsed:
return parsed
return None