Ctrl K
ComunidadGuías…

packages/contracts/link-providers.ts

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

Archivo original ↗Preguntar a KiBot
/*
 * The one table of link sources Полка recognises (docs/specs/URL_IMPORT_SUPPORT.md,
 * docs/research/HEADLESS_PUBLIC_LINKS.md). Server, web and the renderer read it,
 * so «which links the server may open» cannot drift between them. No
 * dependencies: the renderer image copies this file as it is.
 *
 * Each source has one route (per path where a provider has several):
 * - extension: never opened by Полка's servers: robots.txt closes the path
 *   (claude.site, AI Studio apps) or the page is behind a login or bot wall
 *   (v0, Perplexity, Claude chat snapshots). The user brings the content: their
 *   agent over MCP, a downloaded file, the «На Полку» extension, or the link
 *   itself as a bookmark.
 * - server-fetch: one plain HTTP request from the renderer's machine, no
 *   browser (ChatGPT share and canvas pages: the conversation is in the
 *   server-rendered HTML, and robots.txt allows /share/ and /canvas/shared/).
 * - server-try: the renderer opens the page once in its browser; a bot check
 *   is source_blocked, never retried or worked around (Claude artifacts).
 * - server-api: read through the provider's official API (GitHub Gist).
 * - server-render: a public site whose HTML is an empty SPA shell; the
 *   isolated renderer opens it once, after robots.txt allows it, and saves a
 *   DOM snapshot. Only these hosts: never «any URL into a browser».
 * - html: any other public HTTPS page, downloaded without running its code.
 */

export type LinkRoute = "extension" | "server-fetch" | "server-try" | "server-api" | "server-render" | "html";
export type LinkProviderId =
  | "claude"
  | "chatgpt"
  | "v0"
  | "perplexity"
  | "aistudio"
  | "gemini"
  | "gist"
  | "lovable"
  | "bolt"
  | "replit"
  | "github-pages";

export type LinkProvider = {
  id: LinkProviderId;
  /** The service's name as people say it. */
  name: string;
  route: LinkRoute;
  /** The badge shown for the service: letters on a colour, no brand logo. */
  mark: string;
  color: string;
  /** Exact hosts (lower case, no www.). */
  hosts: readonly string[];
  /** Any subdomain of these (".lovable.app" matches "x.lovable.app", not "lovable.app"). */
  suffixes?: readonly string[];
  /** Paths of public share links. A matching host with another path is a page behind a login. */
  publicPath?: RegExp;
  /** A route for some paths instead of `route` (the first match wins). */
  pathRoutes?: ReadonlyArray<readonly [RegExp, LinkRoute]>;
  /** The default title of a link saved as a bookmark. */
  title: (path: string, host: string) => string;
};

const byPath = (entries: Array<[RegExp, string]>, fallback: string) => (path: string) =>
  entries.find(([pattern]) => pattern.test(path))?.[1] ?? fallback;
const hostTitle = (_path: string, host: string) => host;

