@postfetch/core 0.2.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/src/tiktok.ts ADDED
@@ -0,0 +1,187 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ object,
5
+ string,
6
+ type ResolveContext,
7
+ type Json,
8
+ type Net,
9
+ type PostfetchResult,
10
+ type MediaItem,
11
+ } from "./internal";
12
+ import { browserUserAgent, firefoxUserAgent } from "./fingerprint";
13
+
14
+ const marker = '<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">';
15
+
16
+ export async function resolveTiktok(input: ResolveContext): Promise<PostfetchResult> {
17
+ const userAgent = browserUserAgent();
18
+ const pageUrl = await followShortlink(input.net, userAgent, input.url);
19
+ const id = videoId(pageUrl);
20
+ if (!id) {
21
+ throw new Error("TikTok video id not found");
22
+ }
23
+ const page = await fetchVideoPage(input.net, id, userAgent).catch((error: unknown) => {
24
+ if (!recoverablePageError(error)) {
25
+ throw error;
26
+ }
27
+ return fetchVideoPage(input.net, id, firefoxUserAgent());
28
+ });
29
+ const user = author(page.item) ?? username(pageUrl) ?? "i";
30
+ const headers: Record<string, string> = {
31
+ referer: `https://www.tiktok.com/@${encodeURIComponent(user)}/video/${encodeURIComponent(id)}`,
32
+ "user-agent": userAgent,
33
+ };
34
+ if (page.cookie) {
35
+ headers.cookie = page.cookie;
36
+ }
37
+ const items = mediaItems(page.item, user, id, headers);
38
+ if (items.length === 0) {
39
+ throw new Error("TikTok media not found");
40
+ }
41
+ return { archiveFilename: filename(`tiktok_${user}_${id}.zip`), id, items, platform: "tiktok" };
42
+ }
43
+
44
+ async function fetchVideoPage(
45
+ net: Net,
46
+ id: string,
47
+ userAgent: string,
48
+ ): Promise<{ item: Json; cookie: string | null }> {
49
+ const page = await net(`https://www.tiktok.com/@i/video/${id}`, {
50
+ headers: {
51
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
52
+ referer: "https://www.tiktok.com/",
53
+ "user-agent": userAgent,
54
+ },
55
+ });
56
+ return { cookie: cookieHeader(page.headers), item: itemStruct(await page.text()) };
57
+ }
58
+
59
+ async function followShortlink(net: Net, userAgent: string, input: string): Promise<string> {
60
+ const url = asUrl(input);
61
+ if (!url.hostname.includes("vt.tiktok.com")) {
62
+ return input;
63
+ }
64
+ const response = await net(input, { headers: { "user-agent": userAgent }, redirect: "manual" }, 1);
65
+ const location = response.headers.get("location");
66
+ if (location) {
67
+ return new URL(location, input).href;
68
+ }
69
+ const html = await response.text();
70
+ const href = html.match(/<a href="(https:\/\/[^"]+)"/)?.[1];
71
+ return href ? new URL(href.split("?")[0]).href : input;
72
+ }
73
+
74
+ function videoId(input: string): string | null {
75
+ return asUrl(input).pathname.match(/video\/(\d+)/)?.[1] ?? null;
76
+ }
77
+
78
+ function itemStruct(html: string): Json {
79
+ if (html.includes("SlardarWAF") || html.includes("_wafchallengeid")) {
80
+ throw new Error("TikTok WAF challenge");
81
+ }
82
+ const start = html.indexOf(marker);
83
+ const end = start === -1 ? -1 : html.indexOf("</script>", start + marker.length);
84
+ if (start === -1 || end === -1) {
85
+ throw new Error("TikTok hydration not found");
86
+ }
87
+ const parsed: unknown = JSON.parse(html.slice(start + marker.length, end));
88
+ const scope = object(parsed) && object(parsed.__DEFAULT_SCOPE__) ? parsed.__DEFAULT_SCOPE__ : null;
89
+ const detail = scope && object(scope["webapp.video-detail"]) ? scope["webapp.video-detail"] : null;
90
+ const info = detail && object(detail.itemInfo) ? detail.itemInfo : null;
91
+ const item = info && object(info.itemStruct) ? info.itemStruct : null;
92
+ if (!item) {
93
+ throw new Error("TikTok itemStruct not found");
94
+ }
95
+ return item;
96
+ }
97
+
98
+ function recoverablePageError(error: unknown): boolean {
99
+ const message = error instanceof Error ? error.message : "";
100
+ return message === "TikTok WAF challenge" || message === "TikTok hydration not found";
101
+ }
102
+
103
+ function downloadUrl(item: Json): string | null {
104
+ const video = object(item.video) ? item.video : null;
105
+ return video ? string(video.playAddr) ?? string(video.downloadAddr) : null;
106
+ }
107
+
108
+ function mediaItems(item: Json, user: string, id: string, headers: HeadersInit): MediaItem[] {
109
+ const images = imageItems(item, user, id, headers);
110
+ if (images.length > 0) {
111
+ const audio = audioItem(item, user, id, headers);
112
+ return audio ? [...images, audio] : images;
113
+ }
114
+ const url = downloadUrl(item);
115
+ return url
116
+ ? [{
117
+ filename: filename(`tiktok_${user}_${id}.mp4`),
118
+ headers,
119
+ id,
120
+ kind: "video",
121
+ mime: "video/mp4",
122
+ platform: "tiktok",
123
+ url,
124
+ }]
125
+ : [];
126
+ }
127
+
128
+ function imageItems(item: Json, user: string, id: string, headers: HeadersInit): MediaItem[] {
129
+ const imagePost = object(item.imagePost) ? item.imagePost : null;
130
+ const images = imagePost && Array.isArray(imagePost.images) ? imagePost.images.filter(object) : [];
131
+ return images.flatMap((image, index) => {
132
+ const imageUrl = object(image.imageURL) ? image.imageURL : null;
133
+ const list = imageUrl && Array.isArray(imageUrl.urlList) ? imageUrl.urlList : [];
134
+ const url = list.map(string).find((candidate): candidate is string => Boolean(candidate)) ?? null;
135
+ return url
136
+ ? [{
137
+ filename: filename(`tiktok_${user}_${id}_${index + 1}.jpg`),
138
+ headers,
139
+ id,
140
+ kind: "image" as const,
141
+ mime: "image/jpeg",
142
+ platform: "tiktok" as const,
143
+ url,
144
+ }]
145
+ : [];
146
+ });
147
+ }
148
+
149
+ function audioItem(item: Json, user: string, id: string, headers: HeadersInit): MediaItem | null {
150
+ const video = object(item.video) ? item.video : null;
151
+ const music = object(item.music) ? item.music : null;
152
+ const url = video ? string(video.playAddr) : null;
153
+ const fallback = music ? string(music.playUrl) : null;
154
+ const selected = url ?? fallback;
155
+ if (!selected) {
156
+ return null;
157
+ }
158
+ const extension = selected.includes("mime_type=audio_mpeg") ? "mp3" : "m4a";
159
+ return {
160
+ filename: filename(`tiktok_${user}_${id}_audio.${extension}`),
161
+ headers,
162
+ id,
163
+ kind: "audio",
164
+ mime: extension === "mp3" ? "audio/mpeg" : "audio/mp4",
165
+ platform: "tiktok",
166
+ url: selected,
167
+ };
168
+ }
169
+
170
+ function author(item: Json): string | null {
171
+ const user = object(item.author) ? item.author : null;
172
+ return user ? string(user.uniqueId) : null;
173
+ }
174
+
175
+ function username(input: string): string | null {
176
+ return asUrl(input).pathname.match(/@([^/]+)/)?.[1] ?? null;
177
+ }
178
+
179
+ function cookieHeader(headers: Headers): string | null {
180
+ const getter = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie;
181
+ const setCookie = typeof getter === "function" ? getter.call(headers).join(",") : headers.get("set-cookie");
182
+ const cookies = setCookie
183
+ ?.split(/,(?=[^;]+?=)/)
184
+ .map((part) => part.split(";")[0]?.trim())
185
+ .filter((part): part is string => Boolean(part));
186
+ return cookies && cookies.length > 0 ? cookies.join("; ") : null;
187
+ }
package/src/twitter.ts ADDED
@@ -0,0 +1,81 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ number,
5
+ object,
6
+ string,
7
+ type ResolveContext,
8
+ type Json,
9
+ type Net,
10
+ type PostfetchResult,
11
+ type MediaItem,
12
+ } from "./internal";
13
+ import { browserUserAgent } from "./fingerprint";
14
+
15
+ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchResult> {
16
+ const id = tweetId(input.url);
17
+ if (!id) {
18
+ throw new Error("Tweet id not found");
19
+ }
20
+ const tweet = await syndication(input.net, id);
21
+ if (string(tweet.__typename) === "TweetTombstone") {
22
+ throw new Error("Tweet is unavailable or age-restricted");
23
+ }
24
+ const media = Array.isArray(tweet.mediaDetails) ? tweet.mediaDetails.filter(object) : [];
25
+ const items = media.flatMap((entry, index) => twitterItem(entry, id, index + 1));
26
+ if (items.length === 0) {
27
+ throw new Error("Twitter media not found");
28
+ }
29
+ return { archiveFilename: filename(`twitter_${id}.zip`), id, items, platform: "twitter" };
30
+ }
31
+
32
+ // The public syndication endpoint returns tweet media (video variants + photos)
33
+ // without a guest token or bearer; the token is a non-validated cache key.
34
+ async function syndication(net: Net, id: string): Promise<Json> {
35
+ const url = `https://cdn.syndication.twimg.com/tweet-result?id=${id}&lang=en&token=${syndicationToken(id)}`;
36
+ const response = await net(url, { headers: { accept: "application/json", "user-agent": browserUserAgent() } });
37
+ if (!response.ok) {
38
+ throw new Error(`Twitter syndication failed: ${response.status}`);
39
+ }
40
+ const payload: unknown = await response.json();
41
+ if (!object(payload)) {
42
+ throw new Error("Twitter response invalid");
43
+ }
44
+ return payload;
45
+ }
46
+
47
+ function syndicationToken(id: string): string {
48
+ return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
49
+ }
50
+
51
+ function twitterItem(entry: Json, id: string, index: number): MediaItem[] {
52
+ const headers = { "user-agent": browserUserAgent() };
53
+ const type = string(entry.type);
54
+ if (type === "video" || type === "animated_gif") {
55
+ const url = bestVariant(entry);
56
+ return url
57
+ ? [{ filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }]
58
+ : [];
59
+ }
60
+ const photo = string(entry.media_url_https);
61
+ return photo
62
+ ? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }]
63
+ : [];
64
+ }
65
+
66
+ function bestVariant(entry: Json): string | null {
67
+ const info = object(entry.video_info) ? entry.video_info : null;
68
+ const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
69
+ const best = variants
70
+ .filter((variant) => string(variant.content_type) === "video/mp4")
71
+ .reduce<Json | null>((current, variant) => {
72
+ const bitrate = number(variant.bitrate) ?? 0;
73
+ const currentBitrate = current ? number(current.bitrate) ?? 0 : -1;
74
+ return bitrate > currentBitrate ? variant : current;
75
+ }, null);
76
+ return best ? string(best.url) : null;
77
+ }
78
+
79
+ function tweetId(input: string): string | null {
80
+ return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
81
+ }
package/src/youtube.ts ADDED
@@ -0,0 +1,194 @@
1
+ import {
2
+ asUrl,
3
+ filename,
4
+ number,
5
+ object,
6
+ string,
7
+ type ResolveContext,
8
+ type Json,
9
+ type Net,
10
+ type PostfetchResult,
11
+ type MediaItem,
12
+ } from "./internal";
13
+ import { browserUserAgent } from "./fingerprint";
14
+
15
+ type YoutubeSession = {
16
+ cookie: string;
17
+ signatureTimestamp: number;
18
+ visitorData: string;
19
+ };
20
+
21
+ const androidVrClient = {
22
+ name: "ANDROID_VR",
23
+ number: "28",
24
+ userAgent: "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip",
25
+ version: "1.65.10",
26
+ };
27
+
28
+ const browserCookie = "PREF=hl=en&tz=UTC; SOCS=CAI";
29
+
30
+ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchResult> {
31
+ const id = youtubeVideoId(input.url);
32
+ if (!id) {
33
+ throw new Error("YouTube video id not found");
34
+ }
35
+ const session = await youtubeSession(input.net, id);
36
+ const response = await input.net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
37
+ body: JSON.stringify(playerBody(id, session)),
38
+ headers: playerHeaders(session),
39
+ method: "POST",
40
+ });
41
+ if (!response.ok) {
42
+ throw new Error(`YouTube player failed: ${response.status}`);
43
+ }
44
+ const payload = await response.json();
45
+ const status = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.status) : null;
46
+ if (status !== "OK") {
47
+ const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
48
+ throw new Error(reason ?? "YouTube video unavailable");
49
+ }
50
+ const format = selectFormat(payload);
51
+ if (!format) {
52
+ throw new Error("YouTube progressive mp4 not found");
53
+ }
54
+ const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
55
+ const media: MediaItem = {
56
+ filename: filename(`youtube_${title ?? id}_${id}.mp4`),
57
+ headers: { "user-agent": androidVrClient.userAgent },
58
+ id,
59
+ kind: "video",
60
+ mime: "video/mp4",
61
+ platform: "youtube",
62
+ url: format,
63
+ };
64
+ return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], platform: "youtube" };
65
+ }
66
+
67
+ function playerBody(id: string, session: YoutubeSession): Json {
68
+ return {
69
+ contentCheckOk: true,
70
+ context: {
71
+ client: {
72
+ androidSdkVersion: 32,
73
+ clientName: androidVrClient.name,
74
+ clientVersion: androidVrClient.version,
75
+ deviceMake: "Oculus",
76
+ deviceModel: "Quest 3",
77
+ gl: "US",
78
+ hl: "en",
79
+ osName: "Android",
80
+ osVersion: "12L",
81
+ timeZone: "UTC",
82
+ userAgent: androidVrClient.userAgent,
83
+ utcOffsetMinutes: 0,
84
+ visitorData: session.visitorData,
85
+ },
86
+ },
87
+ playbackContext: {
88
+ contentPlaybackContext: {
89
+ html5Preference: "HTML5_PREF_WANTS",
90
+ signatureTimestamp: session.signatureTimestamp,
91
+ },
92
+ },
93
+ racyCheckOk: true,
94
+ videoId: id,
95
+ };
96
+ }
97
+
98
+ async function youtubeSession(net: Net, id: string): Promise<YoutubeSession> {
99
+ const response = await net(`https://www.youtube.com/watch?v=${id}&bpctr=9999999999&has_verified=1`, {
100
+ headers: {
101
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
102
+ "accept-language": "en-us,en;q=0.5",
103
+ cookie: browserCookie,
104
+ "sec-fetch-mode": "navigate",
105
+ "user-agent": browserUserAgent(),
106
+ },
107
+ });
108
+ if (!response.ok) {
109
+ throw new Error(`YouTube page failed: ${response.status}`);
110
+ }
111
+ const page = await response.text();
112
+ const visitorData = string(page.match(/"visitorData":"([^"]+)"/)?.[1]);
113
+ if (!visitorData) {
114
+ throw new Error("YouTube visitor data not found");
115
+ }
116
+ const playerPath = string(page.match(/"jsUrl":"([^"]+)"/)?.[1]) ?? string(page.match(/"PLAYER_JS_URL":"([^"]+)"/)?.[1]);
117
+ if (!playerPath) {
118
+ throw new Error("YouTube player url not found");
119
+ }
120
+ return {
121
+ cookie: youtubeCookie(response.headers),
122
+ signatureTimestamp: await signatureTimestamp(net, playerPath),
123
+ visitorData,
124
+ };
125
+ }
126
+
127
+ function playerHeaders(session: YoutubeSession): Headers {
128
+ return new Headers({
129
+ "content-type": "application/json",
130
+ cookie: `${browserCookie}; ${session.cookie}`,
131
+ origin: "https://www.youtube.com",
132
+ "user-agent": androidVrClient.userAgent,
133
+ "x-goog-visitor-id": session.visitorData,
134
+ "x-youtube-client-name": androidVrClient.number,
135
+ "x-youtube-client-version": androidVrClient.version,
136
+ });
137
+ }
138
+
139
+ function youtubeCookie(headers: Headers): string {
140
+ const readable = headers as Headers & { getSetCookie?: () => string[] };
141
+ const fallback = headers.get("set-cookie");
142
+ const cookieHeaders = readable.getSetCookie ? readable.getSetCookie() : fallback ? [fallback] : [];
143
+ return cookieHeaders
144
+ .map((header) => string(header.split(";", 1)[0]))
145
+ .filter(string)
146
+ .join("; ");
147
+ }
148
+
149
+ async function signatureTimestamp(net: Net, playerPath: string): Promise<number> {
150
+ const response = await net(new URL(playerPath, "https://www.youtube.com").toString(), {
151
+ headers: { "user-agent": browserUserAgent() },
152
+ });
153
+ if (!response.ok) {
154
+ throw new Error(`YouTube player failed: ${response.status}`);
155
+ }
156
+ const player = await response.text();
157
+ const timestamp = number(Number(player.match(/signatureTimestamp[:=](\d+)/)?.[1] ?? player.match(/sts[:=](\d+)/)?.[1]));
158
+ if (!timestamp) {
159
+ throw new Error("YouTube signature timestamp not found");
160
+ }
161
+ return timestamp;
162
+ }
163
+
164
+ export function youtubeVideoId(input: string): string | null {
165
+ const url = asUrl(input);
166
+ if (url.hostname === "youtu.be") {
167
+ return cleanId(url.pathname.split("/").filter(Boolean)[0]);
168
+ }
169
+ const fromQuery = cleanId(url.searchParams.get("v"));
170
+ if (fromQuery) {
171
+ return fromQuery;
172
+ }
173
+ const parts = url.pathname.split("/").filter(Boolean);
174
+ const index = parts.findIndex((part) => part === "shorts" || part === "live" || part === "embed");
175
+ return index >= 0 ? cleanId(parts[index + 1]) : null;
176
+ }
177
+
178
+ function cleanId(value: string | null | undefined): string | null {
179
+ return typeof value === "string" && /^[A-Za-z0-9_-]{11}$/.test(value) ? value : null;
180
+ }
181
+
182
+ function selectFormat(payload: unknown): string | null {
183
+ const root = object(payload) ? payload : null;
184
+ const streaming = root && object(root.streamingData) ? root.streamingData : null;
185
+ const formats = streaming && Array.isArray(streaming.formats) ? streaming.formats.filter(object) : [];
186
+ const mp4 = formats
187
+ .filter((format) => string(format.url) && string(format.mimeType)?.startsWith("video/mp4"))
188
+ .sort((left, right) => height(right) - height(left));
189
+ return mp4[0] ? string(mp4[0].url) : null;
190
+ }
191
+
192
+ function height(format: Json): number {
193
+ return number(format.height) ?? Number(string(format.qualityLabel)?.match(/(\d+)p/)?.[1] ?? 0);
194
+ }
package/src/zip.ts ADDED
@@ -0,0 +1,121 @@
1
+ export type ZipEntry = {
2
+ data: Uint8Array;
3
+ name: string;
4
+ };
5
+
6
+ const encoder = new TextEncoder();
7
+ const table = new Uint32Array(256);
8
+ type PackedEntry = {
9
+ crc: number;
10
+ data: Uint8Array;
11
+ name: Uint8Array;
12
+ };
13
+
14
+ for (let value = 0; value < table.length; value += 1) {
15
+ let crc = value;
16
+ for (let bit = 0; bit < 8; bit += 1) {
17
+ crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
18
+ }
19
+ table[value] = crc >>> 0;
20
+ }
21
+
22
+ export function zip(entries: ZipEntry[]): Uint8Array {
23
+ const files = entries.map((entry) => {
24
+ const name = encoder.encode(entry.name);
25
+ const crc = crc32(entry.data);
26
+ return { ...entry, crc, name };
27
+ });
28
+ const locals = files.map((file) => 30 + file.name.length + file.data.length);
29
+ const centrals = files.map((file) => 46 + file.name.length);
30
+ const localSize = locals.reduce((sum, size) => sum + size, 0);
31
+ const centralSize = centrals.reduce((sum, size) => sum + size, 0);
32
+ const result = new Uint8Array(localSize + centralSize + 22);
33
+ const view = new DataView(result.buffer);
34
+ let offset = 0;
35
+ const centralOffsets: number[] = [];
36
+
37
+ for (const file of files) {
38
+ centralOffsets.push(offset);
39
+ offset = writeLocal(view, result, offset, file);
40
+ }
41
+
42
+ const centralStart = offset;
43
+ for (let index = 0; index < files.length; index += 1) {
44
+ offset = writeCentral(view, result, offset, files[index], centralOffsets[index]);
45
+ }
46
+
47
+ writeEnd(view, offset, files.length, centralSize, centralStart);
48
+ return result;
49
+ }
50
+
51
+ function writeLocal(
52
+ view: DataView,
53
+ target: Uint8Array,
54
+ offset: number,
55
+ file: PackedEntry,
56
+ ): number {
57
+ view.setUint32(offset, 0x04034b50, true);
58
+ view.setUint16(offset + 4, 20, true);
59
+ view.setUint16(offset + 6, 0x0800, true);
60
+ view.setUint16(offset + 8, 0, true);
61
+ writeDate(view, offset + 10);
62
+ view.setUint32(offset + 14, file.crc, true);
63
+ view.setUint32(offset + 18, file.data.length, true);
64
+ view.setUint32(offset + 22, file.data.length, true);
65
+ view.setUint16(offset + 26, file.name.length, true);
66
+ view.setUint16(offset + 28, 0, true);
67
+ target.set(file.name, offset + 30);
68
+ target.set(file.data, offset + 30 + file.name.length);
69
+ return offset + 30 + file.name.length + file.data.length;
70
+ }
71
+
72
+ function writeCentral(
73
+ view: DataView,
74
+ target: Uint8Array,
75
+ offset: number,
76
+ file: PackedEntry,
77
+ localOffset: number,
78
+ ): number {
79
+ view.setUint32(offset, 0x02014b50, true);
80
+ view.setUint16(offset + 4, 20, true);
81
+ view.setUint16(offset + 6, 20, true);
82
+ view.setUint16(offset + 8, 0x0800, true);
83
+ view.setUint16(offset + 10, 0, true);
84
+ writeDate(view, offset + 12);
85
+ view.setUint32(offset + 16, file.crc, true);
86
+ view.setUint32(offset + 20, file.data.length, true);
87
+ view.setUint32(offset + 24, file.data.length, true);
88
+ view.setUint16(offset + 28, file.name.length, true);
89
+ view.setUint16(offset + 30, 0, true);
90
+ view.setUint16(offset + 32, 0, true);
91
+ view.setUint16(offset + 34, 0, true);
92
+ view.setUint16(offset + 36, 0, true);
93
+ view.setUint32(offset + 38, 0, true);
94
+ view.setUint32(offset + 42, localOffset, true);
95
+ target.set(file.name, offset + 46);
96
+ return offset + 46 + file.name.length;
97
+ }
98
+
99
+ function writeEnd(view: DataView, offset: number, count: number, centralSize: number, centralStart: number): void {
100
+ view.setUint32(offset, 0x06054b50, true);
101
+ view.setUint16(offset + 8, count, true);
102
+ view.setUint16(offset + 10, count, true);
103
+ view.setUint32(offset + 12, centralSize, true);
104
+ view.setUint32(offset + 16, centralStart, true);
105
+ }
106
+
107
+ function writeDate(view: DataView, offset: number): void {
108
+ const date = new Date();
109
+ const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
110
+ const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
111
+ view.setUint16(offset, time, true);
112
+ view.setUint16(offset + 2, day, true);
113
+ }
114
+
115
+ function crc32(data: Uint8Array): number {
116
+ let crc = 0xffffffff;
117
+ for (const byte of data) {
118
+ crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
119
+ }
120
+ return (crc ^ 0xffffffff) >>> 0;
121
+ }