channel-worker 2.5.11 → 2.5.13

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/lib/api-client.js CHANGED
@@ -68,7 +68,7 @@ class ApiClient {
68
68
  async getNextCommand(workerId) {
69
69
  // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
70
  // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw';
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,scrape_affiliate_products,ingest_shopee_product';
72
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
73
73
  }
74
74
 
@@ -84,6 +84,16 @@ class ApiClient {
84
84
  return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
85
85
  }
86
86
 
87
+ // Shopee scraper callbacks — worker ships parsed products, API upserts into
88
+ // the global AffiliateProduct kho (dedup by shop_id+item_id).
89
+ async upsertAffiliateProducts(products, meta = {}) {
90
+ return this.request('POST', '/products/worker-upsert', { products, ...meta });
91
+ }
92
+ // Single PDP ingest result (Flow 2). idea_id links it back to the fashion idea.
93
+ async ingestShopeeResult(product, meta = {}) {
94
+ return this.request('POST', '/products/worker-ingest', { product, ...meta });
95
+ }
96
+
87
97
  // Return the calling daemon's own Worker doc — primarily for reading
88
98
  // parallel_limit (the per-daemon scene-generation concurrency cap that
89
99
  // replaced the legacy global flowkit_max_concurrent setting).
@@ -104,6 +104,12 @@ class CommandPoller {
104
104
  case 'set_profile_proxy':
105
105
  await this.handleSetProfileProxy(command);
106
106
  break;
107
+ case 'scrape_affiliate_products':
108
+ await this.handleScrapeAffiliateProducts(command);
109
+ break;
110
+ case 'ingest_shopee_product':
111
+ await this.handleIngestShopeeProduct(command);
112
+ break;
107
113
  default:
108
114
  // Playwright-based pipeline: any command whose type ends in '_pw'
109
115
  // is routed to scripts/<base>.js (BrowserClaw-style automation
@@ -129,6 +135,98 @@ class CommandPoller {
129
135
  // for upload + page-management tasks). Command type 'upload_youtube_pw' maps
130
136
  // to scripts/upload_youtube.js, etc. The script handles its own profile
131
137
  // launch via NST + CDP attach; the daemon orchestrates + ships the result.
138
+ // Lazy-init the NST manager (shared with the _pw + launch handlers).
139
+ async _ensureNst(command) {
140
+ if (this.nst) return true;
141
+ try {
142
+ const apiKey = await this.api.getSetting('nst_api_key');
143
+ if (apiKey) { const NstManager = require('./nst-manager'); this.nst = new NstManager(apiKey); }
144
+ } catch {}
145
+ if (!this.nst) {
146
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'NST API key not configured' });
147
+ return false;
148
+ }
149
+ return true;
150
+ }
151
+
152
+ // Ensure a profile (by name) is running and return a puppeteer-core browser
153
+ // attached over CDP via its local debug port. Worker runs ON the same box as
154
+ // Nstbrowser, so localhost:<remoteDebuggingPort> is reachable directly.
155
+ async _connectNstProfileByName(name) {
156
+ const puppeteer = require('puppeteer-core');
157
+ const profileId = await this.nst.findProfile(name);
158
+ if (!profileId) throw new Error(`NST profile "${name}" not found`);
159
+ let running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
160
+ if (!running) {
161
+ await this.nst.launchProfile(profileId);
162
+ // brief wait for the debug port to come up
163
+ for (let i = 0; i < 10 && !running; i++) {
164
+ await new Promise(r => setTimeout(r, 1500));
165
+ running = (await this.nst.getRunningBrowsers()).find(b => b.profileId === profileId);
166
+ }
167
+ }
168
+ const port = running?.remoteDebuggingPort;
169
+ if (!port) throw new Error(`No debug port for profile "${name}" (launch failed?)`);
170
+ const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });
171
+ return { browser, disconnect: () => browser.disconnect() };
172
+ }
173
+
174
+ // Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
175
+ async handleScrapeAffiliateProducts(command) {
176
+ const payload = command.payload || {};
177
+ if (!(await this._ensureNst(command))) return;
178
+ const scraper = require('./shopee-scraper');
179
+ const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
180
+ let conn;
181
+ try {
182
+ conn = await this._connectNstProfileByName(profileName);
183
+ const products = await scraper.scrapeOffers(conn.browser, {
184
+ sort_type: payload.sort_type ?? 2, // 2 = commission desc
185
+ page_limit: payload.page_limit ?? 20,
186
+ pages: payload.pages ?? 3,
187
+ list_type: payload.list_type ?? 0,
188
+ });
189
+ const filtered = payload.category_group
190
+ ? products.filter(p => p.category_group === payload.category_group)
191
+ : products;
192
+ console.log(`[shopee] scraped ${products.length} offers (${filtered.length} after filter) from ${profileName}`);
193
+ await this.api.upsertAffiliateProducts(filtered, { user_id: command.user_id });
194
+ await this.api.updateCommand(command._id, { status: 'done', result: { count: filtered.length, total: products.length } });
195
+ } catch (err) {
196
+ console.error(`[shopee] scrape failed: ${err.message}`);
197
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
198
+ } finally {
199
+ if (conn) conn.disconnect();
200
+ }
201
+ }
202
+
203
+ // Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
204
+ async handleIngestShopeeProduct(command) {
205
+ const payload = command.payload || {};
206
+ if (!(await this._ensureNst(command))) return;
207
+ const scraper = require('./shopee-scraper');
208
+ const profileName = payload.profile_name || (await this.api.getSetting('shopee_shopping_profile').catch(() => null)) || 'Shopee1';
209
+ let ids = (payload.shop_id && payload.item_id) ? { shop_id: payload.shop_id, item_id: payload.item_id } : null;
210
+ if (!ids && payload.url) ids = scraper.parseProductUrl(payload.url);
211
+ if (!ids) {
212
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
213
+ return;
214
+ }
215
+ let conn;
216
+ try {
217
+ conn = await this._connectNstProfileByName(profileName);
218
+ const product = await scraper.ingestProduct(conn.browser, ids);
219
+ console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
220
+ await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
221
+ await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
222
+ } catch (err) {
223
+ console.error(`[shopee] ingest failed: ${err.message}`);
224
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
225
+ } finally {
226
+ if (conn) conn.disconnect();
227
+ }
228
+ }
229
+
132
230
  async handlePlaywrightCommand(command) {
133
231
  const { runPlaywrightScript } = require('./playwright-runner');
134
232
  const payload = command.payload || {};
@@ -0,0 +1,165 @@
1
+ // Shopee scraper — runs against a logged-in Nstbrowser profile via CDP.
2
+ //
3
+ // Two jobs, both proven against the live "Shopee1" profile on relabs03:
4
+ // 1. ingestProduct — open a PDP, capture the api/v4/pdp/get_pc response the
5
+ // page itself fires (signed headers we can't forge by hand) → full product.
6
+ // 2. scrapeOffers — in-page fetch of affiliate.shopee.vn offer list (only
7
+ // needs the session cookie) → products + commission %, sorted/paginated.
8
+ //
9
+ // Pattern mirrors the cookie-bridge XHS capture: don't replay the API by hand,
10
+ // let the real page sign it and grab the response at the network layer.
11
+
12
+ const IMG_BASE = 'https://down-vn.img.susercontent.com/file/';
13
+ const imgUrl = (h) => (h && !/^https?:/.test(h) ? IMG_BASE + h : h);
14
+
15
+ // Map a Shopee breadcrumb / fe_category path → our coarse bucket. Keyword match
16
+ // is intentionally loose (VN + EN) so new sub-categories still land somewhere.
17
+ function categoryGroup(text = '') {
18
+ const t = text.toLowerCase();
19
+ if (/giày|dép|sandal|boot|sneaker|shoe/.test(t)) return 'shoes';
20
+ if (/son|kem|skincare|makeup|mỹ phẩm|làm đẹp|beauty|nước hoa|dưỡng/.test(t)) return 'beauty';
21
+ if (/túi|ví|kính|mũ|nón|trang sức|phụ kiện|đồng hồ|thắt lưng|accessor/.test(t)) return 'accessory';
22
+ if (/thời trang|áo|quần|váy|đầm|đồ|set|fashion|apparel|clothing|đồ ngủ|đồ lót/.test(t)) return 'fashion';
23
+ return 'other';
24
+ }
25
+
26
+ // "13%" / "13.5%" / 0.13 → 13 (numeric percent). Tolerant of both encodings.
27
+ function pctToNumber(v) {
28
+ if (v == null) return 0;
29
+ if (typeof v === 'number') return v <= 1 ? +(v * 100).toFixed(2) : +v.toFixed(2);
30
+ const s = String(v).replace('%', '').trim();
31
+ const n = parseFloat(s);
32
+ if (!isFinite(n)) return 0;
33
+ return s.includes('.') && n < 1 ? +(n * 100).toFixed(2) : +n.toFixed(2);
34
+ }
35
+
36
+ function parsePdpItem(item) {
37
+ const cat = (item.categories || item.fe_categories || []).map((c) => c.display_name).filter(Boolean).join(' > ');
38
+ const cover = imgUrl(item.image);
39
+ const variants = (item.tier_variations || []).map((t) => ({
40
+ name: t.name,
41
+ options: t.options || [],
42
+ images: (t.images || []).map(imgUrl),
43
+ }));
44
+ // Gallery = cover + all variant images (deduped) — the keyframe source pool.
45
+ const gallery = [cover, ...variants.flatMap((v) => v.images)].filter(Boolean);
46
+ return {
47
+ shop_id: String(item.shop_id),
48
+ item_id: String(item.item_id),
49
+ name: (item.title || item.name || '').trim(),
50
+ description: (item.description || '').trim(),
51
+ brand: item.brand || '',
52
+ category: cat,
53
+ category_group: categoryGroup(cat + ' ' + (item.title || '')),
54
+ price: (item.price || 0) / 100000,
55
+ price_before_discount: (item.price_before_discount || 0) / 100000,
56
+ sold_count: item.historical_sold ?? item.sold ?? null,
57
+ rating: item.item_rating?.rating_star ?? null,
58
+ cover_image: cover,
59
+ images: [...new Set(gallery)],
60
+ variants,
61
+ attributes: (item.attributes || []).map((a) => `${a.name}: ${a.value}`).filter((s) => s !== ': '),
62
+ size_chart_image: imgUrl(item.size_chart),
63
+ };
64
+ }
65
+
66
+ // Open the PDP and resolve with the get_pc payload the page fires. We listen at
67
+ // the response layer because a hand-rolled fetch gets error 90309999 (no sig).
68
+ async function ingestProduct(browser, { shop_id, item_id, timeoutMs = 60000 } = {}) {
69
+ if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
70
+ const page = await browser.newPage();
71
+ try {
72
+ const captured = new Promise((resolve) => {
73
+ page.on('response', async (res) => {
74
+ if (!res.url().includes('/api/v4/pdp/get_pc')) return;
75
+ try { const j = await res.json(); if (j?.data?.item) resolve(j.data.item); } catch { /* keep waiting */ }
76
+ });
77
+ setTimeout(() => resolve(null), timeoutMs);
78
+ });
79
+ await page.goto(`https://shopee.vn/product/${shop_id}/${item_id}`, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
80
+ const item = await captured;
81
+ if (!item) throw new Error('PDP get_pc not captured (login expired or product gone)');
82
+ const parsed = parsePdpItem(item);
83
+ parsed.product_url = `https://shopee.vn/product/${shop_id}/${item_id}`;
84
+ return parsed;
85
+ } finally {
86
+ await page.close().catch(() => {});
87
+ }
88
+ }
89
+
90
+ // Parse one row of /api/v3/offer/product/list. Price fields come back as
91
+ // STRINGS in the *100000 scale (e.g. "10000100000" = 100,001 VND), same as PDP.
92
+ function parseOfferRow(row) {
93
+ const card = row.batch_item_for_item_card_full || row.item_card_displayed_asset || {};
94
+ const shopId = String(card.shopid || card.shop_id || '');
95
+ const itemId = String(row.item_id || card.itemid || card.item_id || '');
96
+ const rate = pctToNumber(row.default_commission_rate ?? row.max_commission_rate ?? row.commission_rate);
97
+ const sellerRate = pctToNumber(row.seller_commission_rate);
98
+ const price = (Number(card.price || card.price_min) || 0) / 100000;
99
+ const priceBefore = (Number(card.price_before_discount || card.price_min_before_discount) || 0) / 100000;
100
+ const cover = card.image ? imgUrl(card.image) : '';
101
+ const gallery = (card.images || []).map(imgUrl).filter(Boolean);
102
+ const name = (card.name || '').trim();
103
+ return {
104
+ shop_id: shopId,
105
+ item_id: itemId,
106
+ name,
107
+ category_group: categoryGroup(name),
108
+ product_url: row.product_link || (shopId && itemId ? `https://shopee.vn/product/${shopId}/${itemId}` : ''),
109
+ affiliate_link: row.long_link || row.offer_link || '',
110
+ commission_rate: rate,
111
+ commission_seller_rate: sellerRate,
112
+ commission_value: price && rate ? Math.round(price * rate / 100) : null,
113
+ price,
114
+ price_before_discount: priceBefore,
115
+ sold_count: card.historical_sold ?? card.sold ?? null,
116
+ rating: card.item_rating?.rating_star ? +card.item_rating.rating_star.toFixed(2) : null,
117
+ shop_name: card.shop_name || '',
118
+ cover_image: cover,
119
+ images: gallery.length ? [...new Set([cover, ...gallery])].filter(Boolean) : (cover ? [cover] : []),
120
+ };
121
+ }
122
+
123
+ // In-page fetch the affiliate offer list. sort_type: 1=relevance, 2=commission,
124
+ // 3=sales (Shopee's internal codes). Loops `pages` times, page_limit per page.
125
+ async function scrapeOffers(browser, { sort_type = 2, page_limit = 20, pages = 1, list_type = 0, timeoutMs = 45000 } = {}) {
126
+ const pagesOpen = await browser.pages();
127
+ let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
128
+ let opened = false;
129
+ if (!page) { page = await browser.newPage(); opened = true; }
130
+ try {
131
+ if (!page.url().includes('affiliate.shopee.vn')) {
132
+ await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle2', timeout: timeoutMs });
133
+ }
134
+ const all = [];
135
+ for (let i = 0; i < pages; i++) {
136
+ const offset = i * page_limit;
137
+ const res = await page.evaluate(async (q) => {
138
+ const url = `/api/v3/offer/product/list?list_type=${q.list_type}&sort_type=${q.sort_type}&page_offset=${q.offset}&page_limit=${q.page_limit}&client_type=1`;
139
+ const r = await fetch(url, { credentials: 'include' });
140
+ return { status: r.status, json: await r.json().catch(() => null) };
141
+ }, { list_type, sort_type, offset, page_limit });
142
+ if (res.status !== 200 || res.json?.code !== 0) {
143
+ throw new Error(`offer list failed (http ${res.status}, code ${res.json?.code}) — session may be expired`);
144
+ }
145
+ const list = res.json?.data?.list || [];
146
+ all.push(...list.map(parseOfferRow).filter((p) => p.item_id));
147
+ if (list.length < page_limit) break; // ran out
148
+ }
149
+ return all;
150
+ } finally {
151
+ if (opened) await page.close().catch(() => {});
152
+ }
153
+ }
154
+
155
+ // Parse a Shopee product URL → { shop_id, item_id }. Accepts
156
+ // /product/<shop>/<item> and i.<shop>.<item> forms.
157
+ function parseProductUrl(url = '') {
158
+ let m = url.match(/\/product\/(\d+)\/(\d+)/);
159
+ if (m) return { shop_id: m[1], item_id: m[2] };
160
+ m = url.match(/i\.(\d+)\.(\d+)/);
161
+ if (m) return { shop_id: m[1], item_id: m[2] };
162
+ return null;
163
+ }
164
+
165
+ module.exports = { ingestProduct, scrapeOffers, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.11",
3
+ "version": "2.5.13",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -187,7 +187,7 @@ async function run({ page, payload, log }) {
187
187
  } = payload || {};
188
188
  if (!video_url) throw new Error('No video_url provided');
189
189
 
190
- log('info', '[fb-pw] selectors version=2026.06.01s-pagewall-thumb-60s-retry');
190
+ log('info', '[fb-pw] selectors version=2026.06.05a-pagewall-no-double-post');
191
191
 
192
192
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
193
193
 
@@ -820,12 +820,18 @@ async function run({ page, payload, log }) {
820
820
  // attach a custom thumbnail in the Page-wall composer.
821
821
  if (!customThumbDone && thumbPath && /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url())) {
822
822
  const editBtn = await page.evaluate(() => {
823
- // Look for the thumbnail-edit button inside ANY visible composer
824
- // dialog. The overlay is a small button on the video preview with:
825
- // - aria-label containing "hình thu nhỏ" / "thumbnail"
826
- // - OR text "Chỉnh sửa" with size < ~120px wide (overlay vs full
827
- // "Chỉnh sửa ảnh bìa" page cover button which is much wider)
828
- const dlgs = document.querySelectorAll("[role='dialog']");
823
+ // Look for the thumbnail-edit button inside the REEL COMPOSER dialog
824
+ // only scanning all dialogs risked matching a button on the
825
+ // page-wall photo composer (which would post a separate "Tin dạng
826
+ // ảnh" alongside the Reel). The Reel composer dialog contains the
827
+ // header text "Tạo thước phim" / "Create reel".
828
+ const allDlgs = document.querySelectorAll("[role='dialog']");
829
+ const dlgs = [];
830
+ for (const d of allDlgs) {
831
+ const txt = (d.innerText || '').slice(0, 400);
832
+ if (/Tạo thước phim|Create reel|Create a reel/i.test(txt)) dlgs.push(d);
833
+ }
834
+ if (dlgs.length === 0) return null;
829
835
  for (const dlg of dlgs) {
830
836
  const r = dlg.getBoundingClientRect();
831
837
  if (r.width < 8 || r.height < 8) continue;
@@ -1001,9 +1007,15 @@ async function run({ page, payload, log }) {
1001
1007
  }
1002
1008
 
1003
1009
  // Thumbnail step handling (BS composer legacy).
1004
- const onThumbStep = await firstVisible(page.locator(
1010
+ // SAFETY GATE only run on business.facebook.com. On the Page-wall flow
1011
+ // (facebook.com root), the page-wide setInputFiles selectors below would
1012
+ // match the regular "Tạo bài viết" photo composer's hidden file input
1013
+ // and accidentally publish a separate "Tin dạng ảnh" post alongside the
1014
+ // Reel (observed bug: 2 posts at same minute — one image-only, one Reel).
1015
+ const isBSComposer = /^https?:\/\/business\.facebook\.com/.test(page.url());
1016
+ const onThumbStep = isBSComposer ? await firstVisible(page.locator(
1005
1017
  "[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail'], div[role='button']:has-text('Tải hình ảnh lên'), div[role='button']:has-text('Upload image')"
1006
- ), 3);
1018
+ ), 3) : null;
1007
1019
  if (onThumbStep && !customThumbDone) {
1008
1020
  if (thumbPath) {
1009
1021
  log('info', '[fb-pw] thumbnail step — uploading custom thumb…');
@@ -515,8 +515,41 @@ async function run({ page, payload, log }) {
515
515
  await jitter(page, 800);
516
516
 
517
517
  // 5) The KEY step — set the video file.
518
- log('info', `[yt-pw] setInputFiles(video)…`);
519
- await page.locator(S.fileInput).setInputFiles(videoPath);
518
+ //
519
+ // Playwright's setInputFiles() over connectOverCDP caps at 50MB. Real
520
+ // shorts are 100–300MB. Bypass via in-browser fetch: the browser pulls
521
+ // the file directly from our media server (CORS-open on redrop) and we
522
+ // assign the resulting Blob to the input through DataTransfer. The CDP
523
+ // channel never carries the bytes. Falls back to Playwright's native
524
+ // setInputFiles if the browser fetch fails (small files, missing URL).
525
+ log('info', `[yt-pw] setInputFiles(video) via in-browser fetch (bypass CDP 50MB cap)…`);
526
+ let injected = false;
527
+ if (video_url) {
528
+ try {
529
+ injected = await page.evaluate(async ({ url, selector }) => {
530
+ const r = await fetch(url, { credentials: 'omit' });
531
+ if (!r.ok) throw new Error(`fetch ${r.status}`);
532
+ const blob = await r.blob();
533
+ if (!blob || !blob.size) throw new Error('empty blob');
534
+ const fname = (url.split('?')[0].split('/').pop()) || 'video.mp4';
535
+ const file = new File([blob], fname, { type: blob.type || 'video/mp4' });
536
+ const inputs = Array.from(document.querySelectorAll(selector));
537
+ const input = inputs.find((el) => /video/i.test(el.accept || '')) || inputs[0];
538
+ if (!input) throw new Error('input not found');
539
+ const dt = new DataTransfer();
540
+ dt.items.add(file);
541
+ input.files = dt.files;
542
+ input.dispatchEvent(new Event('change', { bubbles: true }));
543
+ return true;
544
+ }, { url: video_url, selector: S.fileInput });
545
+ log('info', `[yt-pw] in-browser fetch upload OK`);
546
+ } catch (e) {
547
+ log('warn', `[yt-pw] in-browser fetch upload failed: ${String(e.message || e).slice(0, 160)} — falling back to Playwright setInputFiles`);
548
+ }
549
+ }
550
+ if (!injected) {
551
+ await page.locator(S.fileInput).setInputFiles(videoPath);
552
+ }
520
553
  log('info', `[yt-pw] file set; waiting for the metadata form to appear…`);
521
554
 
522
555
  // 6) Wait for the Title field to be ready (Studio renders it as the file