extract-cite 1.0.0 → 1.0.1

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,36 @@
1
+ import { ContentCheck } from './types';
2
+ export interface ContentInput {
3
+ /** The first `words` words of the content, for the prompt. */
4
+ text: string;
5
+ /** How many words that is. */
6
+ words: number;
7
+ /** One line per block of the page: selector, word count, opening words. */
8
+ outline: string;
9
+ /** The parsed page, to check the model's selectors against. */
10
+ document: any;
11
+ }
12
+ /** The first `max` words of `text`. */
13
+ export declare function firstWords(text: string, max: number): string;
14
+ /** HTML or text → plain text. */
15
+ export declare function contentToText(content: string): string;
16
+ /** `tag#id.class.class`, from parts that need no escaping. */
17
+ export declare function selectorFor(el: any): string;
18
+ /**
19
+ * The page's blocks as an indented list the model can pick selectors from:
20
+ * landmarks (`header`, `nav`, `aside`, `footer`, `main`, `article`, …) and
21
+ * elements with an id, class or role, with their word count and first words.
22
+ */
23
+ export declare function pageOutline(document: any, maxLines?: number): string;
24
+ /** What the model is shown for the content check. */
25
+ export declare function prepareContent(html: string, options?: {
26
+ content?: string;
27
+ text?: string;
28
+ words?: number;
29
+ }): ContentInput;
30
+ /**
31
+ * Normalizes and checks the model's `contentCheck`: an unknown verdict becomes
32
+ * `"unknown"`, signals and examples not in the text are dropped, and selectors
33
+ * that are invalid or match nothing on the page are dropped.
34
+ * @param haystack the content and page text the model was shown
35
+ */
36
+ export declare function parseContentCheck(reply: unknown, input: ContentInput, haystack: string): ContentCheck;
@@ -11,6 +11,8 @@ import { ExtractCiteLLMOptions, ExtractCiteLLMResult } from './types';
11
11
  * cite.formatted.apa?.text; // full APA 7 entry
12
12
  * cite.needsReview; // fields to check by hand
13
13
  * cite.citation.authors[0].qualifications; // bio facts, quoted from the page
