@halallens/halal-scanner 1.0.0
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/LICENSE +26 -0
- package/README.md +130 -0
- package/dist/index.d.mts +185 -0
- package/dist/index.d.ts +185 -0
- package/dist/index.js +173 -0
- package/dist/index.mjs +143 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HalalLens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
Attribution Requirement:
|
|
16
|
+
- Any product, service, or derivative work using this Software must include
|
|
17
|
+
visible attribution to HalalLens (https://halallens.no) in user-facing
|
|
18
|
+
documentation or about screens.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/screenshot-hero.png" alt="Free Halal Scanner" width="600">
|
|
3
|
+
|
|
4
|
+
# Free Halal Scanner
|
|
5
|
+
|
|
6
|
+
**Check if any food ingredient is halal, haram, or mushbooh — instantly.**
|
|
7
|
+
|
|
8
|
+
[](https://www.npmjs.com/package/@halallens/halal-scanner)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](https://www.typescriptlang.org/)
|
|
11
|
+
|
|
12
|
+
[Live Demo](https://halallens-no.github.io/halal-scanner/) · [npm Package](https://www.npmjs.com/package/@halallens/halal-scanner) · [Full Web App](https://app.halallens.no)
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What is this?
|
|
18
|
+
|
|
19
|
+
A free, open-source npm package and demo UI for checking the halal status of food ingredients. Powered by the [HalalLens](https://halallens.no) API — a database of 10,000+ ingredients analyzed by AI and verified by the community.
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
- **Instant search** — Type any ingredient name or E-number
|
|
24
|
+
- **Halal status** — HALAL, HARAM, MUSHBOOH, or UNKNOWN with confidence scores
|
|
25
|
+
- **AI explanations** — Understand *why* an ingredient has its status
|
|
26
|
+
- **TypeScript support** — Full type definitions included
|
|
27
|
+
- **Zero dependencies** — Uses native `fetch`, works in Node.js 18+ and all modern browsers
|
|
28
|
+
- **Free forever** — No API key required
|
|
29
|
+
|
|
30
|
+
## Try it
|
|
31
|
+
|
|
32
|
+
**[Live Demo →](https://halallens-no.github.io/halal-scanner/)**
|
|
33
|
+
|
|
34
|
+
<img src="assets/screenshot-results.png" alt="Search Results" width="600">
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install @halallens/halal-scanner
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { HalalScanner } from '@halallens/halal-scanner';
|
|
46
|
+
|
|
47
|
+
const scanner = new HalalScanner();
|
|
48
|
+
|
|
49
|
+
// Search for ingredients
|
|
50
|
+
const results = await scanner.search('gelatin');
|
|
51
|
+
console.log(results.items[0].halal_status); // "HARAM"
|
|
52
|
+
console.log(results.items[0].display_status); // "Verified HARAM"
|
|
53
|
+
|
|
54
|
+
// Get detailed ingredient info
|
|
55
|
+
const detail = await scanner.check('en', 'gelatin');
|
|
56
|
+
console.log(detail.explanation);
|
|
57
|
+
console.log(detail.food_source); // "Animal"
|
|
58
|
+
|
|
59
|
+
// Batch analyze multiple ingredients
|
|
60
|
+
const analysis = await scanner.analyze(['gelatin', 'sugar', 'E471']);
|
|
61
|
+
console.log(`${analysis.halal_count} halal, ${analysis.haram_count} haram`);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
### `new HalalScanner(options?)`
|
|
67
|
+
|
|
68
|
+
| Option | Type | Default | Description |
|
|
69
|
+
|--------|------|---------|-------------|
|
|
70
|
+
| `baseUrl` | `string` | `https://halallens.no/api` | API base URL |
|
|
71
|
+
| `deviceId` | `string` | Auto-generated | Device ID for usage tracking |
|
|
72
|
+
| `timeout` | `number` | `10000` | Request timeout in ms |
|
|
73
|
+
|
|
74
|
+
### `scanner.search(query, params?)`
|
|
75
|
+
|
|
76
|
+
Search for ingredients by name or E-number.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
const results = await scanner.search('E471', { perPage: 5 });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `scanner.check(lang, name)`
|
|
83
|
+
|
|
84
|
+
Get detailed info about a specific ingredient.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
const info = await scanner.check('en', 'sugar');
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `scanner.analyze(ingredients)`
|
|
91
|
+
|
|
92
|
+
Analyze multiple ingredients at once.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
const result = await scanner.analyze(['sugar', 'gelatin', 'citric acid']);
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Status Values
|
|
99
|
+
|
|
100
|
+
| Status | Meaning |
|
|
101
|
+
|--------|---------|
|
|
102
|
+
| `HALAL` | Permissible to consume |
|
|
103
|
+
| `HARAM` | Not permissible to consume |
|
|
104
|
+
| `MUSHBOOH` | Doubtful — depends on source or processing |
|
|
105
|
+
| `UNKNOWN` | Not yet analyzed |
|
|
106
|
+
|
|
107
|
+
## Want more?
|
|
108
|
+
|
|
109
|
+
This package provides ingredient lookup. For the **full experience**:
|
|
110
|
+
|
|
111
|
+
- **Scan product labels** with your camera (OCR-powered)
|
|
112
|
+
- **AI-powered analysis** of entire ingredient lists
|
|
113
|
+
- **Multi-language support** in 14+ languages
|
|
114
|
+
- **Mobile apps** for Android and iOS
|
|
115
|
+
|
|
116
|
+
**[Try HalalLens →](https://app.halallens.no)**
|
|
117
|
+
|
|
118
|
+
## Contributing
|
|
119
|
+
|
|
120
|
+
Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a PR.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
[MIT](LICENSE) — Free to use with attribution to [HalalLens](https://halallens.no).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
<div align="center">
|
|
129
|
+
<sub>Built with care by <a href="https://halallens.no">HalalLens</a> — AI-powered halal food verification</sub>
|
|
130
|
+
</div>
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Halal status of an ingredient */
|
|
2
|
+
type HalalStatus = 'HALAL' | 'HARAM' | 'MUSHBOOH' | 'UNKNOWN' | 'NOT_FOOD';
|
|
3
|
+
/** Data source for the status determination */
|
|
4
|
+
type IngredientSource = 'database' | 'llm' | 'manual' | 'cache';
|
|
5
|
+
/** Scanner configuration options */
|
|
6
|
+
interface ScannerOptions {
|
|
7
|
+
/** API base URL. Defaults to https://halallens.no/api */
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/** Device ID for usage tracking. Auto-generated if not provided. */
|
|
10
|
+
deviceId?: string;
|
|
11
|
+
/** Request timeout in milliseconds. Defaults to 10000. */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
interface SearchParams {
|
|
15
|
+
/** Search query text */
|
|
16
|
+
query: string;
|
|
17
|
+
/** Filter by halal status */
|
|
18
|
+
status?: HalalStatus;
|
|
19
|
+
/** Filter by category */
|
|
20
|
+
category?: string;
|
|
21
|
+
/** Language code (ISO 639-1). Defaults to "en". */
|
|
22
|
+
language?: string;
|
|
23
|
+
/** Only return verified ingredients */
|
|
24
|
+
verifiedOnly?: boolean;
|
|
25
|
+
/** Only return E-numbers */
|
|
26
|
+
eNumber?: boolean;
|
|
27
|
+
/** Page number. Defaults to 1. */
|
|
28
|
+
page?: number;
|
|
29
|
+
/** Results per page. Defaults to 20, max 100. */
|
|
30
|
+
perPage?: number;
|
|
31
|
+
}
|
|
32
|
+
interface SearchResult {
|
|
33
|
+
items: IngredientSummary[];
|
|
34
|
+
total: number;
|
|
35
|
+
page: number;
|
|
36
|
+
per_page: number;
|
|
37
|
+
pages: number;
|
|
38
|
+
language_matches: LanguageMatch[];
|
|
39
|
+
fuzzy_suggestions: FuzzySuggestion[];
|
|
40
|
+
}
|
|
41
|
+
interface IngredientSummary {
|
|
42
|
+
id: number;
|
|
43
|
+
name: string;
|
|
44
|
+
category: string | null;
|
|
45
|
+
language_code: string;
|
|
46
|
+
halal_status: HalalStatus;
|
|
47
|
+
display_status: string;
|
|
48
|
+
status_verified: boolean;
|
|
49
|
+
status_color: string;
|
|
50
|
+
image_url: string | null;
|
|
51
|
+
has_grounding: boolean;
|
|
52
|
+
has_elaborated_explanation: boolean;
|
|
53
|
+
is_gemini_enriched: boolean;
|
|
54
|
+
ai_review: AIReviewSummary | null;
|
|
55
|
+
similar_items: SimilarItem[];
|
|
56
|
+
}
|
|
57
|
+
interface AIReviewSummary {
|
|
58
|
+
ai_review_count: number;
|
|
59
|
+
agreement_percentage: number;
|
|
60
|
+
consensus_status: HalalStatus | null;
|
|
61
|
+
consensus_confidence: number | null;
|
|
62
|
+
analyzed_at: string | null;
|
|
63
|
+
opinions: AIOpinion[];
|
|
64
|
+
show_all_opinions: boolean;
|
|
65
|
+
}
|
|
66
|
+
interface AIOpinion {
|
|
67
|
+
halal_status: HalalStatus;
|
|
68
|
+
confidence: number;
|
|
69
|
+
explanation: string;
|
|
70
|
+
food_source: string | null;
|
|
71
|
+
}
|
|
72
|
+
interface SimilarItem {
|
|
73
|
+
id: number;
|
|
74
|
+
name: string;
|
|
75
|
+
halal_status: HalalStatus;
|
|
76
|
+
status_verified: boolean;
|
|
77
|
+
status_color: string;
|
|
78
|
+
ai_review_count: number;
|
|
79
|
+
}
|
|
80
|
+
interface LanguageMatch {
|
|
81
|
+
language_code: string;
|
|
82
|
+
language_name: string;
|
|
83
|
+
match_count: number;
|
|
84
|
+
}
|
|
85
|
+
interface FuzzySuggestion {
|
|
86
|
+
id: number;
|
|
87
|
+
name: string;
|
|
88
|
+
halal_status: HalalStatus;
|
|
89
|
+
display_status: string;
|
|
90
|
+
status_color: string;
|
|
91
|
+
similarity: number;
|
|
92
|
+
}
|
|
93
|
+
interface IngredientDetail extends IngredientSummary {
|
|
94
|
+
explanation: string | null;
|
|
95
|
+
food_source: string | null;
|
|
96
|
+
verified_at: string | null;
|
|
97
|
+
verification_notes: string | null;
|
|
98
|
+
translations: IngredientTranslation[];
|
|
99
|
+
explanation_language: string;
|
|
100
|
+
translation_available: boolean;
|
|
101
|
+
created_at: string | null;
|
|
102
|
+
updated_at: string | null;
|
|
103
|
+
}
|
|
104
|
+
interface IngredientTranslation {
|
|
105
|
+
language_code: string;
|
|
106
|
+
translated_name: string;
|
|
107
|
+
is_validated: boolean;
|
|
108
|
+
}
|
|
109
|
+
interface AnalyzeResult {
|
|
110
|
+
queue_id: string | null;
|
|
111
|
+
status: 'pending' | 'processing' | 'completed' | 'error';
|
|
112
|
+
ingredients: AnalyzedIngredient[];
|
|
113
|
+
total_count: number;
|
|
114
|
+
halal_count: number;
|
|
115
|
+
haram_count: number;
|
|
116
|
+
mushbooh_count: number;
|
|
117
|
+
unknown_count: number;
|
|
118
|
+
completion_percentage: number;
|
|
119
|
+
error: string | null;
|
|
120
|
+
}
|
|
121
|
+
interface AnalyzedIngredient {
|
|
122
|
+
ocr_item_id: string | null;
|
|
123
|
+
ingredient_id: number | null;
|
|
124
|
+
original_text: string;
|
|
125
|
+
display_name: string;
|
|
126
|
+
source_language: string;
|
|
127
|
+
halal_status: HalalStatus;
|
|
128
|
+
confidence: number;
|
|
129
|
+
source: IngredientSource;
|
|
130
|
+
explanation: string | null;
|
|
131
|
+
image_url: string | null;
|
|
132
|
+
is_verified: boolean;
|
|
133
|
+
has_grounding: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
declare class HalalScanner {
|
|
137
|
+
private readonly baseUrl;
|
|
138
|
+
private readonly deviceId;
|
|
139
|
+
private readonly timeout;
|
|
140
|
+
constructor(options?: ScannerOptions);
|
|
141
|
+
/**
|
|
142
|
+
* Search for ingredients by name.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* const results = await scanner.search('gelatin');
|
|
146
|
+
* console.log(results.items[0].halal_status); // "HARAM"
|
|
147
|
+
*/
|
|
148
|
+
search(query: string, params?: Partial<SearchParams>): Promise<SearchResult>;
|
|
149
|
+
/**
|
|
150
|
+
* Get detailed information about a specific ingredient.
|
|
151
|
+
*
|
|
152
|
+
* @param lang - Language code (e.g., "en", "ar", "no")
|
|
153
|
+
* @param name - Ingredient URL slug (e.g., "gelatin", "sugar")
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const detail = await scanner.check('en', 'gelatin');
|
|
157
|
+
* console.log(detail.explanation);
|
|
158
|
+
*/
|
|
159
|
+
check(lang: string, name: string): Promise<IngredientDetail>;
|
|
160
|
+
/**
|
|
161
|
+
* Analyze a list of ingredients for halal status.
|
|
162
|
+
*
|
|
163
|
+
* @param ingredients - Array of ingredient names to check
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const result = await scanner.analyze(['gelatin', 'sugar', 'E471']);
|
|
167
|
+
* console.log(`${result.halal_count} halal, ${result.haram_count} haram`);
|
|
168
|
+
*/
|
|
169
|
+
analyze(ingredients: string[]): Promise<AnalyzeResult>;
|
|
170
|
+
private request;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
declare class HalalScannerError extends Error {
|
|
174
|
+
readonly statusCode?: number | undefined;
|
|
175
|
+
readonly endpoint?: string | undefined;
|
|
176
|
+
constructor(message: string, statusCode?: number | undefined, endpoint?: string | undefined);
|
|
177
|
+
}
|
|
178
|
+
declare class RateLimitError extends HalalScannerError {
|
|
179
|
+
constructor(endpoint: string);
|
|
180
|
+
}
|
|
181
|
+
declare class NetworkError extends HalalScannerError {
|
|
182
|
+
constructor(message: string, endpoint: string);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { type AIOpinion, type AIReviewSummary, type AnalyzeResult, type AnalyzedIngredient, type FuzzySuggestion, HalalScanner, HalalScannerError, type HalalStatus, type IngredientDetail, type IngredientSource, type IngredientSummary, type IngredientTranslation, type LanguageMatch, NetworkError, RateLimitError, type ScannerOptions, type SearchParams, type SearchResult, type SimilarItem };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Halal status of an ingredient */
|
|
2
|
+
type HalalStatus = 'HALAL' | 'HARAM' | 'MUSHBOOH' | 'UNKNOWN' | 'NOT_FOOD';
|
|
3
|
+
/** Data source for the status determination */
|
|
4
|
+
type IngredientSource = 'database' | 'llm' | 'manual' | 'cache';
|
|
5
|
+
/** Scanner configuration options */
|
|
6
|
+
interface ScannerOptions {
|
|
7
|
+
/** API base URL. Defaults to https://halallens.no/api */
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/** Device ID for usage tracking. Auto-generated if not provided. */
|
|
10
|
+
deviceId?: string;
|
|
11
|
+
/** Request timeout in milliseconds. Defaults to 10000. */
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
interface SearchParams {
|
|
15
|
+
/** Search query text */
|
|
16
|
+
query: string;
|
|
17
|
+
/** Filter by halal status */
|
|
18
|
+
status?: HalalStatus;
|
|
19
|
+
/** Filter by category */
|
|
20
|
+
category?: string;
|
|
21
|
+
/** Language code (ISO 639-1). Defaults to "en". */
|
|
22
|
+
language?: string;
|
|
23
|
+
/** Only return verified ingredients */
|
|
24
|
+
verifiedOnly?: boolean;
|
|
25
|
+
/** Only return E-numbers */
|
|
26
|
+
eNumber?: boolean;
|
|
27
|
+
/** Page number. Defaults to 1. */
|
|
28
|
+
page?: number;
|
|
29
|
+
/** Results per page. Defaults to 20, max 100. */
|
|
30
|
+
perPage?: number;
|
|
31
|
+
}
|
|
32
|
+
interface SearchResult {
|
|
33
|
+
items: IngredientSummary[];
|
|
34
|
+
total: number;
|
|
35
|
+
page: number;
|
|
36
|
+
per_page: number;
|
|
37
|
+
pages: number;
|
|
38
|
+
language_matches: LanguageMatch[];
|
|
39
|
+
fuzzy_suggestions: FuzzySuggestion[];
|
|
40
|
+
}
|
|
41
|
+
interface IngredientSummary {
|
|
42
|
+
id: number;
|
|
43
|
+
name: string;
|
|
44
|
+
category: string | null;
|
|
45
|
+
language_code: string;
|
|
46
|
+
halal_status: HalalStatus;
|
|
47
|
+
display_status: string;
|
|
48
|
+
status_verified: boolean;
|
|
49
|
+
status_color: string;
|
|
50
|
+
image_url: string | null;
|
|
51
|
+
has_grounding: boolean;
|
|
52
|
+
has_elaborated_explanation: boolean;
|
|
53
|
+
is_gemini_enriched: boolean;
|
|
54
|
+
ai_review: AIReviewSummary | null;
|
|
55
|
+
similar_items: SimilarItem[];
|
|
56
|
+
}
|
|
57
|
+
interface AIReviewSummary {
|
|
58
|
+
ai_review_count: number;
|
|
59
|
+
agreement_percentage: number;
|
|
60
|
+
consensus_status: HalalStatus | null;
|
|
61
|
+
consensus_confidence: number | null;
|
|
62
|
+
analyzed_at: string | null;
|
|
63
|
+
opinions: AIOpinion[];
|
|
64
|
+
show_all_opinions: boolean;
|
|
65
|
+
}
|
|
66
|
+
interface AIOpinion {
|
|
67
|
+
halal_status: HalalStatus;
|
|
68
|
+
confidence: number;
|
|
69
|
+
explanation: string;
|
|
70
|
+
food_source: string | null;
|
|
71
|
+
}
|
|
72
|
+
interface SimilarItem {
|
|
73
|
+
id: number;
|
|
74
|
+
name: string;
|
|
75
|
+
halal_status: HalalStatus;
|
|
76
|
+
status_verified: boolean;
|
|
77
|
+
status_color: string;
|
|
78
|
+
ai_review_count: number;
|
|
79
|
+
}
|
|
80
|
+
interface LanguageMatch {
|
|
81
|
+
language_code: string;
|
|
82
|
+
language_name: string;
|
|
83
|
+
match_count: number;
|
|
84
|
+
}
|
|
85
|
+
interface FuzzySuggestion {
|
|
86
|
+
id: number;
|
|
87
|
+
name: string;
|
|
88
|
+
halal_status: HalalStatus;
|
|
89
|
+
display_status: string;
|
|
90
|
+
status_color: string;
|
|
91
|
+
similarity: number;
|
|
92
|
+
}
|
|
93
|
+
interface IngredientDetail extends IngredientSummary {
|
|
94
|
+
explanation: string | null;
|
|
95
|
+
food_source: string | null;
|
|
96
|
+
verified_at: string | null;
|
|
97
|
+
verification_notes: string | null;
|
|
98
|
+
translations: IngredientTranslation[];
|
|
99
|
+
explanation_language: string;
|
|
100
|
+
translation_available: boolean;
|
|
101
|
+
created_at: string | null;
|
|
102
|
+
updated_at: string | null;
|
|
103
|
+
}
|
|
104
|
+
interface IngredientTranslation {
|
|
105
|
+
language_code: string;
|
|
106
|
+
translated_name: string;
|
|
107
|
+
is_validated: boolean;
|
|
108
|
+
}
|
|
109
|
+
interface AnalyzeResult {
|
|
110
|
+
queue_id: string | null;
|
|
111
|
+
status: 'pending' | 'processing' | 'completed' | 'error';
|
|
112
|
+
ingredients: AnalyzedIngredient[];
|
|
113
|
+
total_count: number;
|
|
114
|
+
halal_count: number;
|
|
115
|
+
haram_count: number;
|
|
116
|
+
mushbooh_count: number;
|
|
117
|
+
unknown_count: number;
|
|
118
|
+
completion_percentage: number;
|
|
119
|
+
error: string | null;
|
|
120
|
+
}
|
|
121
|
+
interface AnalyzedIngredient {
|
|
122
|
+
ocr_item_id: string | null;
|
|
123
|
+
ingredient_id: number | null;
|
|
124
|
+
original_text: string;
|
|
125
|
+
display_name: string;
|
|
126
|
+
source_language: string;
|
|
127
|
+
halal_status: HalalStatus;
|
|
128
|
+
confidence: number;
|
|
129
|
+
source: IngredientSource;
|
|
130
|
+
explanation: string | null;
|
|
131
|
+
image_url: string | null;
|
|
132
|
+
is_verified: boolean;
|
|
133
|
+
has_grounding: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
declare class HalalScanner {
|
|
137
|
+
private readonly baseUrl;
|
|
138
|
+
private readonly deviceId;
|
|
139
|
+
private readonly timeout;
|
|
140
|
+
constructor(options?: ScannerOptions);
|
|
141
|
+
/**
|
|
142
|
+
* Search for ingredients by name.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* const results = await scanner.search('gelatin');
|
|
146
|
+
* console.log(results.items[0].halal_status); // "HARAM"
|
|
147
|
+
*/
|
|
148
|
+
search(query: string, params?: Partial<SearchParams>): Promise<SearchResult>;
|
|
149
|
+
/**
|
|
150
|
+
* Get detailed information about a specific ingredient.
|
|
151
|
+
*
|
|
152
|
+
* @param lang - Language code (e.g., "en", "ar", "no")
|
|
153
|
+
* @param name - Ingredient URL slug (e.g., "gelatin", "sugar")
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const detail = await scanner.check('en', 'gelatin');
|
|
157
|
+
* console.log(detail.explanation);
|
|
158
|
+
*/
|
|
159
|
+
check(lang: string, name: string): Promise<IngredientDetail>;
|
|
160
|
+
/**
|
|
161
|
+
* Analyze a list of ingredients for halal status.
|
|
162
|
+
*
|
|
163
|
+
* @param ingredients - Array of ingredient names to check
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const result = await scanner.analyze(['gelatin', 'sugar', 'E471']);
|
|
167
|
+
* console.log(`${result.halal_count} halal, ${result.haram_count} haram`);
|
|
168
|
+
*/
|
|
169
|
+
analyze(ingredients: string[]): Promise<AnalyzeResult>;
|
|
170
|
+
private request;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
declare class HalalScannerError extends Error {
|
|
174
|
+
readonly statusCode?: number | undefined;
|
|
175
|
+
readonly endpoint?: string | undefined;
|
|
176
|
+
constructor(message: string, statusCode?: number | undefined, endpoint?: string | undefined);
|
|
177
|
+
}
|
|
178
|
+
declare class RateLimitError extends HalalScannerError {
|
|
179
|
+
constructor(endpoint: string);
|
|
180
|
+
}
|
|
181
|
+
declare class NetworkError extends HalalScannerError {
|
|
182
|
+
constructor(message: string, endpoint: string);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { type AIOpinion, type AIReviewSummary, type AnalyzeResult, type AnalyzedIngredient, type FuzzySuggestion, HalalScanner, HalalScannerError, type HalalStatus, type IngredientDetail, type IngredientSource, type IngredientSummary, type IngredientTranslation, type LanguageMatch, NetworkError, RateLimitError, type ScannerOptions, type SearchParams, type SearchResult, type SimilarItem };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
HalalScanner: () => HalalScanner,
|
|
24
|
+
HalalScannerError: () => HalalScannerError,
|
|
25
|
+
NetworkError: () => NetworkError,
|
|
26
|
+
RateLimitError: () => RateLimitError
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/errors.ts
|
|
31
|
+
var HalalScannerError = class extends Error {
|
|
32
|
+
constructor(message, statusCode, endpoint) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.statusCode = statusCode;
|
|
35
|
+
this.endpoint = endpoint;
|
|
36
|
+
this.name = "HalalScannerError";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var RateLimitError = class extends HalalScannerError {
|
|
40
|
+
constructor(endpoint) {
|
|
41
|
+
super("Rate limit exceeded. Please wait before making more requests.", 429, endpoint);
|
|
42
|
+
this.name = "RateLimitError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var NetworkError = class extends HalalScannerError {
|
|
46
|
+
constructor(message, endpoint) {
|
|
47
|
+
super(`Network error: ${message}`, void 0, endpoint);
|
|
48
|
+
this.name = "NetworkError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/client.ts
|
|
53
|
+
var DEFAULT_BASE_URL = "https://halallens.no/api";
|
|
54
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
55
|
+
function generateDeviceId() {
|
|
56
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
57
|
+
let id = "hs-";
|
|
58
|
+
for (let i = 0; i < 16; i++) {
|
|
59
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
60
|
+
}
|
|
61
|
+
return id;
|
|
62
|
+
}
|
|
63
|
+
var HalalScanner = class {
|
|
64
|
+
constructor(options = {}) {
|
|
65
|
+
this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
66
|
+
this.deviceId = options.deviceId || generateDeviceId();
|
|
67
|
+
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Search for ingredients by name.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* const results = await scanner.search('gelatin');
|
|
74
|
+
* console.log(results.items[0].halal_status); // "HARAM"
|
|
75
|
+
*/
|
|
76
|
+
async search(query, params = {}) {
|
|
77
|
+
const searchParams = new URLSearchParams({ search: query });
|
|
78
|
+
if (params.status) searchParams.set("status", params.status);
|
|
79
|
+
if (params.category) searchParams.set("category", params.category);
|
|
80
|
+
if (params.language) searchParams.set("language", params.language);
|
|
81
|
+
if (params.verifiedOnly) searchParams.set("verified_only", "true");
|
|
82
|
+
if (params.eNumber) searchParams.set("e_number", "true");
|
|
83
|
+
if (params.page) searchParams.set("page", String(params.page));
|
|
84
|
+
if (params.perPage) searchParams.set("per_page", String(params.perPage));
|
|
85
|
+
return this.request(
|
|
86
|
+
`/v1/public/ingredients?${searchParams.toString()}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Get detailed information about a specific ingredient.
|
|
91
|
+
*
|
|
92
|
+
* @param lang - Language code (e.g., "en", "ar", "no")
|
|
93
|
+
* @param name - Ingredient URL slug (e.g., "gelatin", "sugar")
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* const detail = await scanner.check('en', 'gelatin');
|
|
97
|
+
* console.log(detail.explanation);
|
|
98
|
+
*/
|
|
99
|
+
async check(lang, name) {
|
|
100
|
+
return this.request(
|
|
101
|
+
`/v1/public/ingredients/${encodeURIComponent(lang)}/${encodeURIComponent(name)}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Analyze a list of ingredients for halal status.
|
|
106
|
+
*
|
|
107
|
+
* @param ingredients - Array of ingredient names to check
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* const result = await scanner.analyze(['gelatin', 'sugar', 'E471']);
|
|
111
|
+
* console.log(`${result.halal_count} halal, ${result.haram_count} haram`);
|
|
112
|
+
*/
|
|
113
|
+
async analyze(ingredients) {
|
|
114
|
+
return this.request("/v1/queue/ingredients/json", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
ingredients: ingredients.map((name) => ({
|
|
119
|
+
name,
|
|
120
|
+
halal_status: "UNKNOWN",
|
|
121
|
+
language: "en",
|
|
122
|
+
confidence: 0
|
|
123
|
+
})),
|
|
124
|
+
device_id: this.deviceId
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async request(path, init) {
|
|
129
|
+
const url = `${this.baseUrl}${path}`;
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
...init,
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
headers: {
|
|
137
|
+
Accept: "application/json",
|
|
138
|
+
...init?.headers
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
if (response.status === 429) {
|
|
142
|
+
throw new RateLimitError(path);
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok) {
|
|
145
|
+
const text = await response.text().catch(() => "");
|
|
146
|
+
throw new HalalScannerError(
|
|
147
|
+
text || `HTTP ${response.status}`,
|
|
148
|
+
response.status,
|
|
149
|
+
path
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return await response.json();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof HalalScannerError) throw error;
|
|
155
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
156
|
+
throw new NetworkError("Request timed out", path);
|
|
157
|
+
}
|
|
158
|
+
throw new NetworkError(
|
|
159
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
160
|
+
path
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
168
|
+
0 && (module.exports = {
|
|
169
|
+
HalalScanner,
|
|
170
|
+
HalalScannerError,
|
|
171
|
+
NetworkError,
|
|
172
|
+
RateLimitError
|
|
173
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var HalalScannerError = class extends Error {
|
|
3
|
+
constructor(message, statusCode, endpoint) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
this.endpoint = endpoint;
|
|
7
|
+
this.name = "HalalScannerError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var RateLimitError = class extends HalalScannerError {
|
|
11
|
+
constructor(endpoint) {
|
|
12
|
+
super("Rate limit exceeded. Please wait before making more requests.", 429, endpoint);
|
|
13
|
+
this.name = "RateLimitError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var NetworkError = class extends HalalScannerError {
|
|
17
|
+
constructor(message, endpoint) {
|
|
18
|
+
super(`Network error: ${message}`, void 0, endpoint);
|
|
19
|
+
this.name = "NetworkError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/client.ts
|
|
24
|
+
var DEFAULT_BASE_URL = "https://halallens.no/api";
|
|
25
|
+
var DEFAULT_TIMEOUT = 1e4;
|
|
26
|
+
function generateDeviceId() {
|
|
27
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
28
|
+
let id = "hs-";
|
|
29
|
+
for (let i = 0; i < 16; i++) {
|
|
30
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
31
|
+
}
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
var HalalScanner = class {
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
37
|
+
this.deviceId = options.deviceId || generateDeviceId();
|
|
38
|
+
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Search for ingredients by name.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* const results = await scanner.search('gelatin');
|
|
45
|
+
* console.log(results.items[0].halal_status); // "HARAM"
|
|
46
|
+
*/
|
|
47
|
+
async search(query, params = {}) {
|
|
48
|
+
const searchParams = new URLSearchParams({ search: query });
|
|
49
|
+
if (params.status) searchParams.set("status", params.status);
|
|
50
|
+
if (params.category) searchParams.set("category", params.category);
|
|
51
|
+
if (params.language) searchParams.set("language", params.language);
|
|
52
|
+
if (params.verifiedOnly) searchParams.set("verified_only", "true");
|
|
53
|
+
if (params.eNumber) searchParams.set("e_number", "true");
|
|
54
|
+
if (params.page) searchParams.set("page", String(params.page));
|
|
55
|
+
if (params.perPage) searchParams.set("per_page", String(params.perPage));
|
|
56
|
+
return this.request(
|
|
57
|
+
`/v1/public/ingredients?${searchParams.toString()}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get detailed information about a specific ingredient.
|
|
62
|
+
*
|
|
63
|
+
* @param lang - Language code (e.g., "en", "ar", "no")
|
|
64
|
+
* @param name - Ingredient URL slug (e.g., "gelatin", "sugar")
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const detail = await scanner.check('en', 'gelatin');
|
|
68
|
+
* console.log(detail.explanation);
|
|
69
|
+
*/
|
|
70
|
+
async check(lang, name) {
|
|
71
|
+
return this.request(
|
|
72
|
+
`/v1/public/ingredients/${encodeURIComponent(lang)}/${encodeURIComponent(name)}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Analyze a list of ingredients for halal status.
|
|
77
|
+
*
|
|
78
|
+
* @param ingredients - Array of ingredient names to check
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const result = await scanner.analyze(['gelatin', 'sugar', 'E471']);
|
|
82
|
+
* console.log(`${result.halal_count} halal, ${result.haram_count} haram`);
|
|
83
|
+
*/
|
|
84
|
+
async analyze(ingredients) {
|
|
85
|
+
return this.request("/v1/queue/ingredients/json", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({
|
|
89
|
+
ingredients: ingredients.map((name) => ({
|
|
90
|
+
name,
|
|
91
|
+
halal_status: "UNKNOWN",
|
|
92
|
+
language: "en",
|
|
93
|
+
confidence: 0
|
|
94
|
+
})),
|
|
95
|
+
device_id: this.deviceId
|
|
96
|
+
})
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
async request(path, init) {
|
|
100
|
+
const url = `${this.baseUrl}${path}`;
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(url, {
|
|
105
|
+
...init,
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
headers: {
|
|
108
|
+
Accept: "application/json",
|
|
109
|
+
...init?.headers
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
throw new RateLimitError(path);
|
|
114
|
+
}
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
const text = await response.text().catch(() => "");
|
|
117
|
+
throw new HalalScannerError(
|
|
118
|
+
text || `HTTP ${response.status}`,
|
|
119
|
+
response.status,
|
|
120
|
+
path
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return await response.json();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof HalalScannerError) throw error;
|
|
126
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
127
|
+
throw new NetworkError("Request timed out", path);
|
|
128
|
+
}
|
|
129
|
+
throw new NetworkError(
|
|
130
|
+
error instanceof Error ? error.message : "Unknown error",
|
|
131
|
+
path
|
|
132
|
+
);
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
export {
|
|
139
|
+
HalalScanner,
|
|
140
|
+
HalalScannerError,
|
|
141
|
+
NetworkError,
|
|
142
|
+
RateLimitError
|
|
143
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@halallens/halal-scanner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Free halal food scanner - check ingredients instantly. Powered by HalalLens AI.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
22
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"lint": "tsc --noEmit",
|
|
26
|
+
"prepublishOnly": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"halal",
|
|
30
|
+
"halal-scanner",
|
|
31
|
+
"food-scanner",
|
|
32
|
+
"ingredient-checker",
|
|
33
|
+
"halal-food",
|
|
34
|
+
"halal-checker",
|
|
35
|
+
"free",
|
|
36
|
+
"open-source",
|
|
37
|
+
"e-numbers",
|
|
38
|
+
"food-ingredients",
|
|
39
|
+
"halal-status",
|
|
40
|
+
"muslim",
|
|
41
|
+
"islamic",
|
|
42
|
+
"food-safety"
|
|
43
|
+
],
|
|
44
|
+
"author": "HalalLens <mac@halallens.no> (https://halallens.no)",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"homepage": "https://halallens.no",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "https://github.com/halallens-no/halal-scanner.git"
|
|
50
|
+
},
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/halallens-no/halal-scanner/issues"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"tsup": "^8.0.0",
|
|
56
|
+
"typescript": "^5.4.0",
|
|
57
|
+
"vitest": "^1.6.0"
|
|
58
|
+
}
|
|
59
|
+
}
|