nosible 0.1.5
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.
- package/README.md +438 -0
- package/dist/index.cjs +1841 -0
- package/dist/index.cjs.map +29 -0
- package/dist/index.js +1815 -0
- package/dist/index.js.map +29 -0
- package/package.json +63 -0
- package/src/api/api.test.ts +366 -0
- package/src/api/index.ts +179 -0
- package/src/api/schemas.ts +152 -0
- package/src/client.test.ts +685 -0
- package/src/client.ts +762 -0
- package/src/index.ts +4 -0
- package/src/scrape/types.ts +119 -0
- package/src/scrape/webPageData.test.ts +302 -0
- package/src/scrape/webPageData.ts +103 -0
- package/src/search/analyze.test.ts +396 -0
- package/src/search/analyze.ts +151 -0
- package/src/search/bulkSearch.ts +62 -0
- package/src/search/result.test.ts +423 -0
- package/src/search/result.ts +391 -0
- package/src/search/result.types.ts +32 -0
- package/src/search/resultFactory.ts +21 -0
- package/src/search/resultSet.io.test.ts +320 -0
- package/src/search/resultSet.test.ts +368 -0
- package/src/search/resultSet.ts +387 -0
- package/src/search/resultSet.types.ts +3 -0
- package/src/search/search.test.ts +299 -0
- package/src/search/search.ts +187 -0
- package/src/search/searchSet.io.test.ts +321 -0
- package/src/search/searchSet.ts +122 -0
- package/src/search/sqlFilter.test.ts +129 -0
- package/src/search/sqlFilter.ts +147 -0
- package/src/test-utils/mocks.ts +159 -0
- package/src/topicTrend/topicTrend.ts +53 -0
- package/src/utils/browser.test.ts +209 -0
- package/src/utils/browser.ts +21 -0
- package/src/utils/fernet.ts +47 -0
- package/src/utils/file.test.ts +81 -0
- package/src/utils/file.ts +195 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/llm.test.ts +279 -0
- package/src/utils/llm.ts +244 -0
- package/src/utils/userPlan.test.ts +332 -0
- package/src/utils/userPlan.ts +211 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {describe, expect, test} from "bun:test";
|
|
2
|
+
import {generateSqlFilter} from "./sqlFilter";
|
|
3
|
+
|
|
4
|
+
describe("generateSqlFilter", () => {
|
|
5
|
+
test("generates basic SQL without filters", () => {
|
|
6
|
+
const sql = generateSqlFilter({});
|
|
7
|
+
expect(sql).toBe(null);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("includes published date range", () => {
|
|
11
|
+
const start = new Date("2023-01-01T00:00:00Z");
|
|
12
|
+
const end = new Date("2023-12-31T23:59:59Z");
|
|
13
|
+
const sql = generateSqlFilter({
|
|
14
|
+
publishStart: start,
|
|
15
|
+
publishEnd: end,
|
|
16
|
+
});
|
|
17
|
+
expect(sql).toBe(
|
|
18
|
+
"SELECT loc FROM engine WHERE published >= '2023-01-01T00:00:00.000Z' AND published <= '2023-12-31T23:59:59.000Z'"
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("includes published start only", () => {
|
|
23
|
+
const start = new Date("2023-01-01T00:00:00Z");
|
|
24
|
+
const sql = generateSqlFilter({publishStart: start});
|
|
25
|
+
expect(sql).toBe(
|
|
26
|
+
"SELECT loc FROM engine WHERE published >= '2023-01-01T00:00:00.000Z'"
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("includes published end only", () => {
|
|
31
|
+
const end = new Date("2023-12-31T23:59:59Z");
|
|
32
|
+
const sql = generateSqlFilter({publishEnd: end});
|
|
33
|
+
expect(sql).toBe(
|
|
34
|
+
"SELECT loc FROM engine WHERE published <= '2023-12-31T23:59:59.000Z'"
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("includes visited date range", () => {
|
|
39
|
+
const start = new Date("2023-01-01T00:00:00Z");
|
|
40
|
+
const end = new Date("2023-12-31T23:59:59Z");
|
|
41
|
+
const sql = generateSqlFilter({
|
|
42
|
+
visitedStart: start,
|
|
43
|
+
visitedEnd: end,
|
|
44
|
+
});
|
|
45
|
+
expect(sql).toBe(
|
|
46
|
+
"SELECT loc FROM engine WHERE visited >= '2023-01-01T00:00:00.000Z' AND visited <= '2023-12-31T23:59:59.000Z'"
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("includes certain true", () => {
|
|
51
|
+
const sql = generateSqlFilter({certain: true});
|
|
52
|
+
expect(sql).toBe("SELECT loc FROM engine WHERE certain = TRUE");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("includes certain false", () => {
|
|
56
|
+
const sql = generateSqlFilter({certain: false});
|
|
57
|
+
expect(sql).toBe("SELECT loc FROM engine WHERE certain = FALSE");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("includes netlocs with variants", () => {
|
|
61
|
+
const sql = generateSqlFilter({
|
|
62
|
+
includeNetlocs: ["example.com", "www.test.com"],
|
|
63
|
+
});
|
|
64
|
+
expect(sql).toBe(
|
|
65
|
+
"SELECT loc FROM engine WHERE netloc IN ('example.com', 'test.com', 'www.example.com', 'www.test.com')"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("excludes netlocs with variants", () => {
|
|
70
|
+
const sql = generateSqlFilter({excludeNetlocs: ["example.com"]});
|
|
71
|
+
expect(sql).toBe(
|
|
72
|
+
"SELECT loc FROM engine WHERE netloc NOT IN ('example.com', 'www.example.com')"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("includes companies", () => {
|
|
77
|
+
const sql = generateSqlFilter({includeCompanies: ["GOOG", "AAPL"]});
|
|
78
|
+
expect(sql).toBe(
|
|
79
|
+
"SELECT loc FROM engine WHERE (companies IS NOT NULL AND (ARRAY_CONTAINS(companies, 'GOOG') OR ARRAY_CONTAINS(companies, 'AAPL')))"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("excludes companies", () => {
|
|
84
|
+
const sql = generateSqlFilter({excludeCompanies: ["GOOG"]});
|
|
85
|
+
expect(sql).toBe(
|
|
86
|
+
"SELECT loc FROM engine WHERE (companies IS NULL OR NOT (ARRAY_CONTAINS(companies, 'GOOG')))"
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("includes docs", () => {
|
|
91
|
+
const sql = generateSqlFilter({includeDocs: ["hash1", "hash2"]});
|
|
92
|
+
expect(sql).toBe(
|
|
93
|
+
"SELECT loc FROM engine WHERE doc_hash IN ('hash1', 'hash2')"
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("excludes docs", () => {
|
|
98
|
+
const sql = generateSqlFilter({excludeDocs: ["hash1"]});
|
|
99
|
+
expect(sql).toBe("SELECT loc FROM engine WHERE doc_hash NOT IN ('hash1')");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("combines multiple filters", () => {
|
|
103
|
+
const sql = generateSqlFilter({
|
|
104
|
+
publishStart: new Date("2023-01-01T00:00:00Z"),
|
|
105
|
+
certain: true,
|
|
106
|
+
includeNetlocs: ["example.com"],
|
|
107
|
+
includeCompanies: ["GOOG"],
|
|
108
|
+
});
|
|
109
|
+
expect(sql).toContain("published >= '2023-01-01T00:00:00.000Z'");
|
|
110
|
+
expect(sql).toContain("certain = TRUE");
|
|
111
|
+
expect(sql).toContain("netloc IN ('example.com', 'www.example.com')");
|
|
112
|
+
expect(sql).toContain(
|
|
113
|
+
"companies IS NOT NULL AND (ARRAY_CONTAINS(companies, 'GOOG'))"
|
|
114
|
+
);
|
|
115
|
+
expect(sql).toMatch(/^SELECT loc FROM engine WHERE/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("throws error for too many include_netlocs", () => {
|
|
119
|
+
const many = Array.from({length: 51}, (_, i) => `netloc${i}`);
|
|
120
|
+
expect(() => generateSqlFilter({includeNetlocs: many})).toThrow("Too big");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("throws error for too many exclude_companies", () => {
|
|
124
|
+
const many = Array.from({length: 51}, (_, i) => `company${i}`);
|
|
125
|
+
expect(() => generateSqlFilter({excludeCompanies: many})).toThrow(
|
|
126
|
+
"Too big"
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import z from "zod";
|
|
2
|
+
|
|
3
|
+
export const sqlFilterSchema = z.object({
|
|
4
|
+
publishStart: z.date().optional(),
|
|
5
|
+
publishEnd: z.date().optional(),
|
|
6
|
+
visitedStart: z.date().optional(),
|
|
7
|
+
visitedEnd: z.date().optional(),
|
|
8
|
+
certain: z.boolean().optional(),
|
|
9
|
+
includeNetlocs: z.array(z.string()).max(50).optional(),
|
|
10
|
+
excludeNetlocs: z.array(z.string()).max(50).optional(),
|
|
11
|
+
includeCompanies: z.array(z.string()).max(50).optional(),
|
|
12
|
+
excludeCompanies: z.array(z.string()).max(50).optional(),
|
|
13
|
+
includeDocs: z.array(z.string()).max(50).optional(),
|
|
14
|
+
excludeDocs: z.array(z.string()).max(50).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type SqlFilter = z.infer<typeof sqlFilterSchema>;
|
|
18
|
+
|
|
19
|
+
export function generateSqlFilter(input: SqlFilter): string | null {
|
|
20
|
+
const validatedInput = sqlFilterSchema.parse(input);
|
|
21
|
+
const {
|
|
22
|
+
publishStart,
|
|
23
|
+
publishEnd,
|
|
24
|
+
visitedStart,
|
|
25
|
+
visitedEnd,
|
|
26
|
+
certain,
|
|
27
|
+
includeNetlocs,
|
|
28
|
+
excludeNetlocs,
|
|
29
|
+
includeCompanies,
|
|
30
|
+
excludeCompanies,
|
|
31
|
+
includeDocs,
|
|
32
|
+
excludeDocs,
|
|
33
|
+
} = validatedInput;
|
|
34
|
+
const sql: string[] = ["SELECT loc FROM engine"];
|
|
35
|
+
const clauses: string[] = [];
|
|
36
|
+
|
|
37
|
+
// Published date range
|
|
38
|
+
if (publishStart || publishEnd) {
|
|
39
|
+
if (publishStart && publishEnd) {
|
|
40
|
+
clauses.push(
|
|
41
|
+
`published >= '${publishStart.toISOString()}' AND published <= '${publishEnd.toISOString()}'`
|
|
42
|
+
);
|
|
43
|
+
} else if (publishStart) {
|
|
44
|
+
clauses.push(`published >= '${publishStart.toISOString()}'`);
|
|
45
|
+
} else if (publishEnd) {
|
|
46
|
+
clauses.push(`published <= '${publishEnd.toISOString()}'`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Visited date range
|
|
51
|
+
if (visitedStart || visitedEnd) {
|
|
52
|
+
if (visitedStart && visitedEnd) {
|
|
53
|
+
clauses.push(
|
|
54
|
+
`visited >= '${visitedStart.toISOString()}' AND visited <= '${visitedEnd.toISOString()}'`
|
|
55
|
+
);
|
|
56
|
+
} else if (visitedStart) {
|
|
57
|
+
clauses.push(`visited >= '${visitedStart.toISOString()}'`);
|
|
58
|
+
} else if (visitedEnd) {
|
|
59
|
+
clauses.push(`visited <= '${visitedEnd.toISOString()}'`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Date certainty filter
|
|
64
|
+
if (certain === true) {
|
|
65
|
+
clauses.push("certain = TRUE");
|
|
66
|
+
} else if (certain === false) {
|
|
67
|
+
clauses.push("certain = FALSE");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Include netlocs with both www/non-www variants
|
|
71
|
+
if (includeNetlocs) {
|
|
72
|
+
const variants = new Set<string>();
|
|
73
|
+
for (const n of includeNetlocs) {
|
|
74
|
+
variants.add(n);
|
|
75
|
+
if (n.startsWith("www.")) {
|
|
76
|
+
variants.add(n.slice(4));
|
|
77
|
+
} else {
|
|
78
|
+
variants.add("www." + n);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const inList = Array.from(variants)
|
|
82
|
+
.sort()
|
|
83
|
+
.map((v) => `'${v}'`)
|
|
84
|
+
.join(", ");
|
|
85
|
+
clauses.push(`netloc IN (${inList})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Exclude netlocs with both www/non-www variants
|
|
89
|
+
if (excludeNetlocs) {
|
|
90
|
+
const variants = new Set<string>();
|
|
91
|
+
for (const n of excludeNetlocs) {
|
|
92
|
+
variants.add(n);
|
|
93
|
+
if (n.startsWith("www.")) {
|
|
94
|
+
variants.add(n.slice(4));
|
|
95
|
+
} else {
|
|
96
|
+
variants.add("www." + n);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const exList = Array.from(variants)
|
|
100
|
+
.sort()
|
|
101
|
+
.map((v) => `'${v}'`)
|
|
102
|
+
.join(", ");
|
|
103
|
+
clauses.push(`netloc NOT IN (${exList})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Include companies
|
|
107
|
+
if (includeCompanies) {
|
|
108
|
+
const companyList = includeCompanies
|
|
109
|
+
.map((c) => `ARRAY_CONTAINS(companies, '${c}')`)
|
|
110
|
+
.join(" OR ");
|
|
111
|
+
clauses.push(`(companies IS NOT NULL AND (${companyList}))`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Exclude companies
|
|
115
|
+
if (excludeCompanies) {
|
|
116
|
+
const companyList = excludeCompanies
|
|
117
|
+
.map((c) => `ARRAY_CONTAINS(companies, '${c}')`)
|
|
118
|
+
.join(" OR ");
|
|
119
|
+
clauses.push(`(companies IS NULL OR NOT (${companyList}))`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Include docs
|
|
123
|
+
if (includeDocs) {
|
|
124
|
+
const docHashes = includeDocs.map((doc) => `'${doc}'`).join(", ");
|
|
125
|
+
clauses.push(`doc_hash IN (${docHashes})`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Exclude docs
|
|
129
|
+
if (excludeDocs) {
|
|
130
|
+
const docHashes = excludeDocs.map((doc) => `'${doc}'`).join(", ");
|
|
131
|
+
clauses.push(`doc_hash NOT IN (${docHashes})`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Join everything
|
|
135
|
+
if (clauses.length > 0) {
|
|
136
|
+
sql.push("WHERE " + clauses.join(" AND "));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (sql.length === 1) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sqlFilter = sql.join(" ");
|
|
144
|
+
|
|
145
|
+
// TODO: Validate SQL
|
|
146
|
+
return sqlFilter;
|
|
147
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {vi} from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
SearchResultSemantics,
|
|
4
|
+
SearchResponse,
|
|
5
|
+
SearchQuery,
|
|
6
|
+
} from "../api/schemas";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared mock objects and utilities for testing
|
|
10
|
+
* This file avoids circular dependencies by only importing types, not classes
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Mock semantics data
|
|
14
|
+
export const mockSemantics: SearchResultSemantics = {
|
|
15
|
+
origin_shard: 1,
|
|
16
|
+
chunks_total: 10,
|
|
17
|
+
chunks_matched: 5,
|
|
18
|
+
chunks_kept: 3,
|
|
19
|
+
similarity: 0.85,
|
|
20
|
+
bitstring: "101010",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Mock search query
|
|
24
|
+
export const mockSearchQuery: SearchQuery = {
|
|
25
|
+
instruction: "Search for relevant articles",
|
|
26
|
+
question: "artificial intelligence machine learning",
|
|
27
|
+
expansions: ["AI", "ML", "neural networks"],
|
|
28
|
+
brand_safety: null,
|
|
29
|
+
language: null,
|
|
30
|
+
continent: null,
|
|
31
|
+
region: null,
|
|
32
|
+
country: null,
|
|
33
|
+
sector: null,
|
|
34
|
+
industry_group: null,
|
|
35
|
+
industry: null,
|
|
36
|
+
sub_industry: null,
|
|
37
|
+
iab_tier_1: null,
|
|
38
|
+
iab_tier_2: null,
|
|
39
|
+
iab_tier_3: null,
|
|
40
|
+
iab_tier_4: null,
|
|
41
|
+
algorithm: "hybrid-2",
|
|
42
|
+
n_results: 10,
|
|
43
|
+
n_contextify: 256,
|
|
44
|
+
n_probes: 5,
|
|
45
|
+
min_similarity: 0.7,
|
|
46
|
+
pass_similarity: 0.5,
|
|
47
|
+
must_exclude: [],
|
|
48
|
+
must_include: [],
|
|
49
|
+
shard_selector: "default",
|
|
50
|
+
shard_reranker: "default",
|
|
51
|
+
search_lang: "en",
|
|
52
|
+
search_words: {},
|
|
53
|
+
search_intents: {},
|
|
54
|
+
companies: [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Mock search response
|
|
58
|
+
export const mockSearchResponse: SearchResponse = {
|
|
59
|
+
url_hash: "abc123",
|
|
60
|
+
url: "https://example.com/article",
|
|
61
|
+
netloc: "example.com",
|
|
62
|
+
published: "2023-01-15",
|
|
63
|
+
visited: "2023-01-16",
|
|
64
|
+
language: "en",
|
|
65
|
+
author: "John Doe",
|
|
66
|
+
title: "Test Article Title",
|
|
67
|
+
description: "This is a test article description",
|
|
68
|
+
content:
|
|
69
|
+
"This is the full content of the test article. It contains multiple sentences and provides comprehensive information about the topic being discussed.",
|
|
70
|
+
best_chunk: "This is the most relevant chunk of content from the article.",
|
|
71
|
+
semantics: mockSemantics,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Additional mock search responses for testing multiple results
|
|
75
|
+
export const mockSearchResponse2: SearchResponse = {
|
|
76
|
+
url_hash: "def456",
|
|
77
|
+
url: "https://example.com/article2",
|
|
78
|
+
netloc: "example.com",
|
|
79
|
+
published: "2023-02-20",
|
|
80
|
+
visited: "2023-02-21",
|
|
81
|
+
language: "en",
|
|
82
|
+
author: "Jane Smith",
|
|
83
|
+
title: "Test Article 2",
|
|
84
|
+
description: "This is test article 2",
|
|
85
|
+
content:
|
|
86
|
+
"This is the full content of test article 2 about artificial intelligence.",
|
|
87
|
+
best_chunk: "Most relevant chunk from article 2.",
|
|
88
|
+
semantics: {
|
|
89
|
+
...mockSemantics,
|
|
90
|
+
origin_shard: 2,
|
|
91
|
+
similarity: 0.92,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Combined result (search query + search response)
|
|
96
|
+
export const mockResult = {
|
|
97
|
+
...mockSearchQuery,
|
|
98
|
+
...mockSearchResponse,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Factory function to create a mock NosibleClient
|
|
103
|
+
* Returns an object that can be cast to NosibleClient type in tests
|
|
104
|
+
*/
|
|
105
|
+
export const createMockClient = () => ({
|
|
106
|
+
fastSearch: vi.fn(),
|
|
107
|
+
scrapeUrl: vi.fn(),
|
|
108
|
+
bulkSearch: vi.fn(),
|
|
109
|
+
aiSearch: vi.fn(),
|
|
110
|
+
fastSearches: vi.fn(),
|
|
111
|
+
topicTrend: vi.fn(),
|
|
112
|
+
llmClient: {
|
|
113
|
+
chat: {
|
|
114
|
+
completions: {
|
|
115
|
+
create: vi.fn(),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
expansionsModel: "openai/gpt-4o",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create mock search response variations
|
|
124
|
+
*/
|
|
125
|
+
export const createMockSearchResponse = (
|
|
126
|
+
overrides: Partial<SearchResponse>
|
|
127
|
+
): SearchResponse => ({
|
|
128
|
+
...mockSearchResponse,
|
|
129
|
+
...overrides,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create mock semantics variations
|
|
134
|
+
*/
|
|
135
|
+
export const createMockSemantics = (
|
|
136
|
+
overrides: Partial<SearchResultSemantics>
|
|
137
|
+
): SearchResultSemantics => ({
|
|
138
|
+
...mockSemantics,
|
|
139
|
+
...overrides,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create multiple mock search responses for testing result sets
|
|
144
|
+
*/
|
|
145
|
+
export const createMockSearchResponses = (count: number): SearchResponse[] => {
|
|
146
|
+
return Array.from({length: count}, (_, i) => ({
|
|
147
|
+
...mockSearchResponse,
|
|
148
|
+
url_hash: `hash${i + 1}`,
|
|
149
|
+
url: `https://example.com/article${i + 1}`,
|
|
150
|
+
title: `Test Article ${i + 1}`,
|
|
151
|
+
description: `This is test article ${i + 1}`,
|
|
152
|
+
content: `This is the full content of test article ${i + 1}.`,
|
|
153
|
+
semantics: {
|
|
154
|
+
...mockSemantics,
|
|
155
|
+
origin_shard: i + 1,
|
|
156
|
+
similarity: 0.85 - i * 0.05,
|
|
157
|
+
},
|
|
158
|
+
}));
|
|
159
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface TopicTrendRes {
|
|
2
|
+
message: string;
|
|
3
|
+
query: Query;
|
|
4
|
+
response: {[key: string]: number};
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Query {
|
|
8
|
+
query: string;
|
|
9
|
+
brand_safety: null;
|
|
10
|
+
language: null;
|
|
11
|
+
continent: null;
|
|
12
|
+
region: null;
|
|
13
|
+
country: null;
|
|
14
|
+
sector: null;
|
|
15
|
+
industry_group: null;
|
|
16
|
+
industry: null;
|
|
17
|
+
sub_industry: null;
|
|
18
|
+
iab_tier_1: null;
|
|
19
|
+
iab_tier_2: null;
|
|
20
|
+
iab_tier_3: null;
|
|
21
|
+
iab_tier_4: null;
|
|
22
|
+
n_probes: number;
|
|
23
|
+
shard_selector: string;
|
|
24
|
+
shard_reranker: string;
|
|
25
|
+
search_lang: string;
|
|
26
|
+
search_words: SearchWords;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SearchWords {
|
|
30
|
+
[key: string]: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// TODO: How to handle when API return 500 error
|
|
34
|
+
|
|
35
|
+
// TODO: Add logic to allow user to pass in start and end date and then remove these from the response
|
|
36
|
+
export class TopicTrend {
|
|
37
|
+
data: TopicTrendRes;
|
|
38
|
+
|
|
39
|
+
constructor(data: TopicTrendRes) {
|
|
40
|
+
this.data = data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
peakDate(): Date {
|
|
44
|
+
const maxValue = Math.max(...Object.values(this.data.response));
|
|
45
|
+
const date = Object.keys(this.data.response).find(
|
|
46
|
+
(key) => this.data.response[key] === maxValue || undefined
|
|
47
|
+
);
|
|
48
|
+
if (!date) {
|
|
49
|
+
throw new Error("No peak date found");
|
|
50
|
+
}
|
|
51
|
+
return new Date(date);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {describe, test, expect, beforeEach, afterEach, mock} from "bun:test";
|
|
2
|
+
import {downloadBlob} from "./browser";
|
|
3
|
+
|
|
4
|
+
describe("downloadBlob", () => {
|
|
5
|
+
// Store original globals
|
|
6
|
+
let originalBlob: typeof Blob;
|
|
7
|
+
let originalURL: typeof URL;
|
|
8
|
+
let originalDocument: Document;
|
|
9
|
+
|
|
10
|
+
// Mock objects
|
|
11
|
+
let mockAnchor: any;
|
|
12
|
+
let mockBlob: any;
|
|
13
|
+
let mockObjectURL: string;
|
|
14
|
+
let createElementSpy: any;
|
|
15
|
+
let appendChildSpy: any;
|
|
16
|
+
let removeChildSpy: any;
|
|
17
|
+
let clickSpy: any;
|
|
18
|
+
let createObjectURLSpy: any;
|
|
19
|
+
let revokeObjectURLSpy: any;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
// Save originals
|
|
23
|
+
originalBlob = globalThis.Blob;
|
|
24
|
+
originalURL = globalThis.URL;
|
|
25
|
+
originalDocument = globalThis.document as any;
|
|
26
|
+
|
|
27
|
+
// Setup mock anchor element
|
|
28
|
+
clickSpy = mock(() => {});
|
|
29
|
+
mockAnchor = {
|
|
30
|
+
href: "",
|
|
31
|
+
download: "",
|
|
32
|
+
click: clickSpy,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Setup mock document
|
|
36
|
+
createElementSpy = mock(() => mockAnchor);
|
|
37
|
+
appendChildSpy = mock(() => {});
|
|
38
|
+
removeChildSpy = mock(() => {});
|
|
39
|
+
|
|
40
|
+
(globalThis as any).document = {
|
|
41
|
+
createElement: createElementSpy,
|
|
42
|
+
body: {
|
|
43
|
+
appendChild: appendChildSpy,
|
|
44
|
+
removeChild: removeChildSpy,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Setup mock Blob
|
|
49
|
+
mockBlob = {type: ""};
|
|
50
|
+
(globalThis as any).Blob = mock((content: any[], options: any) => {
|
|
51
|
+
mockBlob.type = options.type;
|
|
52
|
+
return mockBlob;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Setup mock URL
|
|
56
|
+
mockObjectURL = "blob:mock-url-12345";
|
|
57
|
+
createObjectURLSpy = mock(() => mockObjectURL);
|
|
58
|
+
revokeObjectURLSpy = mock(() => {});
|
|
59
|
+
|
|
60
|
+
(globalThis as any).URL = {
|
|
61
|
+
createObjectURL: createObjectURLSpy,
|
|
62
|
+
revokeObjectURL: revokeObjectURLSpy,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
// Restore originals
|
|
68
|
+
(globalThis as any).Blob = originalBlob;
|
|
69
|
+
(globalThis as any).URL = originalURL;
|
|
70
|
+
(globalThis as any).document = originalDocument;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("should create a blob with correct data and MIME type", () => {
|
|
74
|
+
const testData = '{"name": "test"}';
|
|
75
|
+
const mimeType = "application/json";
|
|
76
|
+
const fileName = "test.json";
|
|
77
|
+
|
|
78
|
+
downloadBlob(testData, mimeType, fileName);
|
|
79
|
+
|
|
80
|
+
expect(globalThis.Blob).toHaveBeenCalledWith([testData], {type: mimeType});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("should create an anchor element", () => {
|
|
84
|
+
downloadBlob("test data", "text/plain", "test.txt");
|
|
85
|
+
|
|
86
|
+
expect(createElementSpy).toHaveBeenCalledWith("a");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("should set correct href and download attributes on anchor", () => {
|
|
90
|
+
const fileName = "data.csv";
|
|
91
|
+
const mimeType = "text/csv";
|
|
92
|
+
const data = "col1,col2\nval1,val2";
|
|
93
|
+
|
|
94
|
+
downloadBlob(data, mimeType, fileName);
|
|
95
|
+
|
|
96
|
+
expect(mockAnchor.href).toBe(mockObjectURL);
|
|
97
|
+
expect(mockAnchor.download).toBe(fileName);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("should append anchor to body, click it, then remove it", () => {
|
|
101
|
+
downloadBlob("test", "text/plain", "test.txt");
|
|
102
|
+
|
|
103
|
+
expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor);
|
|
104
|
+
expect(clickSpy).toHaveBeenCalled();
|
|
105
|
+
expect(removeChildSpy).toHaveBeenCalledWith(mockAnchor);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("should create and revoke object URL", () => {
|
|
109
|
+
downloadBlob("test", "text/plain", "test.txt");
|
|
110
|
+
|
|
111
|
+
expect(createObjectURLSpy).toHaveBeenCalledWith(mockBlob);
|
|
112
|
+
expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockObjectURL);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("should handle JSON data correctly", () => {
|
|
116
|
+
const jsonData = JSON.stringify({users: [{id: 1, name: "Alice"}]});
|
|
117
|
+
const mimeType = "application/json";
|
|
118
|
+
const fileName = "users.json";
|
|
119
|
+
|
|
120
|
+
downloadBlob(jsonData, mimeType, fileName);
|
|
121
|
+
|
|
122
|
+
expect(globalThis.Blob).toHaveBeenCalledWith([jsonData], {type: mimeType});
|
|
123
|
+
expect(mockAnchor.download).toBe(fileName);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("should handle CSV data correctly", () => {
|
|
127
|
+
const csvData = "name,age\nJohn,30\nJane,25";
|
|
128
|
+
const mimeType = "text/csv";
|
|
129
|
+
const fileName = "data.csv";
|
|
130
|
+
|
|
131
|
+
downloadBlob(csvData, mimeType, fileName);
|
|
132
|
+
|
|
133
|
+
expect(globalThis.Blob).toHaveBeenCalledWith([csvData], {type: mimeType});
|
|
134
|
+
expect(mockAnchor.download).toBe(fileName);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("should handle empty data", () => {
|
|
138
|
+
const emptyData = "";
|
|
139
|
+
const mimeType = "text/plain";
|
|
140
|
+
const fileName = "empty.txt";
|
|
141
|
+
|
|
142
|
+
downloadBlob(emptyData, mimeType, fileName);
|
|
143
|
+
|
|
144
|
+
expect(globalThis.Blob).toHaveBeenCalledWith([emptyData], {type: mimeType});
|
|
145
|
+
expect(clickSpy).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("should handle special characters in filename", () => {
|
|
149
|
+
const fileName = "test file (1) [copy].txt";
|
|
150
|
+
|
|
151
|
+
downloadBlob("data", "text/plain", fileName);
|
|
152
|
+
|
|
153
|
+
expect(mockAnchor.download).toBe(fileName);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("should execute operations in correct order", () => {
|
|
157
|
+
const callOrder: string[] = [];
|
|
158
|
+
|
|
159
|
+
const orderClickSpy = mock(() => {
|
|
160
|
+
callOrder.push("click");
|
|
161
|
+
});
|
|
162
|
+
const orderAnchor = {
|
|
163
|
+
href: "",
|
|
164
|
+
download: "",
|
|
165
|
+
click: orderClickSpy,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
createElementSpy = mock(() => {
|
|
169
|
+
callOrder.push("createElement");
|
|
170
|
+
return orderAnchor;
|
|
171
|
+
});
|
|
172
|
+
appendChildSpy = mock(() => {
|
|
173
|
+
callOrder.push("appendChild");
|
|
174
|
+
});
|
|
175
|
+
removeChildSpy = mock(() => {
|
|
176
|
+
callOrder.push("removeChild");
|
|
177
|
+
});
|
|
178
|
+
createObjectURLSpy = mock(() => {
|
|
179
|
+
callOrder.push("createObjectURL");
|
|
180
|
+
return mockObjectURL;
|
|
181
|
+
});
|
|
182
|
+
revokeObjectURLSpy = mock(() => {
|
|
183
|
+
callOrder.push("revokeObjectURL");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
(globalThis as any).document = {
|
|
187
|
+
createElement: createElementSpy,
|
|
188
|
+
body: {
|
|
189
|
+
appendChild: appendChildSpy,
|
|
190
|
+
removeChild: removeChildSpy,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
(globalThis as any).URL = {
|
|
194
|
+
createObjectURL: createObjectURLSpy,
|
|
195
|
+
revokeObjectURL: revokeObjectURLSpy,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
downloadBlob("test", "text/plain", "test.txt");
|
|
199
|
+
|
|
200
|
+
expect(callOrder).toEqual([
|
|
201
|
+
"createObjectURL",
|
|
202
|
+
"createElement",
|
|
203
|
+
"appendChild",
|
|
204
|
+
"click",
|
|
205
|
+
"removeChild",
|
|
206
|
+
"revokeObjectURL",
|
|
207
|
+
]);
|
|
208
|
+
});
|
|
209
|
+
});
|