Expand description
Pull the cited sources out of an AI answer.
ChatGPT, Perplexity, Claude and Google AI Overviews all attach sources to the answers
they write, but they attach them differently: an HTML anchor with a site-name chip, a
numbered marker like [3] that links out, a Markdown link in a copied answer, a
footnote definition at the bottom, or a bare URL in plain text. Anyone measuring
which pages AI answers cite has to read all of those forms before counting anything.
This crate does that reading. Give extract the HTML or Markdown of an answer and
it returns every absolute http(s) link it can find, in document order, each tagged
with the CitationForm it was found in, its visible label, and the numeric marker
when the label is one. canonical then strips the fragment and the click-tracking
query keys (utm_*, fbclid, gclid, srsltid and the like) so that two citations
of the same page compare equal, and tally counts citations per host.
What it deliberately does not do: fetch anything, resolve relative links (an answer
copied out of a chat has no base URL, and a relative link in one is almost never a
citation), or guess a public-suffix list. host strips a leading www. and nothing
else; if you need co.uk-aware grouping, map the hosts yourself.
No dependencies, no unsafe, no allocation beyond the returned strings.
Written as the measurement side of Seen in AI Answers, a page engine built around the question of what AI answers cite. The engine shapes pages to be cited; this crate reads what an answer actually cited, so the two can be compared once a citation sample exists.
§Example
use ai_answer_citations::{extract, canonical, tally, CitationForm};
let answer = r#"
<p>Boil the kettle first.<a href="https://example.com/tea?utm_source=chatgpt.com">[1]</a>
A second view is at <a href="https://www.example.com/tea#steep">Example</a>.</p>
Also see [the guide](https://guide.example.org/tea) and https://example.com/tea.
"#;
let cites = extract(answer);
assert_eq!(cites.len(), 4);
assert_eq!(cites[0].form, CitationForm::HtmlAnchor);
assert_eq!(cites[0].marker, Some(1));
assert_eq!(cites[2].form, CitationForm::MarkdownLink);
assert_eq!(cites[3].form, CitationForm::BareUrl);
// Tracking keys and fragments go; the three example.com links are one page.
assert_eq!(canonical(&cites[0].url), "https://example.com/tea");
assert_eq!(canonical(&cites[1].url), "https://www.example.com/tea");
let counts = tally(&cites);
assert_eq!(counts[0].host, "example.com");
assert_eq!(counts[0].citations, 3);
assert_eq!(counts[1].host, "guide.example.org");An answer can only cite a page that its engine’s crawler was allowed to fetch. What OpenAI, Cloudflare, Perplexity and Google document about AI crawler access, with a robots.txt checker that runs in the browser, is collected at https://seeninaianswers.com/how-to-get-cited-in-ai-overviews/.
Structs§
Enums§
- Citation
Form - Where in the answer a citation was found.
Constants§
- TRACKING_
KEYS - Query keys that identify a click, not a page. Compared ASCII case-insensitively;
any key beginning
utm_is also dropped.
Functions§
- canonical
- Lower-case scheme and host, drop a default port, drop the fragment, drop tracking query keys. Everything else — path case, remaining query order, trailing slash — is kept, because on a real server those can name different pages.
- chatgpt_
tagged - Was this link tagged by ChatGPT? ChatGPT appends
utm_source=chatgpt.comto the links it cites, so the tag survives into whatever the user copied or the referrer log shows. - distinct_
pages - Distinct pages cited, by
canonicalURL, in first-seen order. - extract
- Every absolute
http(s)link intext, in document order. - host
- The host of an absolute
http(s)URL, lower-cased, without port, userinfo or a leadingwww..Nonefor anything else. - tally
- Citations per host, most-cited first, ties broken by host name.