webcrawlerapi-js 1.0.10 → 1.1.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/README.md CHANGED
@@ -1,64 +1,190 @@
1
1
  # JS client for WebcrawlerAPI
2
2
 
3
- Official client for [WebcrawlerAPI](https://webcrawlerapi.com/).
3
+ Official client for [WebcrawlerAPI](https://webcrawlerapi.com/). This SDK provides a robust and type-safe way to interact with WebcrawlerAPI's powerful web scraping capabilities, making it easy to extract structured data from any website.
4
4
 
5
- WebcrawlerAPI allows you to extract data from any website with just a simple API call.
5
+ WebcrawlerAPI allows you to extract data from any website with just a simple API call. The service handles all the complexities of web scraping, including JavaScript rendering, proxy rotation, and rate limiting, so you can focus on using the data.
6
6
 
7
7
  ## Preparation
8
8
  1. Register to [dashboard|https://dash.webcrawlerapi.com/].
9
9
  2. Get an [Access Key](https://dash.webcrawlerapi.com/access).
10
10
 
11
+ Our dashboard provides comprehensive analytics and monitoring tools to help you track your API usage and optimize your scraping operations.
12
+
11
13
  ## Installation
12
14
  Install WebcrawlerAPI js package:
13
15
 
14
16
  `npm i webcrawlerapi-js`
15
17
 
16
- ## Request example
18
+ The package is regularly updated with new features and improvements to ensure the best possible scraping experience.
19
+
20
+ ## JavaScript Examples
21
+
22
+ ### Synchronous Job
23
+ In this mode, the promise will be resolved when all data is ready. Synchronous jobs are perfect for quick scraping tasks where you need immediate results and don't want to handle polling logic.
17
24
 
18
25
  ```javascript
19
- import { WebcrawlerClient } from "webcrawlerapi-js"
26
+ import webcrawlerapi from "webcrawlerapi-js"
20
27
 
21
28
  async function main() {
22
29
  const client = new WebcrawlerClient(
23
30
  "YOUR API ACCESS KEY HERE",
24
31
  )
25
- // sync way - promise will be resolved with the all the data
26
- const syncJob = await client.crawl({
32
+
33
+ try {
34
+ const syncJob = await client.crawl({
27
35
  "items_limit": 10,
28
36
  "url": "https://stripe.com/",
29
37
  "scrape_type": "markdown"
30
- }
31
- )
32
- for (const item of syncJob.job_items) {
33
- item.getContent().then((content) => {
34
- console.log(content.slice(0, 100));
35
38
  })
39
+
40
+ for (const item of syncJob.job_items) {
41
+ const content = await item.getContent()
42
+ console.log(content.slice(0, 100))
43
+ }
44
+ console.log(syncJob)
45
+
46
+ } catch (error) {
47
+ if (error instanceof webcrawlerapi.WebcrawlerApiError) {
48
+ console.error("Webcrawler error:", error.message)
49
+ }
50
+ return
36
51
  }
37
- console.log(syncJob);
52
+ }
53
+
54
+ main().catch(console.error)
55
+ ```
56
+
57
+ ### Asynchronous Job
58
+ In this mode, you get a job ID immediately and can poll for results. Asynchronous jobs are ideal for large-scale scraping operations where you want to process data in batches or handle long-running tasks efficiently.
38
59
 
39
- // or async - get the job id and then poll the job status and get the data
60
+ ```javascript
61
+ import webcrawlerapi from "webcrawlerapi-js"
40
62
 
41
- const jobWithId = await client.crawlAsync({
63
+ async function main() {
64
+ const client = new WebcrawlerClient(
65
+ "YOUR API ACCESS KEY HERE",
66
+ )
67
+
68
+ try {
69
+ // Get job ID immediately
70
+ const jobWithId = await client.crawlAsync({
42
71
  "items_limit": 10,
43
72
  "url": "https://stripe.com/",
44
73
  "scrape_type": "markdown"
74
+ })
75
+
76
+ // Poll for job completion
77
+ const jobId = jobWithId.id
78
+ let asyncJob = await client.getJob(jobId)
79
+
80
+ // Check job status
81
+ console.log("Job status:", asyncJob.status)
82
+
83
+ // Once complete, access results
84
+ if (asyncJob.status === "completed") {
85
+ for (const item of asyncJob.job_items) {
86
+ const content = await item.getContent()
87
+ console.log(content.slice(0, 100))
88
+ }
45
89
  }
90
+ } catch (error) {
91
+ if (error instanceof webcrawlerapi.WebcrawlerApiError) {
92
+ console.error("Webcrawler error:", error.message)
93
+ }
94
+ }
95
+ }
96
+
97
+ main().catch(console.error)
98
+ ```
99
+
100
+ ## TypeScript Usage
101
+
102
+ The SDK provides full TypeScript support with type safety and error handling. Our TypeScript integration ensures you catch potential issues at compile-time and provides excellent IDE support with autocompletion and inline documentation.
103
+
104
+ ```typescript
105
+ import { WebcrawlerClient, WebcrawlerApiError } from "webcrawlerapi-js"
106
+
107
+ async function main() {
108
+ const client = new WebcrawlerClient(
109
+ "YOUR_API_KEY",
110
+ // Optional base URL, defaults to production API
111
+ "http://localhost:8080"
46
112
  )
47
- // wait for job to complete
48
- const jobId = jobWithId.id;
49
- let asyncJob = await client.getJob(jobId);
50
113
 
51
- console.log(asyncJob);
114
+ try {
115
+ // Basic crawl with markdown
116
+ const syncJob = await client.crawl({
117
+ "items_limit": 2,
118
+ "url": "https://books.toscrape.com/",
119
+ "scrape_type": "markdown"
120
+ })
121
+
122
+ // Type-safe status check
123
+ if (syncJob.status === "done") {
124
+ for (const item of syncJob.job_items) {
125
+ if (item.markdown_content_url) {
126
+ // Access typed properties
127
+ console.log("Status code:", item.page_status_code)
128
+ console.log("Content URL:", item.markdown_content_url)
129
+ }
130
+ }
131
+ }
132
+
133
+ // Using specialized scraper
134
+ const scrapeJob = await client.scrape({
135
+ "crawler_id": "webcrawler/url-to-md",
136
+ "input": {
137
+ "url": "https://books.toscrape.com/"
138
+ }
139
+ })
140
+
141
+ if (scrapeJob.status === "done") {
142
+ const content = scrapeJob.getContent()
143
+ console.log("Scraped content:", content)
144
+ }
145
+
146
+ } catch (error) {
147
+ // Type-safe error handling
148
+ if (error instanceof WebcrawlerApiError) {
149
+ console.error("API Error:", error.errorCode, error.message)
150
+ // errorCode is typed and can be used for specific error handling
151
+ if (error.errorCode === "invalid_request") {
152
+ // Handle invalid request
153
+ }
154
+ }
155
+ }
52
156
  }
53
157
 
54
- main().catch(console.error);
158
+ main().catch(console.error)
55
159
  ```
56
160
 
57
- ## Response example
58
- ```javascript
161
+ ### Error Handling
162
+
163
+ The SDK provides typed error handling through the `WebcrawlerApiError` class. Our error handling system is designed to provide detailed, actionable feedback that helps you quickly identify and resolve issues in your scraping pipeline.
164
+
165
+ ```typescript
166
+ try {
167
+ const syncJob = await client.crawl({
168
+ "items_limit": 2,
169
+ "url": "https://books.toscrape.com/",
170
+ // This will cause an error due to missing scrape_type
171
+ })
172
+ } catch (error) {
173
+ if (error instanceof WebcrawlerApiError) {
174
+ // Typed error properties
175
+ console.error("Error code:", error.errorCode)
176
+ console.error("Error message:", error.message)
177
+ }
178
+ }
179
+ ```
180
+
181
+ ## Response Structure
182
+
183
+ Example of a job response. The response structure is carefully designed to provide comprehensive information about your scraping job, including detailed status updates, timing information, and resource usage metrics.
184
+
185
+ ```json
59
186
  {
60
187
  id: '49c4942b-b7d9-4d62-94b5-b54a3016ac51',
61
- org_id: 'clxsnorta00075wuuqxgzzvxm',
62
188
  url: 'https://stripe.com/',
63
189
  scrape_type: 'markdown',
64
190
  whitelist_regexp: '',
@@ -0,0 +1,15 @@
1
+ import { CrawlRequest, Job, JobId, ScrapeRequest, ScrapeResponse } from "./model";
2
+ export declare class WebcrawlerClient {
3
+ protected apiVersion: string;
4
+ protected apiKey: string;
5
+ protected basePath: string;
6
+ constructor(apiKey: string, basePath?: string, apiVersion?: string);
7
+ scrapeAsync(scrapeRequest: ScrapeRequest): Promise<JobId>;
8
+ scrapeWithMeta(scrapeRequest: ScrapeRequest, maxPollingRetries?: number): Promise<ScrapeResponse>;
9
+ scrape(scrapeRequest: ScrapeRequest, maxPollingRetries?: number): Promise<any>;
10
+ getScrapeResult(scrapeID: string): Promise<ScrapeResponse>;
11
+ crawl(crawlRequest: CrawlRequest): Promise<Job>;
12
+ crawlAsync(crawlRequest: CrawlRequest): Promise<JobId>;
13
+ getJob(jobID: string): Promise<Job>;
14
+ private sendRequest;
15
+ }
@@ -0,0 +1,13 @@
1
+ export declare const JobStatus: {
2
+ readonly IN_PROGRESS: "in_progress";
3
+ readonly NEW: "new";
4
+ readonly DONE: "done";
5
+ };
6
+ export declare const HttpStatus: {
7
+ readonly OK: 200;
8
+ readonly BAD_REQUEST: 400;
9
+ readonly UNAUTHORIZED: 401;
10
+ readonly FORBIDDEN: 403;
11
+ readonly NOT_FOUND: 404;
12
+ readonly INTERNAL_SERVER_ERROR: 500;
13
+ };
@@ -0,0 +1,12 @@
1
+ export declare class WebcrawlerApiError extends Error {
2
+ errorCode: string;
3
+ errorMessage: string;
4
+ statusCode?: number;
5
+ constructor(errorCode: string, errorMessage: string, statusCode?: number);
6
+ }
7
+ export interface ErrorResponse {
8
+ error_code?: string;
9
+ error_message?: string;
10
+ error?: string;
11
+ }
12
+ export declare function createErrorFromResponse(response: Response, data: ErrorResponse): WebcrawlerApiError;
@@ -0,0 +1,4 @@
1
+ export * from "./api";
2
+ export * from "./model";
3
+ export * from "./constants";
4
+ export * from "./errors";
@@ -0,0 +1,62 @@
1
+ export interface ScrapeRequest {
2
+ input: any;
3
+ crawler_id: string;
4
+ max_retries?: number;
5
+ debug?: boolean;
6
+ omit_cache?: boolean;
7
+ }
8
+ export interface JobId {
9
+ id: string;
10
+ }
11
+ export interface ScrapeResponse {
12
+ id: string;
13
+ url: string;
14
+ status: any;
15
+ page_status_code: number;
16
+ created_at: string;
17
+ structured_data: any;
18
+ recommended_pull_delay_ms: number;
19
+ }
20
+ export interface CrawlRequest {
21
+ url: string;
22
+ items_limit?: number;
23
+ scrape_type?: string;
24
+ whitelist_regexp?: string;
25
+ blacklist_regexp?: string;
26
+ allow_subdomains?: boolean;
27
+ webhook_url?: string;
28
+ }
29
+ export interface Job {
30
+ id: string;
31
+ org_id: string;
32
+ url: string;
33
+ status: string;
34
+ scrape_type: string;
35
+ whitelist_regexp: string;
36
+ blacklist_regexp: string;
37
+ allow_subdomains: boolean;
38
+ items_limit: number;
39
+ created_at: string;
40
+ finished_at: string;
41
+ updated_at: string;
42
+ webhook_url: string;
43
+ recommended_pull_delay_ms: number;
44
+ job_items: JobItem[];
45
+ }
46
+ export interface JobItem {
47
+ id: string;
48
+ job_id: string;
49
+ original_url: string;
50
+ page_status_code: number;
51
+ markdown_content_url: string;
52
+ status: string;
53
+ title: string;
54
+ last_error: string;
55
+ created_at: string;
56
+ updated_at: string;
57
+ cost: number;
58
+ referred_url: string;
59
+ raw_content_url?: string;
60
+ cleaned_content_url?: string;
61
+ getContent(): Promise<string | null>;
62
+ }
package/dist/api.js ADDED
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebcrawlerClient = void 0;
4
+ const constants_1 = require("./constants");
5
+ const errors_1 = require("./errors");
6
+ const BASE_PATH = "https://api.webcrawlerapi.com";
7
+ const initialPullDelayMs = 2000;
8
+ const MaxPullRetries = 100;
9
+ class WebcrawlerClient {
10
+ constructor(apiKey, basePath = BASE_PATH, apiVersion = "v1") {
11
+ this.apiVersion = "v1";
12
+ this.apiKey = apiKey;
13
+ this.basePath = basePath;
14
+ this.apiVersion = apiVersion;
15
+ }
16
+ async scrapeAsync(scrapeRequest) {
17
+ const url = `${this.basePath}/${this.apiVersion}/scrape`;
18
+ const requestOptions = {
19
+ 'method': 'POST',
20
+ 'headers': {
21
+ 'Content-Type': 'application/json',
22
+ 'Authorization': `Bearer ${this.apiKey}`,
23
+ "User-Agent": "WebcrawlerAPI-NodeJS-Client"
24
+ },
25
+ 'body': JSON.stringify(scrapeRequest),
26
+ };
27
+ return await this.sendRequest(url, requestOptions);
28
+ }
29
+ async scrapeWithMeta(scrapeRequest, maxPollingRetries = MaxPullRetries) {
30
+ const url = `${this.basePath}/${this.apiVersion}/scrape`;
31
+ const requestOptions = {
32
+ 'method': 'POST',
33
+ 'headers': {
34
+ 'Content-Type': 'application/json',
35
+ 'Authorization': `Bearer ${this.apiKey}`
36
+ },
37
+ 'body': JSON.stringify(scrapeRequest),
38
+ };
39
+ const jobIdResponse = await this.sendRequest(url, requestOptions);
40
+ if (jobIdResponse.id === '') {
41
+ throw new errors_1.WebcrawlerApiError('invalid_response', 'Failed to fetch job status', 0);
42
+ }
43
+ let delayIntervalMs = initialPullDelayMs;
44
+ for (let i = 0; i < maxPollingRetries; i++) {
45
+ await new Promise(resolve => setTimeout(resolve, delayIntervalMs));
46
+ const scrapeResult = await this.getScrapeResult(jobIdResponse.id);
47
+ if (scrapeRequest.debug) {
48
+ console.log(`Scrape result: ${JSON.stringify(scrapeResult)}`);
49
+ }
50
+ if (scrapeResult.status !== constants_1.JobStatus.IN_PROGRESS && scrapeResult.status !== constants_1.JobStatus.NEW) {
51
+ return scrapeResult;
52
+ }
53
+ if (scrapeResult.recommended_pull_delay_ms > 0) {
54
+ delayIntervalMs = scrapeResult.recommended_pull_delay_ms;
55
+ }
56
+ }
57
+ throw new errors_1.WebcrawlerApiError('timeout', 'Scraping took too long, please retry or increase the number of polling retries', 0);
58
+ }
59
+ async scrape(scrapeRequest, maxPollingRetries = MaxPullRetries) {
60
+ const scrapeResult = await this.scrapeWithMeta(scrapeRequest, maxPollingRetries);
61
+ return scrapeResult.structured_data;
62
+ }
63
+ async getScrapeResult(scrapeID) {
64
+ const url = `${this.basePath}/${this.apiVersion}/scrape/${scrapeID}`;
65
+ const requestOptions = {
66
+ 'method': 'GET',
67
+ 'headers': {
68
+ 'Content-Type': 'application/json',
69
+ 'Authorization': `Bearer ${this.apiKey}`,
70
+ "User-Agent": "WebcrawlerAPI-NodeJS-Client",
71
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
72
+ 'Pragma': 'no-cache',
73
+ 'Expires': '0'
74
+ },
75
+ };
76
+ return await this.sendRequest(url, requestOptions);
77
+ }
78
+ async crawl(crawlRequest) {
79
+ const url = `${this.basePath}/${this.apiVersion}/crawl`;
80
+ const requestOptions = {
81
+ 'method': 'POST',
82
+ 'headers': {
83
+ 'Content-Type': 'application/json',
84
+ 'Authorization': `Bearer ${this.apiKey}`,
85
+ "User-Agent": "WebcrawlerAPI-NodeJS-Client",
86
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
87
+ 'Pragma': 'no-cache',
88
+ 'Expires': '0'
89
+ },
90
+ 'body': JSON.stringify(crawlRequest),
91
+ };
92
+ const jobIdResponse = await this.sendRequest(url, requestOptions);
93
+ if (jobIdResponse.id === '') {
94
+ throw new errors_1.WebcrawlerApiError('invalid_response', 'Failed to fetch job status', 0);
95
+ }
96
+ let delayIntervalMs = initialPullDelayMs;
97
+ for (let i = 0; i < MaxPullRetries; i++) {
98
+ await new Promise(resolve => setTimeout(resolve, delayIntervalMs));
99
+ const timestamp = new Date().getTime();
100
+ const job = await this.getJob(`${jobIdResponse.id}?t=${timestamp}`);
101
+ if (job.status !== constants_1.JobStatus.IN_PROGRESS && job.status !== constants_1.JobStatus.NEW) {
102
+ // Transform each job item to include getContent method
103
+ job.job_items = job.job_items.map(item => ({
104
+ ...item,
105
+ getContent: async function () {
106
+ if (job.status !== constants_1.JobStatus.DONE || this.status !== constants_1.JobStatus.DONE) {
107
+ return null;
108
+ }
109
+ let contentUrl;
110
+ switch (job.scrape_type) {
111
+ case 'html':
112
+ contentUrl = this.raw_content_url;
113
+ break;
114
+ case 'cleaned':
115
+ contentUrl = this.cleaned_content_url;
116
+ break;
117
+ case 'markdown':
118
+ contentUrl = this.markdown_content_url;
119
+ break;
120
+ }
121
+ if (!contentUrl) {
122
+ return null;
123
+ }
124
+ const response = await fetch(contentUrl, {
125
+ headers: {
126
+ 'Accept-Encoding': 'gzip, deflate, br',
127
+ 'Accept': '*/*'
128
+ }
129
+ });
130
+ if (!response.ok) {
131
+ throw new Error(`Failed to fetch content: ${response.statusText}`);
132
+ }
133
+ return await response.text();
134
+ }
135
+ }));
136
+ return job;
137
+ }
138
+ if (job.recommended_pull_delay_ms > 0) {
139
+ delayIntervalMs = job.recommended_pull_delay_ms;
140
+ }
141
+ }
142
+ throw new errors_1.WebcrawlerApiError('timeout', 'Crawling took too long, please retry or increase the number of polling retries', 0);
143
+ }
144
+ async crawlAsync(crawlRequest) {
145
+ const url = `${this.basePath}/${this.apiVersion}/crawl`;
146
+ const requestOptions = {
147
+ 'method': 'POST',
148
+ 'headers': {
149
+ 'Content-Type': 'application/json',
150
+ 'Authorization': `Bearer ${this.apiKey}`
151
+ },
152
+ 'body': JSON.stringify(crawlRequest),
153
+ };
154
+ return await this.sendRequest(url, requestOptions);
155
+ }
156
+ async getJob(jobID) {
157
+ const url = `${this.basePath}/${this.apiVersion}/job/${jobID}`;
158
+ const requestOptions = {
159
+ 'method': 'GET',
160
+ 'headers': {
161
+ 'Content-Type': 'application/json',
162
+ 'Authorization': `Bearer ${this.apiKey}`,
163
+ "User-Agent": "WebcrawlerAPI-NodeJS-Client",
164
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
165
+ 'Pragma': 'no-cache',
166
+ 'Expires': '0'
167
+ }
168
+ };
169
+ return await this.sendRequest(url, requestOptions);
170
+ }
171
+ async sendRequest(url, requestOptions) {
172
+ let response;
173
+ try {
174
+ response = await fetch(url, requestOptions);
175
+ }
176
+ catch (e) {
177
+ throw new errors_1.WebcrawlerApiError('network_error', `Failed to send request: ${e}`, 0);
178
+ }
179
+ if (!response.ok) {
180
+ try {
181
+ const errorData = await response.json();
182
+ throw (0, errors_1.createErrorFromResponse)(response, errorData);
183
+ }
184
+ catch (e) {
185
+ if (e instanceof errors_1.WebcrawlerApiError) {
186
+ throw e;
187
+ }
188
+ // If we can't parse the error response, create a generic error
189
+ throw new errors_1.WebcrawlerApiError('unknown_error', `Request failed with status ${response.status} ${response.statusText}`, response.status);
190
+ }
191
+ }
192
+ return response.json();
193
+ }
194
+ }
195
+ exports.WebcrawlerClient = WebcrawlerClient;
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./api"), exports);
18
+ __exportStar(require("./model"), exports);
19
+ __exportStar(require("./constants"), exports);
20
+ __exportStar(require("./errors"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcrawlerapi-js",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
4
4
 
5
5
  "description": "JS client for WebcrawlerAPI",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  import {CrawlRequest, Job, JobId, ScrapeRequest, ScrapeResponse} from "./model";
2
2
  import { JobStatus } from "./constants";
3
- import { WebcrawlerApiError, createErrorFromResponse } from "./errors";
3
+ import { WebcrawlerApiError, createErrorFromResponse, ErrorResponse } from "./errors";
4
4
 
5
5
  const BASE_PATH = "https://api.webcrawlerapi.com"
6
6
  const initialPullDelayMs = 2000
@@ -206,7 +206,7 @@ export class WebcrawlerClient {
206
206
 
207
207
  if (!response.ok) {
208
208
  try {
209
- const errorData = await response.json();
209
+ const errorData = await response.json() as ErrorResponse;
210
210
  throw createErrorFromResponse(response, errorData);
211
211
  } catch (e) {
212
212
  if (e instanceof WebcrawlerApiError) {
package/tsconfig.json CHANGED
@@ -1,15 +1,21 @@
1
1
  {
2
2
  "compilerOptions": {
3
- "declaration": true,
4
- "target": "es6",
3
+ "lib": [
4
+ "es2021"
5
+ ],
5
6
  "module": "commonjs",
6
- "noImplicitAny": true,
7
+ "target": "es2021",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
7
12
  "outDir": "dist",
8
- "rootDir": ".",
9
- "typeRoots": [
10
- "node_modules/@types"
11
- ]
13
+ "declarationDir": "dist/@types",
14
+ "declaration": true
12
15
  },
16
+ "include": [
17
+ "src/**/*"
18
+ ],
13
19
  "exclude": [
14
20
  "dist",
15
21
  "node_modules"
File without changes
File without changes
File without changes
File without changes