14
+ * cite.contentCheck?.verdict; // "full", "paywalled", "truncated", …
15
+ * cite.contentCheck?.tips; // header, nav, sidebar, footer blocks to cut
14
16
  * ```
15
17
  *
16
18
  * Throws when the page cannot be fetched or the model call fails; a missing
@@ -4,4 +4,5 @@ export * from './extract-cite-llm';
4
4
  export * from './format-citation';
5
5
  export { preparePage, fetchPageHTML, clipText } from './page-text';
6
6
  export { parseCitationReply, normalizeDate } from './parse-reply';
7
- export { SYSTEM_PROMPT, buildUserPrompt, missingFields } from './prompt';
7
+ export { SYSTEM_PROMPT, CONTENT_CHECK_PROMPT, systemPrompt, buildUserPrompt, missingFields } from './prompt';
8
+ export { prepareContent, parseContentCheck, pageOutline, contentToText, firstWords } from './content-check';
@@ -1,7 +1,15 @@
1
1
  import { ExtractCiteResult } from '../html-to-cite/extract-cite';
2
2
  import { CitationField } from './types';
3
3
  import { PageInput } from './page-text';
4
+ import { ContentInput } from './content-check';
4
5
  export declare const SYSTEM_PROMPT: string;
6
+ /**
7
+ * Appended to `SYSTEM_PROMPT` when the content check is on: `<content>` is the
8
+ * start of the extracted text, `<outline>` the page's blocks as selectors.
9
+ */
10
+ export declare const CONTENT_CHECK_PROMPT: string;
11
+ /** The system prompt, with the content check appended when it is on. */
12
+ export declare function systemPrompt(checkContent?: boolean): string;
5
13
  /** Which candidate fields the regex pass filled, for the prompt. */
6
14
  export declare function describeCandidates(partial: ExtractCiteResult): Record<string, string | null>;
7
15
  /** The citation fields the regex pass cannot supply at all. */
@@ -11,4 +19,6 @@ export declare function buildUserPrompt(args: {
11
19
  partial: ExtractCiteResult;
12
20
  missing: CitationField[];
13
21
  page: PageInput;
22
+ /** The content check's input; leave out to skip the check. */
23
+ content?: ContentInput;
14
24
  }): string;
@@ -55,11 +55,65 @@ export type CitationField = "authors" | "title" | "containerTitle" | "publisher"
55
55
  export declare const CITATION_FIELDS: CitationField[];
56
56
  /** A field (or one author's qualifications) a human should check. */
57
57
  export interface ReviewItem {
58
- /** A citation field, or `qualifications:<author name>`. */
58
+ /** A citation field, `qualifications:<author name>`, or `content` when the content check found no full article. */
59
59
  field: string;
60
60
  confidence: number;
61
61
  reason: string;
62
62
  }
63
+ /** What the content check concluded about the extracted text. */
64
+ export type ContentVerdict =
65
+ /** The article body is all there. */
66
+ "full"
67
+ /** A subscribe or sign-in wall replaces or cuts the body. */
68
+ | "paywalled"
69
+ /** The body stops early without a wall: a teaser, an abstract, "Read more". */
70
+ | "truncated"
71
+ /** A bot check, consent wall, access-denied or error page instead of the article. */
72
+ | "blocked"
73
+ /** A home page, index or listing, with no single article. */
74
+ | "not-article"
75
+ /** The model gave no usable verdict. */
76
+ | "unknown";
77
+ export declare const CONTENT_VERDICTS: ContentVerdict[];
78
+ /** The kinds of clutter a readability tip can be about. */
79
+ export type PageRegion = "header" | "nav" | "sidebar" | "footer" | "ads" | "related" | "newsletter" | "share" | "comments" | "cookie-banner" | "other";
80
+ export declare const PAGE_REGIONS: PageRegion[];
81
+ /** Clutter the extraction kept, and how to cut it. */
82
+ export interface ReadabilityTip {
83
+ region: PageRegion;
84
+ /** CSS selector of the block to cut. Kept only when it matches an element on the page. */
85
+ selector?: string;
86
+ /** A short snippet of the clutter, kept only when it is in the text. */
87
+ example?: string;
88
+ /** How to cut it, in a sentence. */
89
+ tip: string;
90
+ }
91
+ /** Whether the extracted content is the full article, and how to make it cleaner. */
92
+ export interface ContentCheck {
93
+ verdict: ContentVerdict;
94
+ /** True only for `"full"`. */
95
+ isFullContent: boolean;
96
+ /** 0–1. */
97
+ confidence: number;
98
+ /** Phrases from the text that show the verdict ("Subscribe to continue reading"); only ones that are in the text are kept. */
99
+ signals: string[];
100
+ /** One or two sentences for the reader. */
101
+ note: string;
102
+ /** CSS selector of the element that holds the article body. Kept only when it matches. */
103
+ contentSelector?: string;
104
+ /** Clutter the content still holds (header, nav, sidebar, footer, …), biggest first. */
105
+ tips: ReadabilityTip[];
106
+ /**
107
+ * The checked selectors in the shape of an `extract-webpage`
108
+ * `extract-selectors-per-domain.json` entry, ready to paste.
109
+ */
110
+ selectors: {
111
+ content: string[];
112
+ remove: string[];
113
+ };
114
+ /** Words of content the model was shown. */
115
+ wordsChecked: number;
116
+ }
63
117
  export interface FormattedCitation {
64
118
  /** Plain text. */
65
119
  text: string;
@@ -82,6 +136,8 @@ export interface ExtractCiteLLMResult {
82
136
  origin: Partial<Record<CitationField, "regex" | "llm" | "both">>;
83
137
  /** One entry per requested style. */
84
138
  formatted: Partial<Record<CitationStyle, FormattedCitation>>;
139
+ /** Full article or paywall stub, plus tips for cutting clutter. Absent with `checkContent: false`. */
140
+ contentCheck?: ContentCheck;
85
141
  model: string;
86
142
  ms: number;
87
143
  }
@@ -104,6 +160,15 @@ export interface ExtractCiteLLMOptions {
104
160
  reviewThreshold?: number;
105
161
  /** Characters of page text sent to the model. Default 12000. */
106
162
  maxChars?: number;
163
+ /**
164
+ * The content extracted from the page (HTML or text), e.g. extract-webpage's
165
+ * `html`, for the content check. Default: the page's own visible text.
166
+ */
167
+ content?: string;
168
+ /** Check that the content is the full article and suggest what to cut. Default true. */
169
+ checkContent?: boolean;
170
+ /** Words of content the check is shown, from the start. Default 3000. */
171
+ contentWords?: number;
107
172
  /** Abort the model call after this many milliseconds. Default 60000. */
108
173
  timeoutMs?: number;
109
174
  /** ISO date printed as the access date. Default: today. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "extract-cite",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "module": "./dist/extract-cite.es.js",
5
5
  "description": "Cite any webpage: regex/metadata extraction of author, date, title and source, plus an optional LLM pass (OpenRouter by default) that completes the APA parts, scores confidence per field, flags fields for review, extracts author qualifications, and formats APA, MLA, Chicago, Harvard, IEEE and BibTeX.",
6
6
  "author": "vtempest <grokthiscontact@gmail.com>",
@@ -58,4 +58,4 @@
58
58
  "author",
59
59
  "scraping"
60
60
  ]
61
- }
61
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @fileoverview The content check that rides along with the citation call:
3
+ * the first few thousand words of the extracted content and an outline of the
4
+ * page's blocks go to the model, which says whether this is the full article or
5
+ * a paywall stub, and which header, nav, sidebar and footer blocks to cut. The
6
+ * reply is checked like the citation: quoted phrases must be in the text and
7
+ * selectors must match an element on the page, or they are dropped.
8
+ */
9
+ import { parseHTML } from "linkedom";
10
+ import { appearsIn, clamp01 } from "./parse-reply";
11
+ import {
12
+ CONTENT_VERDICTS,
13
+ PAGE_REGIONS,
14
+ type ContentCheck,
15
+ type ContentVerdict,
16
+ type PageRegion,
17
+ type ReadabilityTip,
18
+ } from "./types";
19
+
20
+ type Json = Record<string, any>;
21
+
22
+ const REMOVE = "script,style,noscript,svg,template,iframe,canvas";
23
+ const LANDMARK = /^(header|nav|aside|footer|main|article|section|form)$/;
24
+ /** An id or class usable in a selector without escaping. */
25
+ const IDENT = /^[A-Za-z_-][\w-]*$/;
26
+
27
+ export interface ContentInput {
28
+ /** The first `words` words of the content, for the prompt. */
29
+ text: string;
30
+ /** How many words that is. */
31
+ words: number;
32
+ /** One line per block of the page: selector, word count, opening words. */
33
+ outline: string;
34
+ /** The parsed page, to check the model's selectors against. */
35
+ document: any;
36
+ }
37
+
38
+ const countWords = (text: string) => (text.match(/\S+/g) || []).length;
39
+
40
+ /** The first `max` words of `text`. */
41
+ export function firstWords(text: string, max: number): string {
42
+ const words = text.match(/\S+/g) || [];
43
+ return words.length <= max ? words.join(" ") : `${words.slice(0, max).join(" ")} […]`;
44
+ }
45
+
46
+ /** A node's text with a space between elements, so "Home</a><a>News" does not run together. */
47
+ function textOf(node: any): string {
48
+ if (node.nodeType === 3) return node.data || "";
49
+ if (node.nodeType !== 1 || node.matches?.(REMOVE)) return "";
50
+ return Array.from(node.childNodes || []).map(textOf).join(" ");
51
+ }
52
+
53
+ const squash = (text: string) => text.replace(/\s+/g, " ").trim();
54
+
55
+ /** HTML or text → plain text. */
56
+ export function contentToText(content: string): string {
57
+ if (!/<[a-z!/][^>]*>/i.test(content)) return squash(content);
58
+ const { document } = parseHTML(`<html><body>${content}</body></html>`);
59
+ return document.body ? squash(textOf(document.body)) : "";
60
+ }
61
+
62
+ /** `tag#id.class.class`, from parts that need no escaping. */
63
+ export function selectorFor(el: any): string {
64
+ const tag = String(el.tagName || "").toLowerCase();
65
+ const id = el.getAttribute?.("id");
66
+ if (id && IDENT.test(id)) return `${tag}#${id}`;
67
+ const classes = String(el.getAttribute?.("class") || "")
68
+ .split(/\s+/)
69
+ .filter((c) => IDENT.test(c))
70
+ .slice(0, 2);
71
+ const role = el.getAttribute?.("role");
72
+ const roleAttr = role && IDENT.test(role) ? `[role="${role}"]` : "";
73
+ return `${tag}${classes.map((c) => `.${c}`).join("")}${roleAttr}`;
74
+ }
75
+
76
+ /**
77
+ * The page's blocks as an indented list the model can pick selectors from:
78
+ * landmarks (`header`, `nav`, `aside`, `footer`, `main`, `article`, …) and
79
+ * elements with an id, class or role, with their word count and first words.
80
+ */
81
+ export function pageOutline(document: any, maxLines = 60): string {
82
+ const lines: string[] = [];
83
+ const walk = (parent: any, depth: number, level: number) => {
84
+ for (const el of Array.from(parent.children || []) as any[]) {
85
+ if (lines.length >= maxLines) return;
86
+ const tag = String(el.tagName || "").toLowerCase();
87
+ if (el.matches?.(REMOVE)) continue;
88
+ const text = squash(textOf(el));
89
+ const words = countWords(text);
90
+ if (words < 3) continue;
91
+ // A bare `div` says nothing and would match every div on the page.
92
+ const selector = selectorFor(el);
93
+ const named = LANDMARK.test(tag) || selector !== tag;
94
+ if (named) {
95
+ const opening = text.split(" ").slice(0, 8).join(" ");
96
+ lines.push(`${" ".repeat(level)}${selector} (${words} words) "${opening}"`);
97
+ }
98
+ if (depth < 8 && level < 4) walk(el, depth + 1, named ? level + 1 : level);
99
+ }
100
+ };
101
+ if (document.body) walk(document.body, 0, 0);
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /** What the model is shown for the content check. */
106
+ export function prepareContent(
107
+ html: string,
108
+ options: { content?: string; text?: string; words?: number } = {}
109
+ ): ContentInput {
110
+ const { document } = parseHTML(html);
111
+ const source = options.content ?? options.text;
112
+ const full = source !== undefined ? contentToText(source) : document.body ? squash(textOf(document.body)) : "";
113
+ const text = firstWords(full, options.words ?? 3000);
114
+ return { text, words: Math.min(countWords(full), options.words ?? 3000), outline: pageOutline(document), document };
115
+ }
116
+
117
+ /** Bare tags that are safe to cut whole: every one of them is page chrome. */
118
+ const CHROME_TAG = /^(header|nav|aside|footer)$/i;
119
+
120
+ /** Is `selector` valid and does it match something on the page? */
121
+ function matches(document: any, selector: string): boolean {
122
+ try {
123
+ return Boolean(document.querySelector(selector));
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** A trimmed, tag-free string, capped. */
130
+ function str(value: unknown, max: number): string | undefined {
131
+ if (typeof value !== "string") return undefined;
132
+ const clean = value.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
133
+ if (!clean || /^(null|none|n\/a)$/i.test(clean)) return undefined;
134
+ return clean.slice(0, max);
135
+ }
136
+
137
+ /**
138
+ * Normalizes and checks the model's `contentCheck`: an unknown verdict becomes
139
+ * `"unknown"`, signals and examples not in the text are dropped, and selectors
140
+ * that are invalid or match nothing on the page are dropped.
141
+ * @param haystack the content and page text the model was shown
142
+ */
143
+ export function parseContentCheck(reply: unknown, input: ContentInput, haystack: string): ContentCheck {
144
+ const r = (reply && typeof reply === "object" ? reply : {}) as Json;
145
+ const verdict: ContentVerdict = CONTENT_VERDICTS.includes(r.verdict) ? r.verdict : "unknown";
146
+ const selector = (value: unknown, bareOk: RegExp) => {
147
+ const s = str(value, 200);
148
+ if (!s || !matches(input.document, s)) return undefined;
149
+ // A bare tag like `div` or `section` would cut (or keep) far too much.
150
+ return /^[a-z][a-z0-9]*$/i.test(s) && !bareOk.test(s) ? undefined : s;
151
+ };
152
+
153
+ const signals = (Array.isArray(r.signals) ? r.signals : [])
154
+ .map((s: unknown) => str(s, 160))
155
+ .filter((s: string | undefined): s is string => Boolean(s) && appearsIn(s, haystack))
156
+ .slice(0, 5);
157
+
158
+ const tips: ReadabilityTip[] = [];
159
+ for (const raw of Array.isArray(r.tips) ? r.tips.slice(0, 12) : []) {
160
+ if (!raw || typeof raw !== "object") continue;
161
+ const tip = str(raw.tip, 300);
162
+ if (!tip) continue;
163
+ const example = str(raw.example, 160);
164
+ tips.push({
165
+ region: PAGE_REGIONS.includes(raw.region) ? (raw.region as PageRegion) : "other",
166
+ selector: selector(raw.selector, CHROME_TAG),
167
+ example: example && appearsIn(example, haystack) ? example : undefined,
168
+ tip,
169
+ });
170
+ if (tips.length === 8) break;
171
+ }
172
+
173
+ const contentSelector = selector(r.contentSelector, /^(main|article)$/i);
174
+ const remove = [...new Set(tips.map((t) => t.selector).filter(Boolean) as string[])];
175
+
176
+ return {
177
+ verdict,
178
+ isFullContent: verdict === "full",
179
+ confidence: verdict === "unknown" ? 0 : clamp01(r.confidence),
180
+ signals,
181
+ note: str(r.note, 400) || (verdict === "unknown" ? "The model did not check the content." : ""),
182
+ contentSelector,
183
+ tips,
184
+ selectors: { content: contentSelector ? [contentSelector] : [], remove },
185
+ wordsChecked: input.words,
186
+ };
187
+ }
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * @fileoverview `extractCiteLLM`: the cheap regex/metadata pass first, then one
3
3
  * model call that is handed what was found and what is still missing, completes
4
- * the APA parts, scores each one, and reads the authors' qualifications.
4
+ * the APA parts, scores each one, and reads the authors' qualifications. The
5
+ * same call checks that the extracted content is the full article, not a
6
+ * paywall stub, and names the header, nav, sidebar and footer blocks to cut.
5
7
  */
6
8
  import { parseHTML } from "linkedom";
7
9
  import { extractCite } from "../html-to-cite/extract-cite";
8
10
  import { callLLM } from "./call-llm";
11
+ import { parseContentCheck, prepareContent } from "./content-check";
9
12
  import { formatCitations } from "./format-citation";
10
13
  import { fetchPageHTML, preparePage } from "./page-text";
11
14
  import { parseCitationReply } from "./parse-reply";
12
- import { buildUserPrompt, missingFields, SYSTEM_PROMPT } from "./prompt";
15
+ import { buildUserPrompt, missingFields, systemPrompt } from "./prompt";
13
16
  import {
14
17
  CITATION_STYLES,
15
18
  type CitationField,
@@ -29,6 +32,8 @@ import {
29
32
  * cite.formatted.apa?.text; // full APA 7 entry
30
33
  * cite.needsReview; // fields to check by hand
31
34
  * cite.citation.authors[0].qualifications; // bio facts, quoted from the page
35
+ * cite.contentCheck?.verdict; // "full", "paywalled", "truncated", …
36
+ * cite.contentCheck?.tips; // header, nav, sidebar, footer blocks to cut
32
37
  * ```
33
38
  *
34
39
  * Throws when the page cannot be fetched or the model call fails; a missing
@@ -36,7 +41,7 @@ import {
36
41
  */
37
42
  export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<ExtractCiteLLMResult> {
38
43
  const started = Date.now();
39
- const { url, reviewThreshold = 0.7, styles = [...CITATION_STYLES] } = options;
44
+ const { url, reviewThreshold = 0.7, styles = [...CITATION_STYLES], checkContent = true } = options;
40
45
 
41
46
  let html = options.html;
42
47
  if (!html && options.text === undefined) {
@@ -51,6 +56,9 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
51
56
 
52
57
  // 2. One model call with the partial in hand.
53
58
  const page = preparePage(html, { text: options.text, maxChars: options.maxChars });
59
+ const content = checkContent
60
+ ? prepareContent(html, { content: options.content, text: options.text, words: options.contentWords })
61
+ : undefined;
54
62
  const { json, model } = await callLLM({
55
63
  apiKey: options.apiKey,
56
64
  model: options.model,
@@ -58,8 +66,8 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
58
66
  headers: options.headers,
59
67
  timeoutMs: options.timeoutMs,
60
68
  fetch: options.fetch,
61
- system: SYSTEM_PROMPT,
62
- user: buildUserPrompt({ url, partial, missing, page }),
69
+ system: systemPrompt(checkContent),
70
+ user: buildUserPrompt({ url, partial, missing, page, content }),
63
71
  });
64
72
 
65
73
  // 3. Validate it against the page and score it.
@@ -71,6 +79,24 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
71
79
  reviewThreshold
72
80
  );
73
81
 
82
+ // 4. The content check, held to the same standard: quotes must be in the
83
+ // text, selectors must match the page.
84
+ const contentCheck = content
85
+ ? parseContentCheck(
86
+ (json as { contentCheck?: unknown } | null)?.contentCheck,
87
+ content,
88
+ [content.text, page.text, page.pageTitle].join("\n")
89
+ )
90
+ : undefined;
91
+ if (contentCheck && !contentCheck.isFullContent && contentCheck.verdict !== "unknown") {
92
+ parsed.needsReview.push({
93
+ field: "content",
94
+ confidence: contentCheck.confidence,
95
+ reason: `${contentCheck.verdict}: ${contentCheck.note || contentCheck.signals[0] || "not the full article"}`,
96
+ });
97
+ parsed.needsReview.sort((a, b) => a.confidence - b.confidence);
98
+ }
99
+
74
100
  // `agree` means the regex pass and the model read the same value; anything
75
101
  // else the model supplied, because the regex pass had nothing or was overruled.
76
102
  const origin: ExtractCiteLLMResult["origin"] = {};
@@ -92,6 +118,7 @@ export async function extractCiteLLM(options: ExtractCiteLLMOptions): Promise<Ex
92
118
  missing,
93
119
  origin,
94
120
  formatted: formatCitations(parsed.citation, styles),
121
+ contentCheck,
95
122
  model,
96
123
  ms: Date.now() - started,
97
124
  };
package/src/llm/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./extract-cite-llm";
4
4
  export * from "./format-citation";
5
5
  export { preparePage, fetchPageHTML, clipText } from "./page-text";
6
6
  export { parseCitationReply, normalizeDate } from "./parse-reply";
7
- export { SYSTEM_PROMPT, buildUserPrompt, missingFields } from "./prompt";
7
+ export { SYSTEM_PROMPT, CONTENT_CHECK_PROMPT, systemPrompt, buildUserPrompt, missingFields } from "./prompt";
8
+ export { prepareContent, parseContentCheck, pageOutline, contentToText, firstWords } from "./content-check";
package/src/llm/prompt.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * @fileoverview The prompt: the regex pass's partial citation goes in as
3
3
  * candidates to verify, the still-missing parts are named, and the page text
4
- * is fenced as untrusted data.
4
+ * is fenced as untrusted data. With the content check on, the same call also
5
+ * judges whether the extracted content is the full article and what to cut.
5
6
  */
6
7
  import type { ExtractCiteResult } from "../html-to-cite/extract-cite";
7
- import { CITATION_FIELDS, SOURCE_TYPES, type CitationField } from "./types";
8
+ import { CITATION_FIELDS, CONTENT_VERDICTS, PAGE_REGIONS, SOURCE_TYPES, type CitationField } from "./types";
8
9
  import type { PageInput } from "./page-text";
10
+ import type { ContentInput } from "./content-check";
9
11
 
10
12
  export const SYSTEM_PROMPT = `You build bibliographic citations from web pages for students and researchers. A wrong author or date is worse than a missing one, so never guess.
11
13
 
@@ -44,6 +46,37 @@ Reply with one JSON object and nothing else:
44
46
  }
45
47
  }`;
46
48
 
49
+ /**
50
+ * Appended to `SYSTEM_PROMPT` when the content check is on: `<content>` is the
51
+ * start of the extracted text, `<outline>` the page's blocks as selectors.
52
+ */
53
+ export const CONTENT_CHECK_PROMPT = `
54
+ Also check the content. <content> is the start of the text extracted from this page as the article (or the page's own text when nothing was extracted), and <outline> lists the page's blocks as CSS selectors with their word counts and opening words. Readers will get this text as the article, so say whether it is the whole article and what clutter to cut.
55
+
56
+ - verdict, one of: ${CONTENT_VERDICTS.filter((v) => v !== "unknown").join(", ")}.
57
+ "full": the article body is there, from its opening to its end, or it runs past the sample with no sign of stopping. A short article is still full.
58
+ "paywalled": a subscribe, sign-in or register wall replaces or cuts off the body, usually a teaser paragraph and then a prompt to pay or log in.
59
+ "truncated": the body stops early with no wall: a "Read more" or "Continue reading" link, a cut mid-sentence, or only an abstract or summary where the page promises more.
60
+ "blocked": a bot check, captcha, cookie or consent wall, access-denied or error page instead of the article.
61
+ "not-article": a home page, index, search results or listing, not one article.
62
+ Judge only by what the text shows, not by what you know about the site.
63
+ - signals: up to 5 short phrases copied exactly from the text that show the verdict ("Subscribe to continue reading", "Already a subscriber? Sign in"). Empty when the verdict is full.
64
+ - tips: the clutter in <content> that is not the article, so it can be cut for readability: the site header and top bar, navigation menus, sidebars, footers, ads, related-article lists, newsletter and subscribe boxes, share buttons, comments, cookie banners. For each: region (one of ${PAGE_REGIONS.join(", ")}), the selector from <outline> that holds it (copy it exactly; null if no outline line fits, never invent one), a short example copied exactly from the text, and a one-sentence tip on how to cut it. Biggest first, at most 8. None when the content is clean.
65
+ - contentSelector: the selector from <outline> of the element that holds the article body, or null.
66
+ - note: one or two plain sentences for the reader: is this the full article, and if not, what is missing.
67
+
68
+ Add this key to the JSON object:
69
+ "contentCheck": {
70
+ "verdict": string, "confidence": number, "signals": string[], "note": string,
71
+ "contentSelector": string|null,
72
+ "tips": [ { "region": string, "selector": string|null, "example": string|null, "tip": string } ]
73
+ }`;
74
+
75
+ /** The system prompt, with the content check appended when it is on. */
76
+ export function systemPrompt(checkContent = true): string {
77
+ return checkContent ? SYSTEM_PROMPT + CONTENT_CHECK_PROMPT : SYSTEM_PROMPT;
78
+ }
79
+
47
80
  /** Which candidate fields the regex pass filled, for the prompt. */
48
81
  export function describeCandidates(partial: ExtractCiteResult): Record<string, string | null> {
49
82
  return {
@@ -72,15 +105,17 @@ export function missingFields(partial: ExtractCiteResult): CitationField[] {
72
105
  }
73
106
 
74
107
  /** Stops page text from closing the fence it is wrapped in. */
75
- const unfence = (text: string) => text.replace(/<\/?(page|text|meta|json-ld|title)\b[^>]*>/gi, "");
108
+ const unfence = (text: string) => text.replace(/<\/?(page|text|meta|json-ld|title|content|outline)\b[^>]*>/gi, "");
76
109
 
77
110
  export function buildUserPrompt(args: {
78
111
  url?: string;
79
112
  partial: ExtractCiteResult;
80
113
  missing: CitationField[];
81
114
  page: PageInput;
115
+ /** The content check's input; leave out to skip the check. */
116
+ content?: ContentInput;
82
117
  }): string {
83
- const { url, partial, missing, page } = args;
118
+ const { url, partial, missing, page, content } = args;
84
119
  const parts = [
85
120
  `URL: ${url || "(none given)"}`,
86
121
  `Candidates from the regex pass: ${JSON.stringify(describeCandidates(partial))}`,
@@ -90,6 +125,13 @@ export function buildUserPrompt(args: {
90
125
  ];
91
126
  if (page.meta) parts.push(`<meta>\n${unfence(page.meta)}\n</meta>`);
92
127
  if (page.jsonLd) parts.push(`<json-ld>\n${unfence(page.jsonLd)}\n</json-ld>`);
93
- parts.push(`<text>\n${unfence(page.text)}\n</text>`, "</page>");
128
+ parts.push(`<text>\n${unfence(page.text)}\n</text>`);
129
+ if (content) {
130
+ parts.push(
131
+ `<content words="${content.words}">\n${unfence(content.text)}\n</content>`,
132
+ `<outline>\n${unfence(content.outline) || "(no blocks with an id, class or role)"}\n</outline>`
133
+ );
134
+ }
135
+ parts.push("</page>");
94
136
  return parts.join("\n");
95
137
  }