Ctrl K
ComunidadGuías…

packages/robots.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
 * robots.txt as RFC 9309 reads it, for PolkaRenderer (/bot). Pure and without
 * dependencies: the app (a page's title for «Сохранить как ссылку») and the
 * renderer (every /render and /fetch, from the machine that makes the
 * request) use the same rules; the renderer image copies this file.
 */

export type RobotsRule = { allow: boolean; pattern: string };
/** unreachable: 5xx or no answer, which RFC 9309 treats as «disallow all» for now. */
export type Robots = { rules: RobotsRule[] } | { unreachable: true };

export const ROBOTS_TOKEN = "polkarenderer";

/** The rules of the groups for our token, or of «*» when none names us. */
export function parseRobots(text: string, token = ROBOTS_TOKEN): RobotsRule[] {
  const groups: Array<{ agents: string[]; rules: RobotsRule[] }> = [];
  let current: { agents: string[]; rules: RobotsRule[] } | null = null;
  let lastWasAgent = false;
  for (const raw of text.split(/\r\n|\r|\n/)) {
    const line = raw.replace(/#.*/, "").trim();
    const match = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
    if (!match) continue;
    const key = match[1].toLowerCase();
    const value = match[2].trim();
    if (key === "user-agent") {
      if (!current || !lastWasAgent) {
        current = { agents: [], rules: [] };
        groups.push(current);
      }
      current.agents.push(value.toLowerCase());
      lastWasAgent = true;
      continue;
    }
    lastWasAgent = false;
    if (!current || (key !== "allow" && key !== "disallow")) continue;
    // An empty Disallow allows everything; an empty Allow says nothing.
    if (!value) continue;
    current.rules.push({ allow: key === "allow", pattern: value });
  }
  const ours = groups.filter((g) => g.agents.some((agent) => agent === token));
  return (ours.length ? ours : groups.filter((g) => g.agents.includes("*"))).flatMap((g) => g.rules);
}

/**
 * A robots.txt pattern against a path: `*` is any run of characters, a final
 * `$` anchors the end. No regular expression: the pattern is whatever the
 * site serves, and a rule of many `*a` parts backtracks without end. Each
 * part is searched once, leftmost, so the work is bounded by the path length
 * times the pattern length.
 */
function matches(pattern: string, path: string) {
  const anchored = pattern.endsWith("$");
  const parts = (anchored ? pattern.slice(0, -1) : pattern).split("*");
  if (parts.length === 1) return anchored ? path === parts[0] : path.startsWith(parts[0]);
  const first = parts[0];
  const last = parts[parts.length - 1];
  if (!path.startsWith(first)) return false;
  let at = first.length;
  for (const part of parts.slice(1, -1)) {
    const found = path.indexOf(part, at);
    if (found === -1) return false;
    at = found + part.length;
  }
  if (anchored) return path.length - last.length >= at && path.endsWith(last);
  return path.indexOf(last, at) !== -1;
}

const normalize = (value: string) => {
  try {
    return decodeURIComponent(value);
  } catch {
    return value;
  }
};

/** RFC 9309: the longest matching rule wins; Allow wins a tie. */
export function robotsAllow(robots: Robots, url: URL): boolean {
  if ("unreachable" in robots) return false;
  const path = normalize(url.pathname + url.search);
  let best: RobotsRule | null = null;
  for (const rule of robots.rules) {
    if (!matches(normalize(rule.pattern), path)) continue;
    if (
      !best ||
      rule.pattern.length > best.pattern.length ||
      (rule.pattern.length === best.pattern.length && rule.allow)
    )
      best = rule;
  }
  return !best || best.allow;
}

/** How an HTTP answer for /robots.txt becomes rules (RFC 9309 § 2.3.1). */
export function robotsFromAnswer(status: number | null, body: string): Robots {
  if (status !== null && status >= 200 && status < 300) return { rules: parseRobots(body) };
  if (status !== null && status >= 400 && status < 500) return { rules: [] };
  return { unreachable: true };
}

/** A per-host cache: an hour for answers, five minutes for failures. */
export function robotsCache(fetchRobots: (origin: string) => Promise<Robots>, max = 5000) {
  const cache = new Map<string, { robots: Robots; expires: number }>();
  return {
    async get(url: URL, now = Date.now()): Promise<Robots> {
      const key = url.host.toLowerCase();
      const cached = cache.get(key);
      if (cached && cached.expires > now) return cached.robots;
      const robots = await fetchRobots(`https://${key}`);
      cache.set(key, { robots, expires: now + ("unreachable" in robots ? 5 * 60_000 : 60 * 60_000) });
      if (cache.size > max) cache.delete(cache.keys().next().value!);
      return robots;
    },
    clear: () => cache.clear(),
  };
}