55
66const JINA_BASE = 'https://r.jina.ai/' ;
77
8+ // Metadata comes back under different conventions depending on what KIND of
9+ // page it is — a scholarly article hides everything under Highwire/Dublin-Core/
10+ // PRISM keys (citation_*, dc.*, prism.*); a blog/news/company page uses
11+ // OpenGraph (og:*, article:*). So there are two PROFILES and fuzzy routing
12+ // picks one; forceProfile lets a human re-route when detection is wrong.
13+ export type ParserProfile = 'structured' | 'opengraph' ;
14+ export type SourceKind = 'academic-paper' | 'article' | 'company-landing' | 'web-page' ;
15+
816export type JinaResult =
917 | {
1018 ok : true ;
@@ -15,12 +23,15 @@ export type JinaResult =
1523 }
1624 | { ok : false ; error : string ; status ?: number } ;
1725
18- export async function fetchViaJina ( url : string , opts : { noCache ?: boolean } = { } ) : Promise < JinaResult > {
26+ export async function fetchViaJina (
27+ url : string ,
28+ opts : { noCache ?: boolean ; forceProfile ?: ParserProfile } = { } ,
29+ ) : Promise < JinaResult > {
1930 const RETRIES = 3 ;
2031 let backoffMs = 2000 ;
2132 let lastErr : { ok : false ; error : string ; status ?: number } | null = null ;
2233 for ( let attempt = 0 ; attempt < RETRIES ; attempt += 1 ) {
23- const result = await jinaFetchOnce ( url , opts . noCache ) ;
34+ const result = await jinaFetchOnce ( url , opts . noCache , opts . forceProfile ) ;
2435 if ( result . ok ) return result ;
2536 if ( result . status !== 429 ) return result ;
2637 lastErr = result ;
@@ -32,7 +43,7 @@ export async function fetchViaJina(url: string, opts: { noCache?: boolean } = {}
3243 return lastErr ?? { ok : false , error : 'jina fetch failed after retries' } ;
3344}
3445
35- async function jinaFetchOnce ( url : string , noCache = false ) : Promise < JinaResult > {
46+ async function jinaFetchOnce ( url : string , noCache = false , forceProfile ?: ParserProfile ) : Promise < JinaResult > {
3647 const fetched_at = new Date ( ) . toISOString ( ) ;
3748 const apiKey = process . env . JINA_API_KEY ;
3849 const headers : Record < string , string > = { Accept : 'application/json' } ;
@@ -60,37 +71,28 @@ async function jinaFetchOnce(url: string, noCache = false): Promise<JinaResult>
6071 parsed = null ;
6172 }
6273
63- // JSON path (the normal case) — rich metadata available.
74+ // JSON path (the normal case) — rich metadata available, routed by profile .
6475 if ( parsed ?. data ) {
6576 const data = parsed . data ;
66- const meta = ( data . metadata && typeof data . metadata === 'object' ? data . metadata : { } ) as Record < string , unknown > ;
6777 const markdown = typeof data . content === 'string' ? data . content : '' ;
6878 if ( ! markdown . trim ( ) ) return { ok : false , error : 'Jina returned empty content' } ;
69- const title = firstStr ( data . title ) ?? extractTitle ( markdown , url ) ;
70- const extra : Record < string , unknown > = { jina_status : res . status , content_length_bytes : markdown . length } ;
71-
72- const publishedRaw = firstStr ( data . publishedTime , meta [ 'article:published_time' ] , meta [ 'article:modified_time' ] , meta . date ) ;
73- if ( publishedRaw ) {
74- const iso = normalizeToISO ( publishedRaw ) ;
75- if ( iso ) extra . published_at = iso ;
76- }
77- const authors = normalizeAuthors ( meta . author , meta [ 'article:author' ] , meta [ 'dc.creator' ] , data . author ) ;
78- if ( authors . length ) extra . authors = authors ;
79- const publisher = firstStr ( meta [ 'og:site_name' ] , data . publisher ) ?? hostnameOf ( url ) ;
80- if ( publisher ) extra . publisher = publisher ;
81- const description = firstStr ( data . description , meta . description ) ;
82- if ( description ) extra . description = description ;
83- const language = firstStr ( meta . lang , meta . language , data . lang ) ;
84- if ( language ) extra . language = language ;
85-
79+ const { title, extra } = extractBib ( data , url , { forceProfile } ) ;
80+ extra . jina_status = res . status ;
81+ extra . content_length_bytes = markdown . length ;
8682 return { ok : true , markdown, title, fetched_at, extra } ;
8783 }
8884
8985 // Fallback: non-JSON body (some upstreams / error shapes) — parse the legacy
90- // key:value preamble so we still degrade gracefully.
86+ // key:value preamble so we still degrade gracefully. Always the opengraph
87+ // profile, kind web-page (no structured metadata is available this way).
9188 const markdown = body ;
9289 const title = extractTitle ( markdown , url ) ;
93- const extra : Record < string , unknown > = { jina_status : res . status , content_length_bytes : markdown . length } ;
90+ const extra : Record < string , unknown > = {
91+ jina_status : res . status ,
92+ content_length_bytes : markdown . length ,
93+ source_kind : 'web-page' as SourceKind ,
94+ parser_profile : 'opengraph' as ParserProfile ,
95+ } ;
9496 const preamble = parsePreamble ( markdown ) ;
9597 const publishedTime = preamble [ 'Published Time' ] ?? preamble [ 'published_time' ] ;
9698 if ( publishedTime ) {
@@ -105,6 +107,119 @@ async function jinaFetchOnce(url: string, noCache = false): Promise<JinaResult>
105107 return { ok : true , markdown, title, fetched_at, extra } ;
106108}
107109
110+ // ── Profile routing ────────────────────────────────────────────────────────
111+
112+ function metaOf ( data : Record < string , unknown > ) : Record < string , unknown > {
113+ return ( data . metadata && typeof data . metadata === 'object' ? data . metadata : { } ) as Record < string , unknown > ;
114+ }
115+
116+ function present ( meta : Record < string , unknown > , key : string ) : boolean {
117+ const v = meta [ key ] ;
118+ if ( v == null || v === '' ) return false ;
119+ if ( Array . isArray ( v ) ) return v . length > 0 ;
120+ return true ;
121+ }
122+
123+ // Fuzzy-match the source kind from which metadata convention is present, and
124+ // map it to a parser profile. Academic tells (a DOI or citation_* journal
125+ // keys) are the strongest signal; then OpenGraph article/website; else a
126+ // plain web page.
127+ export function detectProfile ( data : Record < string , unknown > ) : { profile : ParserProfile ; kind : SourceKind } {
128+ const meta = metaOf ( data ) ;
129+ const ogType = firstStr ( meta [ 'og:type' ] ) ;
130+ if ( present ( meta , 'citation_title' ) || present ( meta , 'citation_doi' ) || present ( meta , 'DOI' ) || present ( meta , 'citation_journal_title' ) ) {
131+ return { profile : 'structured' , kind : 'academic-paper' } ;
132+ }
133+ if ( ogType === 'article' || present ( meta , 'article:published_time' ) || present ( meta , 'article:author' ) ) {
134+ return { profile : 'opengraph' , kind : 'article' } ;
135+ }
136+ if ( ogType === 'website' || ( present ( meta , 'og:site_name' ) && ! present ( meta , 'article:published_time' ) ) ) {
137+ return { profile : 'opengraph' , kind : 'company-landing' } ;
138+ }
139+ return { profile : 'opengraph' , kind : 'web-page' } ;
140+ }
141+
142+ // Pure, testable, profile-aware bibliographic extraction. Each field resolves
143+ // across an ordered alias list (first hit wins), so a field missing under one
144+ // convention still resolves under another. forceProfile overrides routing (the
145+ // manual re-route path).
146+ export function extractBib (
147+ data : Record < string , unknown > ,
148+ url : string ,
149+ opts : { forceProfile ?: ParserProfile } = { } ,
150+ ) : { title : string ; extra : Record < string , unknown > } {
151+ const meta = metaOf ( data ) ;
152+ const routed = detectProfile ( data ) ;
153+ const profile = opts . forceProfile ?? routed . profile ;
154+ const kind : SourceKind = opts . forceProfile
155+ ? ( opts . forceProfile === 'structured' ? 'academic-paper' : routed . kind )
156+ : routed . kind ;
157+
158+ const markdown = typeof data . content === 'string' ? data . content : '' ;
159+
160+ // Per-profile candidate lists. `data.*` are Jina's top-level convenience
161+ // fields (often undefined); the rest are pass-through meta tags.
162+ const titleCands =
163+ profile === 'structured'
164+ ? [ data . title , meta [ 'citation_title' ] , meta [ 'dc.title' ] , meta [ 'og:title' ] ]
165+ : [ data . title , meta [ 'og:title' ] , meta [ 'twitter:title' ] , meta [ 'dc.title' ] ] ;
166+
167+ const authorCands =
168+ profile === 'structured'
169+ ? [ meta [ 'citation_author' ] , meta [ 'dc.creator' ] , data . author ]
170+ : [ meta [ 'author' ] , meta [ 'article:author' ] , data . author , meta [ 'dc.creator' ] ] ;
171+
172+ const dateCands =
173+ profile === 'structured'
174+ ? [ meta [ 'dc.date' ] , meta [ 'prism.publicationDate' ] , meta [ 'citation_online_date' ] , meta [ 'citation_publication_date' ] , meta [ 'citation_cover_date' ] , data . publishedTime ]
175+ : [ data . publishedTime , meta [ 'article:published_time' ] , meta [ 'article:modified_time' ] , meta [ 'date' ] , meta [ 'dc.date' ] ] ;
176+
177+ const publisherCands =
178+ profile === 'structured'
179+ ? [ meta [ 'citation_publisher' ] , meta [ 'dc.publisher' ] , meta [ 'prism.publicationName' ] , meta [ 'citation_journal_title' ] , meta [ 'og:site_name' ] ]
180+ : [ meta [ 'og:site_name' ] , data . publisher , meta [ 'dc.publisher' ] ] ;
181+
182+ const descCands =
183+ profile === 'structured'
184+ ? [ meta [ 'dc.description' ] , data . description , meta [ 'description' ] , meta [ 'og:description' ] ]
185+ : [ data . description , meta [ 'og:description' ] , meta [ 'description' ] , meta [ 'twitter:description' ] ] ;
186+
187+ const langCands =
188+ profile === 'structured'
189+ ? [ meta [ 'citation_language' ] , meta [ 'dc.language' ] , meta [ 'lang' ] , meta [ 'language' ] ]
190+ : [ meta [ 'lang' ] , meta [ 'language' ] , data . lang ] ;
191+
192+ const title = firstStr ( ...titleCands ) ?? extractTitle ( markdown , url ) ;
193+
194+ const extra : Record < string , unknown > = { source_kind : kind , parser_profile : profile } ;
195+
196+ const publishedRaw = firstStr ( ...dateCands ) ;
197+ if ( publishedRaw ) {
198+ const iso = normalizeToISO ( publishedRaw ) ;
199+ if ( iso ) extra . published_at = iso ;
200+ }
201+ const authors = normalizeAuthors ( ...authorCands ) ;
202+ if ( authors . length ) extra . authors = authors ;
203+ const publisher = firstStr ( ...publisherCands ) ?? hostnameOf ( url ) ;
204+ if ( publisher ) extra . publisher = publisher ;
205+ const description = firstStr ( ...descCands ) ;
206+ if ( description ) extra . description = description ;
207+ const language = firstStr ( ...langCands ) ;
208+ if ( language ) extra . language = language ;
209+
210+ // Bonus identifiers worth carrying when the structured profile has them.
211+ if ( profile === 'structured' ) {
212+ const doi = firstStr ( meta [ 'citation_doi' ] , meta [ 'DOI' ] , meta [ 'prism.doi' ] ) ;
213+ if ( doi ) extra . doi = doi . replace ( / ^ d o i : / i, '' ) ;
214+ const journal = firstStr ( meta [ 'citation_journal_title' ] , meta [ 'prism.publicationName' ] ) ;
215+ if ( journal ) extra . journal = journal ;
216+ }
217+
218+ return { title, extra } ;
219+ }
220+
221+ // ── helpers ──────────────────────────────────────────────────────────────
222+
108223function firstStr ( ...vals : unknown [ ] ) : string | undefined {
109224 for ( const v of vals ) {
110225 if ( typeof v === 'string' && v . trim ( ) ) return v . trim ( ) ;
@@ -114,7 +229,8 @@ function firstStr(...vals: unknown[]): string | undefined {
114229
115230// Jina returns author as a STRING for one author but an ARRAY for several.
116231// Normalize to a string[] (one author → one-element array), taking the first
117- // key that yields anything and stripping a leading "By ".
232+ // key that yields anything and stripping a leading "By ". Never comma-splits a
233+ // single string — "Pal, Soumen" (Last, First) is one author, not two.
118234function normalizeAuthors ( ...vals : unknown [ ] ) : string [ ] {
119235 for ( const v of vals ) {
120236 let list : string [ ] = [ ] ;
@@ -156,9 +272,9 @@ function parsePreamble(markdown: string): Record<string, string> {
156272}
157273
158274// Jina passes through whatever the upstream meta tag carried. Coerce
159- // the common cases (ISO already, RFC 2822, "YYYY-MM-DD") to ISO 8601.
160- // Returns null when Date parsing yields NaN — better to drop than to
161- // stamp garbage into the frontmatter.
275+ // the common cases (ISO already, RFC 2822, "YYYY-MM-DD", "YYYY/MM/DD" ) to
276+ // ISO 8601. Returns null when Date parsing yields NaN — better to drop than
277+ // to stamp garbage into the frontmatter.
162278function normalizeToISO ( raw : string ) : string | null {
163279 if ( / ^ \d { 4 } - \d { 2 } - \d { 2 } T \d { 2 } : \d { 2 } / . test ( raw ) ) {
164280 const d = new Date ( raw ) ;
0 commit comments