export const LINK_PROVIDERS: readonly LinkProvider[] = [
  {
    // claude.site: robots.txt «Disallow: /» for everyone.
    id: "claude",
    name: "Claude",
    route: "extension",
    mark: "Cl",
    color: "#c96442",
    hosts: ["claude.site"],
    suffixes: [".claude.site"],
    title: () => "Артефакт Claude",
  },
  {
    id: "claude",
    name: "Claude",
    route: "extension",
    mark: "Cl",
    color: "#c96442",
    hosts: ["claude.ai"],
    publicPath: /^\/(?:artifact|public\/artifacts|share|code\/artifact)\/[^/]+/,
    // robots.txt allows these pages (only /api/* is closed); Cloudflare usually
    // answers a data-centre IP with a challenge, and then the card offers the rest.
    pathRoutes: [[/^\/(?:artifact|public\/artifacts)\/[A-Za-z0-9-]{6,80}\/?$/, "server-try"]],
    title: byPath([[/^\/share\//, "Чат Claude"]], "Артефакт Claude"),
  },
  {
    id: "chatgpt",
    name: "ChatGPT",
    route: "extension",
    mark: "GP",
    color: "#10a37f",
    hosts: ["chatgpt.com", "chat.openai.com"],
    publicPath: /^\/(?:share|canvas\/shared)\/[^/]+/,
    // robots.txt: «Allow: /share/» and «Allow: /canvas/shared/» for every agent.
    pathRoutes: [[/^\/(?:share|canvas\/shared)\/[A-Za-z0-9-]{6,80}\/?$/, "server-fetch"]],
    title: byPath([[/^\/canvas\//, "Canvas ChatGPT"]], "Чат ChatGPT"),
  },
  {
    id: "v0",
    name: "v0",
    route: "extension",
    mark: "v0",
    color: "#111111",
    hosts: ["v0.app", "v0.dev"],
    title: () => "Чат v0",
  },
  {
    id: "perplexity",
    name: "Perplexity",
    route: "extension",
    mark: "Px",
    color: "#1f6f78",
    hosts: ["perplexity.ai"],
    title: () => "Страница Perplexity",
  },
  {
    id: "aistudio",
    name: "Google AI Studio",
    route: "extension",
    mark: "AI",
    color: "#3367d6",
    hosts: ["aistudio.google.com"],
    title: () => "Приложение AI Studio",
  },
  {
    id: "gemini",
    name: "Gemini",
    route: "server-render",
    mark: "Ge",
    color: "#4f6bed",
    hosts: ["gemini.google.com", "g.co"],
    publicPath: /^\/(?:gemini\/)?share\/[A-Za-z0-9_-]+\/?$/,
    title: () => "Чат Gemini",
  },
  {
    id: "gist",
    name: "GitHub Gist",
    route: "server-api",
    mark: "Gi",
    color: "#24292f",
    hosts: ["gist.github.com", "gistpreview.github.io"],
    title: () => "Gist",
  },
  {
    id: "lovable",
    name: "Lovable",
    route: "server-render",
    mark: "Lo",
    color: "#e0457b",
    hosts: [],
    suffixes: [".lovable.app"],
    title: hostTitle,
  },
  {
    id: "bolt",
    name: "Bolt",
    route: "server-render",
    mark: "Bo",
    color: "#1b64f2",
    hosts: [],
    suffixes: [".bolt.host"],
    title: hostTitle,
  },
  {
    id: "replit",
    name: "Replit",
    route: "server-render",
    mark: "Re",
    color: "#f26207",
    hosts: [],
    suffixes: [".replit.app"],
    title: hostTitle,
  },
  {
    id: "github-pages",
    name: "GitHub Pages",
    route: "server-render",
    mark: "GH",
    color: "#24292f",
    hosts: [],
    suffixes: [".github.io"],
    title: hostTitle,
  },
];

export type LinkMatch = {
  url: URL;
  /** Lower case, without www. */
  host: string;
  provider: LinkProvider | null;
  /** html for an unknown host; the provider's route otherwise. */
  route: LinkRoute;
  /** A known provider's host with a path that is not a public share link. */
  closed: boolean;
};

const hostOf = (url: URL) => url.hostname.toLowerCase().replace(/^www\./, "");

/** Which provider a link belongs to and how Полка may bring it. Null: not an https URL. */
export function matchLink(input: string | URL): LinkMatch | null {
  let url: URL;
  try {
    url = typeof input === "string" ? new URL(input.trim()) : new URL(input.href);
  } catch {
    return null;
  }
  if (url.protocol !== "https:") return null;
  const host = hostOf(url);
  // Exact hosts win over suffixes: gistpreview.github.io is a gist, not a GitHub Pages site.
  const provider =
    LINK_PROVIDERS.find((p) => p.hosts.includes(host)) ??
    LINK_PROVIDERS.find((p) => p.suffixes?.some((s) => host.endsWith(s) && host.length > s.length)) ??
    null;
  if (!provider) return { url, host, provider: null, route: "html", closed: false };
  const closed = !!provider.publicPath && !provider.publicPath.test(url.pathname);
  // g.co is a Google short-link host: only its Gemini share path belongs to Gemini.
  if (host === "g.co" && closed) return { url, host, provider: null, route: "html", closed: false };
  const route = provider.pathRoutes?.find(([pattern]) => pattern.test(url.pathname))?.[1] ?? provider.route;
  return { url, host, provider, route, closed };
}

/** Pages the renderer may open in its browser at all, whatever redirects lead there. */
export function renderable(input: string | URL): boolean {
  const match = matchLink(input);
  return !!match && (match.route === "server-render" || match.route === "server-try") && !match.closed;
}

/** Pages the renderer may fetch without a browser (POST /fetch). */
export function fetchable(input: string | URL): boolean {
  const match = matchLink(input);
  return !!match && match.route === "server-fetch" && !match.closed;
}

/** A bookmark's default title: «Артефакт Claude», «Чат ChatGPT», or the host. */
export function defaultLinkTitle(input: string | URL): string {
  const match = matchLink(input);
  if (!match) return "Ссылка";
  return match.provider ? match.provider.title(match.url.pathname, match.host) : match.host;
}