-
Notifications
You must be signed in to change notification settings - Fork 0
Fix citations: OpenAlex replaces Semantic Scholar (Phase 0+1) #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
8a2de7b
feat(providers): add openalex to ProviderId union
sharonds beb45e5
feat(config): add openalexMailto, load from OPENALEX_MAILTO env
sharonds 0bed258
feat(providers): register OpenAlex metadata for academic skill
sharonds a98e833
feat(providers): add OpenAlex search client (SSPaper-compatible)
sharonds f212159
feat(providers): route academic skill to OpenAlex when mailto set
sharonds 1d59e38
feat(academic): dispatch to OpenAlex when resolver returns openalex
sharonds 0486467
test(academic): golden-file regression for Semantic Scholar path
sharonds 9769c40
test(openalex): live-API integration test (gated on OPENALEX_INTEGRAT…
sharonds bfb4f37
docs(citations): OpenAlex default provider, SS as legacy fallback
sharonds ddfae1b
fix: address PR #49 review — dashboard parity, golden hermeticity, docs
sharonds 5482de6
test(openalex): use shared mock-fetch helper; add 429 + dx.doi.org cases
sharonds File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { describe, test, expect, mock, afterEach } from "bun:test"; | ||
| import { oaSearch } from "./openalex.ts"; | ||
|
|
||
| const originalFetch = globalThis.fetch; | ||
|
|
||
| function mockFetch(response: Response | Promise<Response>) { | ||
| globalThis.fetch = mock(() => Promise.resolve(response)) as unknown as typeof fetch; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
|
|
||
| describe("oaSearch", () => { | ||
| test("returns papers in SSPaper shape", async () => { | ||
| const body = { | ||
| results: [ | ||
| { | ||
| id: "https://openalex.org/W123", | ||
| doi: "https://doi.org/10.1136/bmj.i6583", | ||
| title: "Vitamin D supplementation to prevent acute respiratory tract infections", | ||
| publication_year: 2017, | ||
| authorships: [{ author: { display_name: "Martineau AR" } }, { author: { display_name: "Jolliffe DA" } }], | ||
| primary_location: { landing_page_url: "https://www.bmj.com/content/356/bmj.i6583" }, | ||
| }, | ||
| ], | ||
| }; | ||
| mockFetch(new Response(JSON.stringify(body), { status: 200 })); | ||
| const papers = await oaSearch("vitamin d respiratory", 5, { mailto: "me@example.com" }); | ||
| expect(papers).toHaveLength(1); | ||
| expect(papers[0].title).toContain("Vitamin D supplementation"); | ||
| expect(papers[0].year).toBe(2017); | ||
| expect(papers[0].authors).toHaveLength(2); | ||
| expect(papers[0].authors[0].name).toBe("Martineau AR"); | ||
| expect(papers[0].externalIds?.DOI).toBe("10.1136/bmj.i6583"); | ||
| expect(papers[0].url).toBe("https://www.bmj.com/content/356/bmj.i6583"); | ||
| }); | ||
|
|
||
| test("returns empty array when API returns no results", async () => { | ||
| mockFetch(new Response(JSON.stringify({ results: [] }), { status: 200 })); | ||
| const papers = await oaSearch("no match", 5); | ||
| expect(papers).toEqual([]); | ||
| }); | ||
|
|
||
| test("returns empty array on non-OK response", async () => { | ||
| mockFetch(new Response("server error", { status: 500 })); | ||
| const papers = await oaSearch("anything", 5); | ||
| expect(papers).toEqual([]); | ||
| }); | ||
|
|
||
| test("returns empty array on network throw", async () => { | ||
| globalThis.fetch = mock(() => Promise.reject(new Error("ECONNRESET"))) as unknown as typeof fetch; | ||
| const papers = await oaSearch("anything", 5); | ||
| expect(papers).toEqual([]); | ||
| }); | ||
|
|
||
| test("returns empty array on malformed JSON response", async () => { | ||
| mockFetch(new Response("<html>not json</html>", { status: 200 })); | ||
| const papers = await oaSearch("anything", 5); | ||
| expect(papers).toEqual([]); | ||
| }); | ||
|
|
||
| test("includes mailto in URL when provided", async () => { | ||
| let capturedUrl = ""; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })); | ||
| }) as unknown as typeof fetch; | ||
| await oaSearch("q", 3, { mailto: "x@y.com" }); | ||
| expect(capturedUrl).toContain("mailto=x%40y.com"); | ||
| }); | ||
|
|
||
| test("omits mailto when not provided", async () => { | ||
| let capturedUrl = ""; | ||
| globalThis.fetch = mock((url: string) => { | ||
| capturedUrl = url; | ||
| return Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })); | ||
| }) as unknown as typeof fetch; | ||
| await oaSearch("q", 3); | ||
| expect(capturedUrl).not.toContain("mailto"); | ||
| }); | ||
|
|
||
| test("strips https://doi.org/ prefix from DOI field", async () => { | ||
| const body = { | ||
| results: [{ | ||
| id: "W1", doi: "https://doi.org/10.1000/xyz", | ||
| title: "t", publication_year: 2020, authorships: [], primary_location: {}, | ||
| }], | ||
| }; | ||
| mockFetch(new Response(JSON.stringify(body), { status: 200 })); | ||
| const papers = await oaSearch("q", 1); | ||
| expect(papers[0].externalIds?.DOI).toBe("10.1000/xyz"); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import type { SSPaper } from "./semanticscholar.ts"; | ||
|
|
||
| export interface OaSearchOptions { | ||
| mailto?: string; | ||
| } | ||
|
|
||
| interface OaAuthorship { author?: { display_name?: string } } | ||
|
|
||
| interface OaWork { | ||
| id: string; | ||
| doi?: string | null; | ||
| title: string | null; | ||
| publication_year?: number; | ||
| authorships?: OaAuthorship[]; | ||
| primary_location?: { landing_page_url?: string }; | ||
| } | ||
|
|
||
| interface OaResponse { results?: OaWork[] } | ||
|
|
||
| /** | ||
| * Search OpenAlex for papers matching a query. | ||
| * | ||
| * OpenAlex is a free, open academic-metadata service with ~250M indexed works. | ||
| * Using the polite pool (via `mailto` param) grants 100k requests/day. No API | ||
| * key is required for the polite pool; the `mailto` identifies the client for | ||
| * soft rate limiting. | ||
| * | ||
| * Returns up to `limit` papers in the same `SSPaper` shape as `ssSearch` so | ||
| * callers can swap providers without changing downstream logic. Returns an | ||
| * empty array on any error — caller treats zero results as a warn (no academic | ||
| * support for this claim), not a hard failure. | ||
| */ | ||
| export async function oaSearch( | ||
| query: string, | ||
| limit = 3, | ||
| opts: OaSearchOptions = {}, | ||
| ): Promise<SSPaper[]> { | ||
| const url = new URL("https://api.openalex.org/works"); | ||
| url.searchParams.set("search", query); | ||
| url.searchParams.set("per-page", String(limit)); | ||
| url.searchParams.set("select", "id,doi,title,publication_year,authorships,primary_location,type"); | ||
| url.searchParams.set("filter", "type:article|review"); | ||
| if (opts.mailto) url.searchParams.set("mailto", opts.mailto); | ||
|
|
||
| try { | ||
| const res = await fetch(url.toString()); | ||
| if (!res.ok) return []; | ||
| const json = (await res.json()) as OaResponse; | ||
| const works = json.results ?? []; | ||
| return works.map((w) => ({ | ||
| paperId: w.id, | ||
| title: w.title ?? "", | ||
| year: w.publication_year, | ||
| authors: (w.authorships ?? []) | ||
| .map((a) => ({ name: a.author?.display_name ?? "" })) | ||
| .filter((a) => a.name.length > 0), | ||
| externalIds: w.doi ? { DOI: w.doi.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "") } : undefined, | ||
| url: w.primary_location?.landing_page_url ?? (w.doi ?? undefined), | ||
| })); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { describe, test, expect } from "bun:test"; | ||
| import { getProvider } from "./registry.ts"; | ||
|
|
||
| describe("getProvider", () => { | ||
| test("returns metadata for academic + openalex", () => { | ||
| const meta = getProvider("academic", "openalex"); | ||
| expect(meta).toBeDefined(); | ||
| expect(meta?.id).toBe("openalex"); | ||
| expect(meta?.freeTier).toBe(true); | ||
| expect(meta?.requiresKey).toBe(false); | ||
| expect(meta?.endpoint).toContain("api.openalex.org"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { describe, test, expect } from "bun:test"; | ||
| import type { ProviderId } from "./types.ts"; | ||
|
|
||
| describe("ProviderId", () => { | ||
| test("accepts openalex as a provider", () => { | ||
| const p: ProviderId = "openalex"; | ||
| expect(p).toBe("openalex"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.