open-nami 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +1058 -210
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ var require_package = __commonJS({
9
9
  "package.json"(exports, module) {
10
10
  module.exports = {
11
11
  name: "open-nami",
12
- version: "1.0.3",
12
+ version: "1.0.5",
13
13
  description: "Open Nami \u2014 terminal-first bulk social media profile downloader for Instagram, TikTok, Facebook & X.",
14
14
  type: "module",
15
15
  bin: {
@@ -1387,14 +1387,31 @@ function parseProfileInput(input) {
1387
1387
  }
1388
1388
  }
1389
1389
  if (host.includes("x.com") || host.includes("twitter.com")) {
1390
+ if (pathParts[0]?.toLowerCase() === "i" && pathParts[1]?.toLowerCase() === "history" && pathParts[2]?.toLowerCase() === "likes") {
1391
+ return {
1392
+ platform: "x",
1393
+ username: "i_history_likes",
1394
+ cleanUrl: "https://x.com/i/history/likes",
1395
+ rawInput: input
1396
+ };
1397
+ }
1398
+ if (pathParts[0]?.toLowerCase() === "i" && pathParts[1]?.toLowerCase() === "bookmarks") {
1399
+ return {
1400
+ platform: "x",
1401
+ username: "i_bookmarks",
1402
+ cleanUrl: "https://x.com/i/bookmarks",
1403
+ rawInput: input
1404
+ };
1405
+ }
1390
1406
  const firstPart = pathParts[0] || "";
1391
1407
  const reserved = ["home", "explore", "notifications", "messages", "i", "settings"];
1392
1408
  if (firstPart && !reserved.includes(firstPart.toLowerCase())) {
1393
1409
  const username = firstPart.replace(/^@/, "");
1410
+ const isLikesUrl = pathParts[1]?.toLowerCase() === "likes";
1394
1411
  return {
1395
1412
  platform: "x",
1396
1413
  username: sanitizeUsername(username),
1397
- cleanUrl: `https://x.com/${username}`,
1414
+ cleanUrl: isLikesUrl ? `https://x.com/${username}/likes` : `https://x.com/${username}`,
1398
1415
  rawInput: input
1399
1416
  };
1400
1417
  }
@@ -1559,6 +1576,9 @@ function resolvePlatformDownloadsDir(options = {}) {
1559
1576
  return defaultFallback;
1560
1577
  }
1561
1578
 
1579
+ // src/lib/engines/extractors/instagram.ts
1580
+ import crypto from "crypto";
1581
+
1562
1582
  // src/lib/engines/extractors/http.ts
1563
1583
  import fs5 from "fs/promises";
1564
1584
  import { setTimeout as setTimeoutPromise } from "timers/promises";
@@ -1627,6 +1647,11 @@ async function sleep(minMs, maxMs, signal) {
1627
1647
  throw err;
1628
1648
  }
1629
1649
  }
1650
+ function combineTimeoutSignal(signal, timeoutMs) {
1651
+ if (!timeoutMs || timeoutMs <= 0) return signal;
1652
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
1653
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
1654
+ }
1630
1655
  var HttpClient = class {
1631
1656
  defaultCookies = {};
1632
1657
  defaultHeaders;
@@ -1661,8 +1686,8 @@ var HttpClient = class {
1661
1686
  Object.assign(this.defaultCookies, loaded);
1662
1687
  }
1663
1688
  async request(url, options = {}) {
1664
- const retries = options.retries ?? 3;
1665
- const retryDelay = options.retryDelayMs ?? 1500;
1689
+ const retries = options.retries ?? 1;
1690
+ const retryDelay = options.retryDelayMs ?? 1e3;
1666
1691
  let cookieHeader = "";
1667
1692
  if (options.cookiePath) {
1668
1693
  const fromPath = await this.getCachedCookies(options.cookiePath, options.cookieDomain);
@@ -1688,9 +1713,11 @@ var HttpClient = class {
1688
1713
  if (options.signal?.aborted) {
1689
1714
  throw options.signal.reason ?? new Error("Aborted");
1690
1715
  }
1716
+ const requestSignal = combineTimeoutSignal(options.signal || void 0, options.timeoutMs);
1691
1717
  try {
1692
1718
  const response = await fetch(url, {
1693
1719
  ...options,
1720
+ signal: requestSignal,
1694
1721
  headers
1695
1722
  });
1696
1723
  if (response.status === 429) {
@@ -1699,7 +1726,8 @@ var HttpClient = class {
1699
1726
  await response.body?.cancel();
1700
1727
  } catch {
1701
1728
  }
1702
- await sleep(retryDelay * (attempt + 1) * 2, void 0, options.signal || void 0);
1729
+ const backoffMs = Math.min(retryDelay * (attempt + 1), 2500);
1730
+ await sleep(backoffMs, void 0, options.signal || void 0);
1703
1731
  continue;
1704
1732
  }
1705
1733
  }
@@ -1708,7 +1736,8 @@ var HttpClient = class {
1708
1736
  await response.body?.cancel();
1709
1737
  } catch {
1710
1738
  }
1711
- await sleep(retryDelay * (attempt + 1), void 0, options.signal || void 0);
1739
+ const backoffMs = Math.min(retryDelay * (attempt + 1), 2e3);
1740
+ await sleep(backoffMs, void 0, options.signal || void 0);
1712
1741
  continue;
1713
1742
  }
1714
1743
  return response;
@@ -1731,13 +1760,23 @@ var HttpClient = class {
1731
1760
  }
1732
1761
  });
1733
1762
  if (!res.ok) {
1763
+ try {
1764
+ await res.body?.cancel();
1765
+ } catch {
1766
+ }
1734
1767
  throw new Error(`HTTP ${res.status} (${res.statusText}) for ${url}`);
1735
1768
  }
1736
- return await res.json();
1769
+ const text = await res.text();
1770
+ const cleanText = text.replace(/^\s*(?:for\s*\(\s*;\s*;\s*\);|\)\]\}',)/, "").trim();
1771
+ return JSON.parse(cleanText);
1737
1772
  }
1738
1773
  async fetchText(url, options = {}) {
1739
1774
  const res = await this.request(url, options);
1740
1775
  if (!res.ok) {
1776
+ try {
1777
+ await res.body?.cancel();
1778
+ } catch {
1779
+ }
1741
1780
  throw new Error(`HTTP ${res.status} (${res.statusText}) for ${url}`);
1742
1781
  }
1743
1782
  return await res.text();
@@ -1747,7 +1786,22 @@ var HttpClient = class {
1747
1786
  // src/lib/engines/extractors/instagram.ts
1748
1787
  var IG_APP_ID = "936619743392459";
1749
1788
  var IG_TIMELINE_QUERY_HASH = "69cba40317214236af40e7efa697781d";
1750
- var IG_POLARIS_DOC_ID = "28975909992013618";
1789
+ var IG_QUERIES = {
1790
+ PolarisProfilePostsTabContentQuery_connection: "39229011733413759",
1791
+ PolarisProfileReelsTabContentQuery_connection: "28989741540630190",
1792
+ PolarisProfileTaggedTabContentQuery_connection: "28794602806842110",
1793
+ PolarisProfileStoryHighlightsTrayContentQuery: "26970053832668570",
1794
+ PolarisStoriesV3ReelPageGalleryQuery: "28262315486766731",
1795
+ PolarisStoriesV3TrayContainerQuery: "27703822975903310",
1796
+ PolarisProfilePageContentQuery: "28649589871350527"
1797
+ };
1798
+ var IG_POLARIS_DOC_ID = IG_QUERIES.PolarisProfilePostsTabContentQuery_connection;
1799
+ function logExtractorDebug(endpoint, err) {
1800
+ if (process.env.DEBUG || process.env.OPEN_NAMI_DEBUG) {
1801
+ const message = err instanceof Error ? err.message : String(err);
1802
+ console.debug(`[open-nami:instagram] ${endpoint} query fallback: ${message}`);
1803
+ }
1804
+ }
1751
1805
  function parseIgNodeToMediaItems(node, username) {
1752
1806
  const items = [];
1753
1807
  const anyNode = node;
@@ -1783,6 +1837,21 @@ function parseIgNodeToMediaItems(node, username) {
1783
1837
  height: video?.height ?? node.dimensions?.height,
1784
1838
  thumbnailUrl: node.display_url
1785
1839
  });
1840
+ } else {
1841
+ const candidate = node.image_versions2?.candidates?.[0];
1842
+ const photoUrl = candidate?.url || node.display_url;
1843
+ if (photoUrl) {
1844
+ items.push({
1845
+ id: baseId,
1846
+ url: photoUrl,
1847
+ filename: `${username}_${date || baseId}`,
1848
+ extension: "jpg",
1849
+ type: "photo",
1850
+ date,
1851
+ width: candidate?.width ?? node.dimensions?.width,
1852
+ height: candidate?.height ?? node.dimensions?.height
1853
+ });
1854
+ }
1786
1855
  }
1787
1856
  } else {
1788
1857
  const candidate = node.image_versions2?.candidates?.[0];
@@ -1852,6 +1921,8 @@ var InstagramExtractor = class {
1852
1921
  cookiePath: options.cookiePath,
1853
1922
  cookieDomain: "instagram.com",
1854
1923
  signal: options.signal,
1924
+ retries: 0,
1925
+ timeoutMs: 4e3,
1855
1926
  headers: {
1856
1927
  "X-IG-App-ID": IG_APP_ID,
1857
1928
  "Referer": `https://www.instagram.com/${username}/`
@@ -1860,9 +1931,47 @@ var InstagramExtractor = class {
1860
1931
  if (!res.data?.user) {
1861
1932
  throw new Error(`Instagram user not found or private profile: ${username}`);
1862
1933
  }
1863
- return res.data.user;
1934
+ const user = res.data.user;
1935
+ if (user.is_private) {
1936
+ const fs13 = user.friendship_status;
1937
+ const isFollowing = fs13 ? Boolean(fs13.following) : Boolean(user.followed_by_viewer);
1938
+ if (!isFollowing) {
1939
+ throw new Error(`Instagram profile @${username} is private. You must provide a session cookie jar of an account following this profile.`);
1940
+ }
1941
+ }
1942
+ return user;
1864
1943
  }
1865
1944
  async fetchHighlightsTray(userId, options) {
1945
+ try {
1946
+ const variables = JSON.stringify({ user_id: userId });
1947
+ const body = new URLSearchParams({
1948
+ doc_id: IG_QUERIES.PolarisProfileStoryHighlightsTrayContentQuery,
1949
+ variables
1950
+ }).toString();
1951
+ const res = await this.httpClient.fetchJson("https://www.instagram.com/graphql/query", {
1952
+ method: "POST",
1953
+ body,
1954
+ cookiePath: options.cookiePath,
1955
+ cookieDomain: "instagram.com",
1956
+ signal: options.signal,
1957
+ headers: {
1958
+ "Content-Type": "application/x-www-form-urlencoded",
1959
+ "X-IG-App-ID": IG_APP_ID,
1960
+ "X-FB-Friendly-Name": "PolarisProfileStoryHighlightsTrayContentQuery",
1961
+ "Referer": "https://www.instagram.com/"
1962
+ }
1963
+ });
1964
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
1965
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
1966
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
1967
+ }
1968
+ const edges = res?.data?.highlights?.edges;
1969
+ if (Array.isArray(edges) && edges.length > 0) {
1970
+ return edges.map((e) => e.node);
1971
+ }
1972
+ } catch (err) {
1973
+ logExtractorDebug("PolarisProfileStoryHighlightsTrayContentQuery", err);
1974
+ }
1866
1975
  const url = `https://www.instagram.com/api/v1/highlights/${userId}/highlights_tray/`;
1867
1976
  try {
1868
1977
  const res = await this.httpClient.fetchJson(url, {
@@ -1874,7 +1983,8 @@ var InstagramExtractor = class {
1874
1983
  }
1875
1984
  });
1876
1985
  return res.tray ?? [];
1877
- } catch {
1986
+ } catch (err) {
1987
+ logExtractorDebug("Highlights legacy REST", err);
1878
1988
  return [];
1879
1989
  }
1880
1990
  }
@@ -1898,40 +2008,226 @@ var InstagramExtractor = class {
1898
2008
  return [];
1899
2009
  }
1900
2010
  }
1901
- async fetchNextPostsPage(userId, username, cursor, options) {
1902
- const variables = JSON.stringify({ id: userId, first: 12, after: cursor });
1903
- const url = `https://www.instagram.com/graphql/query/?query_hash=${IG_TIMELINE_QUERY_HASH}&variables=${encodeURIComponent(variables)}`;
2011
+ async fetchWebTokens(username, options) {
2012
+ let csrfToken;
2013
+ if (options.cookiePath) {
2014
+ try {
2015
+ const cookies = await loadCookiesFromPath(options.cookiePath, "instagram.com");
2016
+ csrfToken = cookies["csrftoken"];
2017
+ } catch {
2018
+ }
2019
+ }
2020
+ if (!csrfToken) {
2021
+ csrfToken = crypto.randomBytes(16).toString("hex");
2022
+ }
1904
2023
  try {
1905
- const res = await this.httpClient.fetchJson(url, {
2024
+ const html = await this.httpClient.fetchText(`https://www.instagram.com/${encodeURIComponent(username)}/`, {
1906
2025
  cookiePath: options.cookiePath,
1907
2026
  cookieDomain: "instagram.com",
1908
2027
  signal: options.signal,
1909
2028
  headers: {
1910
- "X-IG-App-ID": IG_APP_ID,
1911
- "Referer": `https://www.instagram.com/${username}/`
2029
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
1912
2030
  }
1913
2031
  });
1914
- const data = res?.data?.user?.edge_owner_to_timeline_media;
1915
- if (data?.edges && data.edges.length > 0) {
1916
- return {
1917
- edges: data.edges,
1918
- hasNextPage: Boolean(data.page_info?.has_next_page),
1919
- nextCursor: data.page_info?.end_cursor
1920
- };
2032
+ let lsd;
2033
+ let fbDtsg;
2034
+ let userId;
2035
+ const eqmcMatch = html.match(/id="__eqmc"[^>]*>([^<]+)<\/script>/);
2036
+ if (eqmcMatch) {
2037
+ try {
2038
+ const eqmc = JSON.parse(eqmcMatch[1]);
2039
+ if (eqmc.l) lsd = eqmc.l;
2040
+ if (eqmc.f) fbDtsg = eqmc.f;
2041
+ } catch {
2042
+ }
2043
+ }
2044
+ if (!lsd) {
2045
+ const m = html.match(/"lsd":"([^"]+)"/) || html.match(/"LSD",\[\],\{"token":"([^"]+)"/);
2046
+ if (m) lsd = m[1];
2047
+ }
2048
+ if (!fbDtsg) {
2049
+ const m = html.match(/"dtsg":\{"token":"([^"]+)"/);
2050
+ if (m) fbDtsg = m[1];
2051
+ }
2052
+ const uMatch = html.match(/"profile_id":"([^"]+)"/) || html.match(/"user_id":"([^"]+)"/);
2053
+ if (uMatch) userId = uMatch[1];
2054
+ return { lsd, fbDtsg, csrfToken, userId };
2055
+ } catch (err) {
2056
+ logExtractorDebug("fetchWebTokens", err);
2057
+ return { csrfToken };
2058
+ }
2059
+ }
2060
+ async fetchPostsTabGraphQL(username, cursor, tokens, options) {
2061
+ const postVariables = JSON.stringify({
2062
+ after: cursor || null,
2063
+ before: null,
2064
+ data: {
2065
+ count: 12,
2066
+ include_reel_media_seen_timestamp: true,
2067
+ include_relationship_info: true,
2068
+ latest_besties_reel_media: true,
2069
+ latest_reel_media: true
2070
+ },
2071
+ first: 12,
2072
+ include_multi_captions: true,
2073
+ last: null,
2074
+ username,
2075
+ __relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: true,
2076
+ __relay_internal__pv__PolarisShortDramaEnabledrelayprovider: true,
2077
+ __relay_internal__pv__PolarisThreadsStyleCommentsEnabledrelayprovider: false,
2078
+ __relay_internal__pv__PolarisReelsRecoDebugOverlayEnabledrelayprovider: false
2079
+ });
2080
+ const bodyParams = new URLSearchParams({
2081
+ av: "17841415137994167",
2082
+ __d: "www",
2083
+ __user: "0",
2084
+ __a: "1",
2085
+ __req: "0",
2086
+ __hs: "20731.HYP:instagram_web_pkg.2.1...0",
2087
+ dpr: "1",
2088
+ __ccg: "EXCELLENT",
2089
+ __rev: "1049285836",
2090
+ __hsi: "7693193318680449885",
2091
+ __comet_req: "7",
2092
+ jazoest: "26461",
2093
+ fb_api_caller_class: "RelayModern",
2094
+ fb_api_req_friendly_name: "PolarisProfilePostsTabContentQuery_connection",
2095
+ server_timestamps: "true",
2096
+ variables: postVariables,
2097
+ doc_id: IG_POLARIS_DOC_ID
2098
+ });
2099
+ if (tokens.fbDtsg) bodyParams.set("fb_dtsg", tokens.fbDtsg);
2100
+ if (tokens.lsd) bodyParams.set("lsd", tokens.lsd);
2101
+ const headers = {
2102
+ "Accept": "*/*",
2103
+ "Content-Type": "application/x-www-form-urlencoded",
2104
+ "X-IG-App-ID": IG_APP_ID,
2105
+ "X-IG-Max-Touch-Points": "0",
2106
+ "X-FB-Friendly-Name": "PolarisProfilePostsTabContentQuery_connection",
2107
+ "Referer": `https://www.instagram.com/${username}/`,
2108
+ "Origin": "https://www.instagram.com",
2109
+ "X-ASBD-ID": "359341",
2110
+ "Sec-Fetch-Dest": "empty",
2111
+ "Sec-Fetch-Mode": "cors",
2112
+ "Sec-Fetch-Site": "same-origin"
2113
+ };
2114
+ if (tokens.csrfToken) headers["X-CSRFToken"] = tokens.csrfToken;
2115
+ if (tokens.lsd) headers["X-FB-LSD"] = tokens.lsd;
2116
+ const res = await this.httpClient.fetchJson("https://www.instagram.com/graphql/query", {
2117
+ method: "POST",
2118
+ body: bodyParams.toString(),
2119
+ cookiePath: options.cookiePath,
2120
+ cookieDomain: "instagram.com",
2121
+ signal: options.signal,
2122
+ headers
2123
+ });
2124
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2125
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2126
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2127
+ }
2128
+ const connection = res?.data?.xdt_api__v1__feed__user_timeline_graphql_connection || res?.data?.user?.edge_owner_to_timeline_media;
2129
+ if (connection?.edges && Array.isArray(connection.edges)) {
2130
+ let resolvedUserId;
2131
+ for (const edge of connection.edges) {
2132
+ const node = edge?.node;
2133
+ if (node?.owner_id) {
2134
+ resolvedUserId = String(node.owner_id);
2135
+ break;
2136
+ }
2137
+ if (node?.user?.pk || node?.user?.id) {
2138
+ resolvedUserId = String(node.user.pk || node.user.id);
2139
+ break;
2140
+ }
2141
+ if (typeof node?.id === "string" && node.id.includes("_")) {
2142
+ resolvedUserId = node.id.split("_")[1];
2143
+ break;
2144
+ }
2145
+ }
2146
+ return {
2147
+ edges: connection.edges,
2148
+ hasNextPage: Boolean(connection.page_info?.has_next_page),
2149
+ nextCursor: connection.page_info?.end_cursor,
2150
+ userId: resolvedUserId
2151
+ };
2152
+ }
2153
+ return { edges: [], hasNextPage: false };
2154
+ }
2155
+ async fetchNextPostsPage(userId, username, cursor, options, tokens) {
2156
+ if (userId) {
2157
+ const variables = JSON.stringify({ id: userId, first: 12, after: cursor });
2158
+ const url = `https://www.instagram.com/graphql/query/?query_hash=${IG_TIMELINE_QUERY_HASH}&variables=${encodeURIComponent(variables)}`;
2159
+ try {
2160
+ const res = await this.httpClient.fetchJson(url, {
2161
+ cookiePath: options.cookiePath,
2162
+ cookieDomain: "instagram.com",
2163
+ signal: options.signal,
2164
+ headers: {
2165
+ "X-IG-App-ID": IG_APP_ID,
2166
+ "Referer": `https://www.instagram.com/${username}/`
2167
+ }
2168
+ });
2169
+ const data = res?.data?.user?.edge_owner_to_timeline_media;
2170
+ if (data?.edges && data.edges.length > 0) {
2171
+ return {
2172
+ edges: data.edges,
2173
+ hasNextPage: Boolean(data.page_info?.has_next_page),
2174
+ nextCursor: data.page_info?.end_cursor
2175
+ };
2176
+ }
2177
+ } catch (err) {
2178
+ logExtractorDebug("edge_owner_to_timeline_media", err);
2179
+ }
2180
+ }
2181
+ try {
2182
+ const page = await this.fetchPostsTabGraphQL(username, cursor, tokens || {}, options);
2183
+ if (page.edges && page.edges.length > 0) {
2184
+ return page;
2185
+ }
2186
+ } catch (err) {
2187
+ logExtractorDebug("PolarisProfilePostsTabContentQuery_connection", err);
2188
+ }
2189
+ if (userId) {
2190
+ try {
2191
+ const restUrl = `https://www.instagram.com/api/v1/feed/user/${userId}/?count=12&max_id=${encodeURIComponent(cursor)}`;
2192
+ const res = await this.httpClient.fetchJson(restUrl, {
2193
+ cookiePath: options.cookiePath,
2194
+ cookieDomain: "instagram.com",
2195
+ signal: options.signal,
2196
+ headers: {
2197
+ "X-IG-App-ID": IG_APP_ID,
2198
+ "Referer": `https://www.instagram.com/${username}/`
2199
+ }
2200
+ });
2201
+ if (Array.isArray(res?.items) && res.items.length > 0) {
2202
+ const edges = res.items.map((item) => ({ node: item }));
2203
+ return {
2204
+ edges,
2205
+ hasNextPage: Boolean(res.more_available),
2206
+ nextCursor: res.next_max_id ? String(res.next_max_id) : void 0
2207
+ };
2208
+ }
2209
+ } catch (err) {
2210
+ logExtractorDebug("v1 user feed REST", err);
1921
2211
  }
1922
- } catch {
1923
2212
  }
2213
+ return { edges: [], hasNextPage: false };
2214
+ }
2215
+ async fetchNextReelsPage(userId, cursor, options) {
1924
2216
  try {
1925
- const postVariables = JSON.stringify({
2217
+ const variables = JSON.stringify({
1926
2218
  after: cursor,
1927
2219
  first: 12,
1928
- username,
1929
- data: { count: 12 }
2220
+ id: userId,
2221
+ data: {
2222
+ include_feed_video: true,
2223
+ page_size: 12,
2224
+ target_user_id: userId
2225
+ }
1930
2226
  });
1931
2227
  const postUrl = "https://www.instagram.com/graphql/query";
1932
2228
  const body = new URLSearchParams({
1933
- doc_id: IG_POLARIS_DOC_ID,
1934
- variables: postVariables
2229
+ doc_id: IG_QUERIES.PolarisProfileReelsTabContentQuery_connection,
2230
+ variables
1935
2231
  }).toString();
1936
2232
  const res = await this.httpClient.fetchJson(postUrl, {
1937
2233
  method: "POST",
@@ -1942,40 +2238,33 @@ var InstagramExtractor = class {
1942
2238
  headers: {
1943
2239
  "Content-Type": "application/x-www-form-urlencoded",
1944
2240
  "X-IG-App-ID": IG_APP_ID,
1945
- "X-FB-Friendly-Name": "PolarisProfilePostsTabContentQuery_connection",
1946
- "Referer": `https://www.instagram.com/${username}/`
2241
+ "X-FB-Friendly-Name": "PolarisProfileReelsTabContentQuery_connection",
2242
+ "Referer": "https://www.instagram.com/"
1947
2243
  }
1948
2244
  });
1949
- const connection = res?.data?.xdt_api__v1__feed__user_timeline_graphql_connection || res?.data?.user?.edge_owner_to_timeline_media;
1950
- if (connection?.edges && connection.edges.length > 0) {
2245
+ if (res?.errors && Array.isArray(res.errors) && res.errors.length > 0) {
2246
+ const errMsg = res.errors.map((e) => e.message || JSON.stringify(e)).join("; ");
2247
+ throw new Error(`Instagram GraphQL query error: ${errMsg}`);
2248
+ }
2249
+ const reelsData = res?.data?.fetch__XDTUserDict;
2250
+ let connection = reelsData?.reels;
2251
+ if (!connection && reelsData) {
2252
+ for (const val of Object.values(reelsData)) {
2253
+ if (val && typeof val === "object" && "edges" in val) {
2254
+ connection = val;
2255
+ break;
2256
+ }
2257
+ }
2258
+ }
2259
+ if (connection?.edges && Array.isArray(connection.edges)) {
1951
2260
  return {
1952
2261
  edges: connection.edges,
1953
2262
  hasNextPage: Boolean(connection.page_info?.has_next_page),
1954
2263
  nextCursor: connection.page_info?.end_cursor
1955
2264
  };
1956
2265
  }
1957
- } catch {
1958
- }
1959
- try {
1960
- const restUrl = `https://www.instagram.com/api/v1/feed/user/${userId}/?count=12&max_id=${encodeURIComponent(cursor)}`;
1961
- const res = await this.httpClient.fetchJson(restUrl, {
1962
- cookiePath: options.cookiePath,
1963
- cookieDomain: "instagram.com",
1964
- signal: options.signal,
1965
- headers: {
1966
- "X-IG-App-ID": IG_APP_ID,
1967
- "Referer": `https://www.instagram.com/${username}/`
1968
- }
1969
- });
1970
- if (Array.isArray(res?.items) && res.items.length > 0) {
1971
- const edges = res.items.map((item) => ({ node: item }));
1972
- return {
1973
- edges,
1974
- hasNextPage: Boolean(res.more_available),
1975
- nextCursor: res.next_max_id ? String(res.next_max_id) : void 0
1976
- };
1977
- }
1978
- } catch {
2266
+ } catch (err) {
2267
+ logExtractorDebug("PolarisProfileReelsTabContentQuery_connection", err);
1979
2268
  }
1980
2269
  return { edges: [], hasNextPage: false };
1981
2270
  }
@@ -1987,9 +2276,29 @@ var InstagramExtractor = class {
1987
2276
  found: 0,
1988
2277
  message: `Fetching Instagram profile @${username}...`
1989
2278
  });
1990
- const user = await this.fetchUserProfile(username, options);
1991
- const userId = user.id;
2279
+ let user = null;
2280
+ try {
2281
+ user = await this.fetchUserProfile(username, options);
2282
+ } catch (err) {
2283
+ logExtractorDebug("fetchUserProfile", err);
2284
+ }
2285
+ let webTokens = {};
2286
+ if (!user) {
2287
+ webTokens = await this.fetchWebTokens(username, options);
2288
+ }
2289
+ let userId = user?.id || webTokens.userId;
2290
+ if (!userId && (subDir === "Stories" || subDir === "Highlights")) {
2291
+ try {
2292
+ const probe = await this.fetchPostsTabGraphQL(username, void 0, webTokens, options);
2293
+ if (probe.userId) {
2294
+ userId = probe.userId;
2295
+ }
2296
+ } catch (err) {
2297
+ logExtractorDebug("userId probe", err);
2298
+ }
2299
+ }
1992
2300
  if (subDir === "Stories") {
2301
+ if (!userId) return;
1993
2302
  options.onProgress?.({
1994
2303
  stage: "Stories",
1995
2304
  found: 0,
@@ -2005,6 +2314,7 @@ var InstagramExtractor = class {
2005
2314
  return;
2006
2315
  }
2007
2316
  if (subDir === "Highlights") {
2317
+ if (!userId) return;
2008
2318
  options.onProgress?.({
2009
2319
  stage: "Highlights",
2010
2320
  found: 0,
@@ -2030,15 +2340,31 @@ var InstagramExtractor = class {
2030
2340
  return;
2031
2341
  }
2032
2342
  let found = 0;
2033
- let currentEdges = user.edge_owner_to_timeline_media?.edges ?? [];
2034
- let hasNextPage = Boolean(user.edge_owner_to_timeline_media?.page_info?.has_next_page);
2035
- let endCursor = user.edge_owner_to_timeline_media?.page_info?.end_cursor;
2343
+ const emittedItemIds = /* @__PURE__ */ new Set();
2344
+ let currentEdges = user?.edge_owner_to_timeline_media?.edges ?? [];
2345
+ let hasNextPage = Boolean(user?.edge_owner_to_timeline_media?.page_info?.has_next_page);
2346
+ let endCursor = user?.edge_owner_to_timeline_media?.page_info?.end_cursor;
2347
+ if (!user) {
2348
+ try {
2349
+ const firstPage = await this.fetchPostsTabGraphQL(username, void 0, webTokens, options);
2350
+ currentEdges = firstPage.edges;
2351
+ hasNextPage = firstPage.hasNextPage;
2352
+ endCursor = firstPage.nextCursor;
2353
+ if (!userId && firstPage.userId) {
2354
+ userId = firstPage.userId;
2355
+ }
2356
+ } catch (err) {
2357
+ logExtractorDebug("fetchPostsTabGraphQL initial", err);
2358
+ }
2359
+ }
2036
2360
  while (true) {
2037
2361
  for (const edge of currentEdges) {
2038
2362
  const items = parseIgNodeToMediaItems(edge.node, username);
2039
2363
  for (const item of items) {
2040
2364
  if (subDir === "Photos" && item.type !== "photo") continue;
2041
2365
  if (subDir === "Videos" && item.type !== "video") continue;
2366
+ if (emittedItemIds.has(item.id)) continue;
2367
+ emittedItemIds.add(item.id);
2042
2368
  found++;
2043
2369
  options.onProgress?.({
2044
2370
  stage: subDir,
@@ -2053,7 +2379,7 @@ var InstagramExtractor = class {
2053
2379
  }
2054
2380
  await sleep(1500, 2500, options.signal);
2055
2381
  try {
2056
- const nextPage = await this.fetchNextPostsPage(userId, username, endCursor, options);
2382
+ const nextPage = await this.fetchNextPostsPage(userId || "", username, endCursor, options, webTokens);
2057
2383
  currentEdges = nextPage.edges;
2058
2384
  hasNextPage = nextPage.hasNextPage;
2059
2385
  endCursor = nextPage.nextCursor;
@@ -2064,12 +2390,14 @@ var InstagramExtractor = class {
2064
2390
  break;
2065
2391
  }
2066
2392
  }
2067
- if (subDir === "Videos") {
2068
- const videoEdges = user.edge_felix_video_timeline?.edges ?? [];
2393
+ if (subDir === "Videos" && userId) {
2394
+ const videoEdges = user?.edge_felix_video_timeline?.edges ?? [];
2069
2395
  for (const edge of videoEdges) {
2070
2396
  const items = parseIgNodeToMediaItems(edge.node, username);
2071
2397
  for (const item of items) {
2072
2398
  if (item.type !== "video") continue;
2399
+ if (emittedItemIds.has(item.id)) continue;
2400
+ emittedItemIds.add(item.id);
2073
2401
  found++;
2074
2402
  options.onProgress?.({
2075
2403
  stage: "Videos",
@@ -2079,6 +2407,37 @@ var InstagramExtractor = class {
2079
2407
  yield item;
2080
2408
  }
2081
2409
  }
2410
+ let reelsCursor = "";
2411
+ let hasMoreReels = true;
2412
+ while (hasMoreReels && !options.signal?.aborted) {
2413
+ try {
2414
+ const reelsPage = await this.fetchNextReelsPage(userId, reelsCursor, options);
2415
+ if (!reelsPage.edges || reelsPage.edges.length === 0) break;
2416
+ for (const edge of reelsPage.edges) {
2417
+ const items = parseIgNodeToMediaItems(edge.node, username);
2418
+ for (const item of items) {
2419
+ if (item.type !== "video") continue;
2420
+ if (emittedItemIds.has(item.id)) continue;
2421
+ emittedItemIds.add(item.id);
2422
+ found++;
2423
+ options.onProgress?.({
2424
+ stage: "Videos",
2425
+ found,
2426
+ message: `Found reel: ${item.filename}`
2427
+ });
2428
+ yield item;
2429
+ }
2430
+ }
2431
+ if (reelsPage.hasNextPage && reelsPage.nextCursor && reelsPage.nextCursor !== reelsCursor) {
2432
+ reelsCursor = reelsPage.nextCursor;
2433
+ await sleep(1500, 2500, options.signal);
2434
+ } else {
2435
+ hasMoreReels = false;
2436
+ }
2437
+ } catch {
2438
+ break;
2439
+ }
2440
+ }
2082
2441
  }
2083
2442
  }
2084
2443
  };
@@ -2104,8 +2463,43 @@ var TWITTER_FEATURES = {
2104
2463
  responsive_web_text_conversations_enabled: false,
2105
2464
  longform_notetweets_rich_text_read_enabled: true,
2106
2465
  longform_notetweets_inline_media_enabled: true,
2107
- responsive_web_enhance_cards_enabled: false
2466
+ responsive_web_enhance_cards_enabled: false,
2467
+ creator_subscriptions_tweet_preview_api_enabled: true,
2468
+ communities_web_enable_tweet_community_results_fetch: true,
2469
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
2470
+ articles_preview_enabled: true,
2471
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
2472
+ responsive_web_grok_analyze_post_followups_enabled: true,
2473
+ responsive_web_jetfuel_frame: true,
2474
+ responsive_web_grok_share_attachment_enabled: true,
2475
+ responsive_web_grok_analysis_button_from_backend: true,
2476
+ responsive_web_grok_image_annotation_enabled: true,
2477
+ responsive_web_grok_imagine_annotation_enabled: true,
2478
+ profile_label_improvements_pcf_label_in_post_enabled: true,
2479
+ rweb_tipjar_consumption_enabled: true
2108
2480
  };
2481
+ function shouldSkipTweetReply(tweet, targetUserId, mode = "timeline") {
2482
+ const tweetResult = tweet.tweet || tweet;
2483
+ const legacy = tweetResult.legacy || tweetResult;
2484
+ const replyToUserId = legacy.in_reply_to_user_id_str;
2485
+ if (!replyToUserId) {
2486
+ return false;
2487
+ }
2488
+ const authorId = legacy.user_id_str || tweetResult.core?.user_results?.result?.rest_id;
2489
+ if (mode === "timeline") {
2490
+ if (targetUserId) {
2491
+ if (replyToUserId !== targetUserId || authorId && authorId !== targetUserId) {
2492
+ return true;
2493
+ }
2494
+ return false;
2495
+ }
2496
+ return Boolean(authorId && replyToUserId !== authorId);
2497
+ }
2498
+ if (authorId && replyToUserId === authorId) {
2499
+ return false;
2500
+ }
2501
+ return true;
2502
+ }
2109
2503
  function parseTweetMediaEntities(tweet, username) {
2110
2504
  const items = [];
2111
2505
  const tweetResult = tweet.tweet || tweet;
@@ -2175,6 +2569,8 @@ var XExtractor = class {
2175
2569
  cookiePath: options.cookiePath,
2176
2570
  cookieDomain: "x.com",
2177
2571
  signal: options.signal,
2572
+ retries: 0,
2573
+ timeoutMs: 3e3,
2178
2574
  headers: {
2179
2575
  "Authorization": `Bearer ${TWITTER_BEARER_TOKEN}`
2180
2576
  }
@@ -2193,6 +2589,29 @@ var XExtractor = class {
2193
2589
  throw new Error("Failed to activate X/Twitter guest session: no guest token returned");
2194
2590
  }
2195
2591
  async fetchUserRestId(username, options) {
2592
+ if (username.startsWith("id:")) {
2593
+ return username.slice(3);
2594
+ }
2595
+ if (username === "i_history_likes") {
2596
+ if (options.cookiePath) {
2597
+ let cookies = await loadCookiesFromPath(options.cookiePath, "x.com");
2598
+ if (!cookies["twid"]) {
2599
+ cookies = await loadCookiesFromPath(options.cookiePath, "twitter.com");
2600
+ }
2601
+ if (!cookies["twid"]) {
2602
+ cookies = await loadCookiesFromPath(options.cookiePath);
2603
+ }
2604
+ const twid = cookies["twid"];
2605
+ if (twid) {
2606
+ const decoded = decodeURIComponent(twid).replace(/^"|"$/g, "").trim();
2607
+ const rawId = decoded.startsWith("u=") ? decoded.slice(2) : decoded.includes("=") ? decoded.split("=").pop()?.trim() : decoded;
2608
+ if (rawId && /^[a-zA-Z0-9_-]+$/.test(rawId)) {
2609
+ return rawId;
2610
+ }
2611
+ }
2612
+ }
2613
+ throw new Error("Authentication required for X/Twitter likes history: missing 'twid' cookie");
2614
+ }
2196
2615
  await this.ensureGuestToken(options);
2197
2616
  const variables = JSON.stringify({
2198
2617
  screen_name: username,
@@ -2204,7 +2623,9 @@ var XExtractor = class {
2204
2623
  const res = await this.httpClient.fetchJson(url, {
2205
2624
  cookiePath: options.cookiePath,
2206
2625
  cookieDomain: "x.com",
2207
- signal: options.signal
2626
+ signal: options.signal,
2627
+ retries: 0,
2628
+ timeoutMs: 4e3
2208
2629
  });
2209
2630
  const restId = res.data?.user?.result?.rest_id;
2210
2631
  if (restId) return restId;
@@ -2212,7 +2633,11 @@ var XExtractor = class {
2212
2633
  }
2213
2634
  const syndiUrl = `https://cdn.syndication.twimg.com/widgets/followbutton/info.json?screen_names=${encodeURIComponent(username)}`;
2214
2635
  try {
2215
- const res = await this.httpClient.fetchJson(syndiUrl, { signal: options.signal });
2636
+ const res = await this.httpClient.fetchJson(syndiUrl, {
2637
+ signal: options.signal,
2638
+ retries: 0,
2639
+ timeoutMs: 3e3
2640
+ });
2216
2641
  if (res[0]?.id) return String(res[0].id);
2217
2642
  } catch {
2218
2643
  }
@@ -2221,13 +2646,28 @@ var XExtractor = class {
2221
2646
  async *extract(profile, options) {
2222
2647
  const username = profile.username;
2223
2648
  const subDir = options.subDir;
2649
+ const isLikes = username === "i_history_likes" || profile.cleanUrl?.endsWith("/likes");
2650
+ const authHeaders = {};
2651
+ if (options.cookiePath) {
2652
+ let cookies = await loadCookiesFromPath(options.cookiePath, "x.com");
2653
+ if (!cookies["ct0"] && !cookies["auth_token"]) {
2654
+ const twCookies = await loadCookiesFromPath(options.cookiePath, "twitter.com");
2655
+ cookies = { ...twCookies, ...cookies };
2656
+ }
2657
+ if (cookies["ct0"]) {
2658
+ authHeaders["x-csrf-token"] = cookies["ct0"];
2659
+ }
2660
+ if (cookies["auth_token"]) {
2661
+ authHeaders["x-twitter-auth-type"] = "OAuth2Session";
2662
+ }
2663
+ }
2224
2664
  options.onProgress?.({
2225
- stage: "Profile",
2665
+ stage: isLikes ? "Likes" : "Profile",
2226
2666
  found: 0,
2227
- message: `Fetching X profile @${username}...`
2667
+ message: isLikes ? "Fetching liked posts from X..." : `Fetching X profile @${username}...`
2228
2668
  });
2229
2669
  const userId = await this.fetchUserRestId(username, options);
2230
- const endpoint = "https://x.com/i/api/graphql/jCRhbOzdgOHp6u9H4g2tEg/UserMedia";
2670
+ const endpoint = isLikes ? "https://x.com/i/api/graphql/TGEKkJG_meudeaFcqaxM-Q/Likes" : "https://x.com/i/api/graphql/jCRhbOzdgOHp6u9H4g2tEg/UserMedia";
2231
2671
  let cursor;
2232
2672
  let hasMore = true;
2233
2673
  let found = 0;
@@ -2251,11 +2691,15 @@ var XExtractor = class {
2251
2691
  res = await this.httpClient.fetchJson(url, {
2252
2692
  cookiePath: options.cookiePath,
2253
2693
  cookieDomain: "x.com",
2254
- signal: options.signal
2694
+ signal: options.signal,
2695
+ headers: authHeaders
2255
2696
  });
2256
2697
  } catch (err) {
2257
2698
  break;
2258
2699
  }
2700
+ if (res?.errors?.length && !res?.data?.user?.result) {
2701
+ throw new Error(`X/Twitter API error: ${res.errors[0]?.message || "GraphQL query failed"}`);
2702
+ }
2259
2703
  const instructions = res?.data?.user?.result?.timeline_v2?.timeline?.instructions || [];
2260
2704
  let newCursor;
2261
2705
  let pageItemCount = 0;
@@ -2268,6 +2712,9 @@ var XExtractor = class {
2268
2712
  }
2269
2713
  const tweetContent = entry.content?.itemContent?.tweet_results?.result;
2270
2714
  if (tweetContent) {
2715
+ if (shouldSkipTweetReply(tweetContent, userId, isLikes ? "likes" : "timeline")) {
2716
+ continue;
2717
+ }
2271
2718
  const mediaItems = parseTweetMediaEntities(tweetContent, username);
2272
2719
  for (const item of mediaItems) {
2273
2720
  if (subDir === "Photos" && item.type !== "photo") continue;
@@ -2296,16 +2743,86 @@ var XExtractor = class {
2296
2743
 
2297
2744
  // src/lib/engines/extractors/tiktok.ts
2298
2745
  function parseTikTokRehydrationJson(html) {
2299
- const match = html.match(
2746
+ const universalMatch = html.match(
2300
2747
  /<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>([\s\S]*?)<\/script>/
2301
2748
  );
2302
- if (!match) return null;
2303
- try {
2304
- const raw = JSON.parse(match[1]);
2305
- return raw?.["__DEFAULT_SCOPE__"];
2306
- } catch {
2307
- return null;
2749
+ if (universalMatch) {
2750
+ try {
2751
+ const raw = JSON.parse(universalMatch[1]);
2752
+ const scope = raw?.["__DEFAULT_SCOPE__"] || raw;
2753
+ if (scope) return scope;
2754
+ } catch {
2755
+ }
2308
2756
  }
2757
+ const sigiTagMatch = html.match(
2758
+ /<script id="SIGI_STATE"[^>]*>([\s\S]*?)<\/script>/
2759
+ );
2760
+ if (sigiTagMatch) {
2761
+ try {
2762
+ const raw = JSON.parse(sigiTagMatch[1]);
2763
+ const user = Object.values(raw?.UserModule?.users || {})[0];
2764
+ const items = Object.values(raw?.ItemModule || {});
2765
+ return {
2766
+ "webapp.user-detail": {
2767
+ userInfo: { user },
2768
+ itemList: items
2769
+ },
2770
+ SIGI_STATE: raw
2771
+ };
2772
+ } catch {
2773
+ }
2774
+ }
2775
+ const sigiWindowMatch = html.match(
2776
+ /(?:window\[['"]SIGI_STATE['"]\]|SIGI_STATE)\s*=\s*(\{[\s\S]*?\})\s*;/i
2777
+ );
2778
+ if (sigiWindowMatch) {
2779
+ try {
2780
+ const raw = JSON.parse(sigiWindowMatch[1]);
2781
+ const user = Object.values(raw?.UserModule?.users || {})[0];
2782
+ const items = Object.values(raw?.ItemModule || {});
2783
+ return {
2784
+ "webapp.user-detail": {
2785
+ userInfo: { user },
2786
+ itemList: items
2787
+ },
2788
+ SIGI_STATE: raw
2789
+ };
2790
+ } catch {
2791
+ }
2792
+ }
2793
+ const nextDataMatch = html.match(
2794
+ /<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/
2795
+ );
2796
+ if (nextDataMatch) {
2797
+ try {
2798
+ const raw = JSON.parse(nextDataMatch[1]);
2799
+ const pageProps = raw?.props?.pageProps;
2800
+ if (pageProps) {
2801
+ return {
2802
+ "webapp.user-detail": {
2803
+ userInfo: pageProps.userInfo,
2804
+ itemList: pageProps.items || pageProps.itemList || []
2805
+ },
2806
+ __NEXT_DATA__: raw
2807
+ };
2808
+ }
2809
+ } catch {
2810
+ }
2811
+ }
2812
+ const secUidMatch = html.match(/(?:secUid|sec_uid)["':=\s]+(MS4wLjABAAAA[\w-]{32,64})/i);
2813
+ if (secUidMatch) {
2814
+ return {
2815
+ "webapp.user-detail": {
2816
+ userInfo: {
2817
+ user: {
2818
+ secUid: secUidMatch[1]
2819
+ }
2820
+ },
2821
+ itemList: []
2822
+ }
2823
+ };
2824
+ }
2825
+ return null;
2309
2826
  }
2310
2827
  function parseTikTokItem(item, username) {
2311
2828
  const items = [];
@@ -2368,7 +2885,9 @@ var TikTokExtractor = class {
2368
2885
  const html = await this.httpClient.fetchText(profileUrl, {
2369
2886
  cookiePath: options.cookiePath,
2370
2887
  cookieDomain: "tiktok.com",
2371
- signal: options.signal
2888
+ signal: options.signal,
2889
+ retries: 0,
2890
+ timeoutMs: 4e3
2372
2891
  });
2373
2892
  const scope = parseTikTokRehydrationJson(html);
2374
2893
  const userDetail = scope?.["webapp.user-detail"];
@@ -2400,13 +2919,28 @@ var TikTokExtractor = class {
2400
2919
  if (options.signal?.aborted) break;
2401
2920
  if (visitedCursors.has(cursor)) break;
2402
2921
  visitedCursors.add(cursor);
2403
- const apiUrl = `https://www.tiktok.com/api/post/item_list/?secUid=${encodeURIComponent(secUid)}&count=30&cursor=${cursor}&post_item_list_request_type=0`;
2922
+ const deviceId = `${Math.floor(725e16 + Math.random() * 75e15)}`;
2923
+ const queryParams = new URLSearchParams({
2924
+ aid: "1988",
2925
+ app_name: "tiktok_web",
2926
+ device_platform: "web_pc",
2927
+ channel: "tiktok_web",
2928
+ device_id: deviceId,
2929
+ secUid,
2930
+ count: "30",
2931
+ cursor,
2932
+ post_item_list_request_type: "0"
2933
+ });
2934
+ const apiUrl = `https://www.tiktok.com/api/post/item_list/?${queryParams.toString()}`;
2404
2935
  let res;
2405
2936
  try {
2406
2937
  res = await this.httpClient.fetchJson(apiUrl, {
2407
2938
  cookiePath: options.cookiePath,
2408
2939
  cookieDomain: "tiktok.com",
2409
- signal: options.signal
2940
+ signal: options.signal,
2941
+ headers: {
2942
+ "Referer": profileUrl
2943
+ }
2410
2944
  });
2411
2945
  } catch {
2412
2946
  break;
@@ -2435,54 +2969,95 @@ var TikTokExtractor = class {
2435
2969
  };
2436
2970
 
2437
2971
  // src/lib/engines/extractors/facebook.ts
2972
+ function decodeEscapedUrl(raw) {
2973
+ const sanitized = raw.replace(/[\r\n\t]/g, "").trim();
2974
+ try {
2975
+ return JSON.parse(`"${sanitized.replace(/"/g, '\\"')}"`);
2976
+ } catch {
2977
+ return sanitized.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/\\(["\\\/bfnrt])/g, (_, ch) => {
2978
+ const escapes = {
2979
+ '"': '"',
2980
+ "\\": "\\",
2981
+ "/": "/",
2982
+ b: "\b",
2983
+ f: "\f",
2984
+ n: "\n",
2985
+ r: "\r",
2986
+ t: " "
2987
+ };
2988
+ return escapes[ch] || ch;
2989
+ });
2990
+ }
2991
+ }
2438
2992
  function extractFacebookMediaFromHtml(html, username) {
2439
2993
  const items = [];
2440
2994
  const seenUrls = /* @__PURE__ */ new Set();
2441
- const hdMatch = html.match(/"browser_native_hd_url":"([^"]+)"/);
2442
- const sdMatch = html.match(/"browser_native_sd_url":"([^"]+)"/);
2443
- const rawVideoUrl = hdMatch?.[1] || sdMatch?.[1];
2444
- if (rawVideoUrl) {
2445
- try {
2446
- const videoUrl = JSON.parse(`"${rawVideoUrl}"`);
2447
- if (!seenUrls.has(videoUrl)) {
2995
+ const videoPatterns = [
2996
+ /"browser_native_hd_url":"([^"]+)"/g,
2997
+ /"browser_native_sd_url":"([^"]+)"/g,
2998
+ /"playable_url_quality_hd":"([^"]+)"/g,
2999
+ /"playable_url":"([^"]+)"/g,
3000
+ /<video[^>]+src=["'](https?:[^"']+)["']/gi
3001
+ ];
3002
+ let videoIndex = 1;
3003
+ for (const regex of videoPatterns) {
3004
+ let vMatch;
3005
+ while ((vMatch = regex.exec(html)) !== null) {
3006
+ const rawUrl = vMatch[1];
3007
+ const videoUrl = decodeEscapedUrl(rawUrl).replace(/&amp;/g, "&");
3008
+ if (videoUrl.startsWith("http") && !seenUrls.has(videoUrl)) {
2448
3009
  seenUrls.add(videoUrl);
2449
3010
  items.push({
2450
- id: `fb_video_${Date.now()}`,
3011
+ id: `fb_video_${videoIndex}`,
2451
3012
  url: videoUrl,
2452
- filename: `${username}_video_${Date.now()}`,
3013
+ filename: `${username}_video_${videoIndex}`,
2453
3014
  extension: "mp4",
2454
3015
  type: "video"
2455
3016
  });
3017
+ videoIndex++;
2456
3018
  }
2457
- } catch {
2458
3019
  }
2459
3020
  }
2460
- const photoRegex = /,"image":\{"uri":"([^"]+)"/g;
3021
+ const photoRegex = /,"(?:image|viewer_image|full_size_image|display_image)":\{"uri":"([^"]+)"/g;
2461
3022
  let match;
2462
3023
  let photoIndex = 1;
2463
3024
  while ((match = photoRegex.exec(html)) !== null) {
2464
- try {
2465
- const rawUrl = match[1];
2466
- const photoUrl = JSON.parse(`"${rawUrl}"`);
2467
- if (!seenUrls.has(photoUrl)) {
2468
- seenUrls.add(photoUrl);
2469
- items.push({
2470
- id: `fb_photo_${photoIndex}`,
2471
- url: photoUrl,
2472
- filename: `${username}_photo_${photoIndex}`,
2473
- extension: "jpg",
2474
- type: "photo"
2475
- });
2476
- photoIndex++;
2477
- }
2478
- } catch {
3025
+ const rawUrl = match[1];
3026
+ const photoUrl = decodeEscapedUrl(rawUrl).replace(/&amp;/g, "&");
3027
+ if (!seenUrls.has(photoUrl)) {
3028
+ seenUrls.add(photoUrl);
3029
+ items.push({
3030
+ id: `fb_photo_${photoIndex}`,
3031
+ url: photoUrl,
3032
+ filename: `${username}_photo_${photoIndex}`,
3033
+ extension: "jpg",
3034
+ type: "photo"
3035
+ });
3036
+ photoIndex++;
3037
+ }
3038
+ }
3039
+ const mbasicImgRegex = /<img[^>]+src=["'](https:\/\/[^"'\s]*scontent[^"'\s]*\.(?:jpg|png|webp)[^"'\s]*)["']/gi;
3040
+ let mMatch;
3041
+ while ((mMatch = mbasicImgRegex.exec(html)) !== null) {
3042
+ const rawUrl = mMatch[1];
3043
+ const photoUrl = decodeEscapedUrl(rawUrl).replace(/&amp;/g, "&");
3044
+ if (!seenUrls.has(photoUrl)) {
3045
+ seenUrls.add(photoUrl);
3046
+ items.push({
3047
+ id: `fb_photo_${photoIndex}`,
3048
+ url: photoUrl,
3049
+ filename: `${username}_photo_${photoIndex}`,
3050
+ extension: "jpg",
3051
+ type: "photo"
3052
+ });
3053
+ photoIndex++;
2479
3054
  }
2480
3055
  }
2481
3056
  if (items.length === 0) {
2482
- const scontentRegex = /https:\/\/[^"'\s]*scontent[^"'\s]*\.(?:jpg|png|webp)[^"'\s]*/gi;
3057
+ const scontentRegex = /https:\/\/[^"'\s<>]*scontent[^"'\s<>]*\.(?:jpg|png|webp)[^"'\s<>]*/gi;
2483
3058
  let sMatch;
2484
3059
  while ((sMatch = scontentRegex.exec(html)) !== null) {
2485
- const cleanUrl = sMatch[0].replace(/\\/g, "");
3060
+ const cleanUrl = sMatch[0].replace(/\\/g, "").replace(/&amp;/g, "&");
2486
3061
  if (!seenUrls.has(cleanUrl)) {
2487
3062
  seenUrls.add(cleanUrl);
2488
3063
  items.push({
@@ -2498,6 +3073,59 @@ function extractFacebookMediaFromHtml(html, username) {
2498
3073
  }
2499
3074
  return items;
2500
3075
  }
3076
+ function extractFacebookPaginationCursor(html) {
3077
+ const jsonCursorMatch = html.match(/"end_cursor":"([^"]+)"/) || html.match(/"cursor":"([^"]+)"/);
3078
+ if (jsonCursorMatch && jsonCursorMatch[1]) {
3079
+ return jsonCursorMatch[1];
3080
+ }
3081
+ const linkCursorMatch = html.match(/[?&](?:cursor|after)=([^"'\s&<>]+)/i);
3082
+ if (linkCursorMatch && linkCursorMatch[1]) {
3083
+ try {
3084
+ return decodeURIComponent(linkCursorMatch[1]);
3085
+ } catch {
3086
+ return linkCursorMatch[1];
3087
+ }
3088
+ }
3089
+ return null;
3090
+ }
3091
+ function resolveFacebookUrl(href, currentUrl) {
3092
+ const cleanHref = href.replace(/\\/g, "").replace(/&amp;/g, "&");
3093
+ try {
3094
+ return new URL(cleanHref, currentUrl).toString();
3095
+ } catch {
3096
+ return cleanHref;
3097
+ }
3098
+ }
3099
+ function extractFacebookNextPageUrl(html, currentUrl) {
3100
+ const moreItemMatch = html.match(/<div[^>]*id=["'](?:m_more_item|m_more_posts)["'][^>]*>\s*<a[^>]+href=["']([^"']+)["']/i);
3101
+ if (moreItemMatch && moreItemMatch[1]) {
3102
+ return resolveFacebookUrl(moreItemMatch[1], currentUrl);
3103
+ }
3104
+ const cursorLabelMatch = html.match(/<a[^>]+href=["']([^"']*(?:cursor=|after=)[^"']+)["'][^>]*>[\s\S]*?(?:See [Mm]ore|More [Pp]hotos|More|Next|Older [Pp]osts)[\s\S]*?<\/a>/i);
3105
+ if (cursorLabelMatch && cursorLabelMatch[1]) {
3106
+ return resolveFacebookUrl(cursorLabelMatch[1], currentUrl);
3107
+ }
3108
+ const genericCursorMatch = html.match(/<a[^>]+href=["']([^"']*(?:[?&]|&amp;)(?:cursor|after)=[^"']+)["']/i);
3109
+ if (genericCursorMatch && genericCursorMatch[1]) {
3110
+ return resolveFacebookUrl(genericCursorMatch[1], currentUrl);
3111
+ }
3112
+ const cursor = extractFacebookPaginationCursor(html);
3113
+ if (cursor) {
3114
+ try {
3115
+ const u = new URL(currentUrl);
3116
+ if (u.searchParams.get("cursor") !== cursor) {
3117
+ u.searchParams.set("cursor", cursor);
3118
+ return u.toString();
3119
+ }
3120
+ } catch {
3121
+ if (!currentUrl.includes(cursor)) {
3122
+ const sep = currentUrl.includes("?") ? "&" : "?";
3123
+ return `${currentUrl}${sep}cursor=${encodeURIComponent(cursor)}`;
3124
+ }
3125
+ }
3126
+ }
3127
+ return null;
3128
+ }
2501
3129
  var FacebookExtractor = class {
2502
3130
  platform = "facebook";
2503
3131
  httpClient;
@@ -2514,24 +3142,120 @@ var FacebookExtractor = class {
2514
3142
  found: 0,
2515
3143
  message: `Fetching Facebook profile @${username}...`
2516
3144
  });
2517
- const targetUrl = subDir === "Photos" ? `https://www.facebook.com/${encodeURIComponent(username)}/photos` : `https://www.facebook.com/${encodeURIComponent(username)}/videos`;
2518
- const html = await this.httpClient.fetchText(targetUrl, {
2519
- cookiePath: options.cookiePath,
2520
- cookieDomain: "facebook.com",
2521
- signal: options.signal
2522
- });
2523
- const items = extractFacebookMediaFromHtml(html, username);
2524
- let found = 0;
2525
- for (const item of items) {
2526
- if (subDir === "Photos" && item.type !== "photo") continue;
2527
- if (subDir === "Videos" && item.type !== "video") continue;
2528
- found++;
3145
+ const targetPath = subDir === "Photos" ? "photos" : "videos";
3146
+ const desktopUrl = `https://www.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3147
+ const mbasicUrl = `https://mbasic.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3148
+ const mobileUrl = `https://m.facebook.com/${encodeURIComponent(username)}/${targetPath}`;
3149
+ let currentUrl = desktopUrl;
3150
+ let html = "";
3151
+ try {
3152
+ html = await this.httpClient.fetchText(desktopUrl, {
3153
+ cookiePath: options.cookiePath,
3154
+ cookieDomain: "facebook.com",
3155
+ signal: options.signal,
3156
+ retries: 0,
3157
+ timeoutMs: 4e3
3158
+ });
3159
+ } catch {
3160
+ html = "";
3161
+ }
3162
+ let initialItems = extractFacebookMediaFromHtml(html, username);
3163
+ if (initialItems.length === 0) {
2529
3164
  options.onProgress?.({
2530
- stage: subDir,
2531
- found,
2532
- message: `Found item: ${item.filename}`
3165
+ stage: "Profile",
3166
+ found: 0,
3167
+ message: `Desktop yielded 0 items. Falling back to mbasic.facebook.com for @${username}...`
2533
3168
  });
2534
- yield item;
3169
+ try {
3170
+ const mbasicHtml = await this.httpClient.fetchText(mbasicUrl, {
3171
+ cookiePath: options.cookiePath,
3172
+ cookieDomain: "facebook.com",
3173
+ signal: options.signal,
3174
+ retries: 1,
3175
+ timeoutMs: 5e3,
3176
+ headers: {
3177
+ "Referer": "https://mbasic.facebook.com/"
3178
+ }
3179
+ });
3180
+ const mbasicItems = extractFacebookMediaFromHtml(mbasicHtml, username);
3181
+ if (mbasicItems.length > 0 || mbasicHtml.length > 200) {
3182
+ html = mbasicHtml;
3183
+ currentUrl = mbasicUrl;
3184
+ initialItems = mbasicItems;
3185
+ }
3186
+ } catch {
3187
+ }
3188
+ if (initialItems.length === 0) {
3189
+ try {
3190
+ const mobileHtml = await this.httpClient.fetchText(mobileUrl, {
3191
+ cookiePath: options.cookiePath,
3192
+ cookieDomain: "facebook.com",
3193
+ signal: options.signal,
3194
+ retries: 0,
3195
+ timeoutMs: 5e3,
3196
+ headers: {
3197
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
3198
+ "Referer": "https://m.facebook.com/"
3199
+ }
3200
+ });
3201
+ const mobileItems = extractFacebookMediaFromHtml(mobileHtml, username);
3202
+ if (mobileItems.length > 0) {
3203
+ html = mobileHtml;
3204
+ currentUrl = mobileUrl;
3205
+ initialItems = mobileItems;
3206
+ }
3207
+ } catch {
3208
+ }
3209
+ }
3210
+ }
3211
+ const seenUrls = /* @__PURE__ */ new Set();
3212
+ let found = 0;
3213
+ let page = 1;
3214
+ const maxPages = 5;
3215
+ while (true) {
3216
+ const items = extractFacebookMediaFromHtml(html, username);
3217
+ let pageNewCount = 0;
3218
+ for (const item of items) {
3219
+ if (subDir === "Photos" && item.type !== "photo") continue;
3220
+ if (subDir === "Videos" && item.type !== "video") continue;
3221
+ if (seenUrls.has(item.url)) continue;
3222
+ seenUrls.add(item.url);
3223
+ found++;
3224
+ pageNewCount++;
3225
+ options.onProgress?.({
3226
+ stage: subDir,
3227
+ found,
3228
+ message: `Found item: ${item.filename}`
3229
+ });
3230
+ yield item;
3231
+ }
3232
+ if (page >= maxPages || options.signal?.aborted) {
3233
+ break;
3234
+ }
3235
+ const nextUrl = extractFacebookNextPageUrl(html, currentUrl);
3236
+ if (!nextUrl || nextUrl === currentUrl) {
3237
+ break;
3238
+ }
3239
+ if (pageNewCount === 0 && page > 1) {
3240
+ break;
3241
+ }
3242
+ await sleep(1e3, 2e3, options.signal);
3243
+ try {
3244
+ currentUrl = nextUrl;
3245
+ html = await this.httpClient.fetchText(currentUrl, {
3246
+ cookiePath: options.cookiePath,
3247
+ cookieDomain: "facebook.com",
3248
+ signal: options.signal,
3249
+ retries: 1,
3250
+ timeoutMs: 5e3,
3251
+ headers: {
3252
+ "Referer": currentUrl.includes("mbasic") ? "https://mbasic.facebook.com/" : "https://www.facebook.com/"
3253
+ }
3254
+ });
3255
+ page++;
3256
+ } catch {
3257
+ break;
3258
+ }
2535
3259
  }
2536
3260
  }
2537
3261
  };
@@ -2559,17 +3283,48 @@ import path5 from "path";
2559
3283
  import { Readable } from "stream";
2560
3284
  import { pipeline } from "stream/promises";
2561
3285
  var ARCHIVE_FILENAME = ".open-nami-archive.json";
3286
+ var YTDLP_ARCHIVE_FILENAME = "archive_yt-dlp.txt";
3287
+ var TEXT_ARCHIVE_FILENAME = "archive.txt";
2562
3288
  async function loadArchive(destDir) {
2563
- const archivePath = path5.join(destDir, ARCHIVE_FILENAME);
3289
+ const archive = /* @__PURE__ */ new Set();
3290
+ const jsonPath = path5.join(destDir, ARCHIVE_FILENAME);
2564
3291
  try {
2565
- const raw = await fsPromises2.readFile(archivePath, "utf-8");
3292
+ const raw = await fsPromises2.readFile(jsonPath, "utf-8");
2566
3293
  const parsed = JSON.parse(raw);
2567
3294
  if (Array.isArray(parsed)) {
2568
- return new Set(parsed);
3295
+ for (const id of parsed) {
3296
+ if (id) archive.add(String(id));
3297
+ }
3298
+ }
3299
+ } catch {
3300
+ }
3301
+ const ytdlpPath = path5.join(destDir, YTDLP_ARCHIVE_FILENAME);
3302
+ try {
3303
+ const raw = await fsPromises2.readFile(ytdlpPath, "utf-8");
3304
+ const lines = raw.split(/\r?\n/);
3305
+ for (const rawLine of lines) {
3306
+ const line = rawLine.trim();
3307
+ if (!line || line.startsWith("#")) continue;
3308
+ const parts = line.split(/\s+/);
3309
+ const id = parts[parts.length - 1];
3310
+ if (id) archive.add(id);
2569
3311
  }
2570
3312
  } catch {
2571
3313
  }
2572
- return /* @__PURE__ */ new Set();
3314
+ const textPath = path5.join(destDir, TEXT_ARCHIVE_FILENAME);
3315
+ try {
3316
+ const raw = await fsPromises2.readFile(textPath, "utf-8");
3317
+ const lines = raw.split(/\r?\n/);
3318
+ for (const rawLine of lines) {
3319
+ const line = rawLine.trim();
3320
+ if (!line || line.startsWith("#")) continue;
3321
+ const parts = line.split(/\s+/);
3322
+ const id = parts[parts.length - 1];
3323
+ if (id) archive.add(id);
3324
+ }
3325
+ } catch {
3326
+ }
3327
+ return archive;
2573
3328
  }
2574
3329
  async function saveArchive(destDir, archive) {
2575
3330
  const archivePath = path5.join(destDir, ARCHIVE_FILENAME);
@@ -2579,6 +3334,18 @@ async function saveArchive(destDir, archive) {
2579
3334
  await fsPromises2.rename(tmpPath, archivePath);
2580
3335
  } catch {
2581
3336
  }
3337
+ const textPaths = [
3338
+ path5.join(destDir, YTDLP_ARCHIVE_FILENAME),
3339
+ path5.join(destDir, TEXT_ARCHIVE_FILENAME)
3340
+ ];
3341
+ const content = [...archive].map((id) => `${id}
3342
+ `).join("");
3343
+ for (const tPath of textPaths) {
3344
+ try {
3345
+ await fsPromises2.writeFile(tPath, content, "utf-8");
3346
+ } catch {
3347
+ }
3348
+ }
2582
3349
  }
2583
3350
  function sanitizeFilename(rawFilename, fallbackId, ext) {
2584
3351
  const cleanExt = ext.replace(/^\./, "");
@@ -2595,90 +3362,138 @@ function sanitizeFilename(rawFilename, fallbackId, ext) {
2595
3362
  async function downloadMediaStream(items, options) {
2596
3363
  const { jobName, destDir, signal, onProgress } = options;
2597
3364
  const httpClient = options.httpClient ?? new HttpClient();
3365
+ const concurrency = Math.max(1, options.concurrency ?? 3);
2598
3366
  await fsPromises2.mkdir(destDir, { recursive: true });
2599
3367
  const archive = await loadArchive(destDir);
2600
3368
  let downloaded = 0;
2601
3369
  let skipped = 0;
2602
3370
  const errors = [];
2603
- for await (const item of items) {
2604
- if (signal?.aborted) {
2605
- break;
2606
- }
2607
- const ext = item.extension.replace(/^\./, "") || (item.type === "video" ? "mp4" : "jpg");
2608
- const finalFilename = sanitizeFilename(item.filename, item.id, ext);
2609
- const finalPath = path5.join(destDir, finalFilename);
2610
- if (archive.has(item.id) && fs6.existsSync(finalPath)) {
2611
- skipped++;
2612
- onProgress?.({
2613
- job: jobName,
2614
- downloadedCount: downloaded,
2615
- skippedCount: skipped,
2616
- currentFile: finalFilename,
2617
- statusText: `Skipping already downloaded: ${finalFilename}`
2618
- });
2619
- continue;
2620
- }
2621
- onProgress?.({
2622
- job: jobName,
2623
- downloadedCount: downloaded,
2624
- skippedCount: skipped,
2625
- currentFile: finalFilename,
2626
- statusText: `Downloading: ${finalFilename}`
2627
- });
2628
- const tmpPath = `${finalPath}.download`;
2629
- try {
2630
- const reqHeaders = {};
2631
- if (options.referer) {
2632
- reqHeaders["Referer"] = options.referer;
2633
- }
2634
- const response = await httpClient.request(item.url, {
2635
- signal,
2636
- cookiePath: options.cookiePath,
2637
- headers: reqHeaders
2638
- });
2639
- if (!response.ok || !response.body) {
2640
- throw new Error(`HTTP ${response.status} (${response.statusText}) for ${item.url}`);
3371
+ const iterator = items[Symbol.asyncIterator]();
3372
+ let queueLock = Promise.resolve();
3373
+ let isDone = false;
3374
+ let consecutiveErrors = 0;
3375
+ const MAX_CONSECUTIVE_ERRORS = 5;
3376
+ function getNextItem() {
3377
+ const nextItem = queueLock.then(() => iterator.next());
3378
+ queueLock = nextItem.then(
3379
+ () => {
3380
+ },
3381
+ () => {
2641
3382
  }
2642
- const fileStream = fs6.createWriteStream(tmpPath);
2643
- await pipeline(Readable.fromWeb(response.body), fileStream, { signal });
2644
- if (item.date) {
2645
- try {
2646
- const mtime = new Date(item.date * 1e3);
2647
- await fsPromises2.utimes(tmpPath, mtime, mtime);
2648
- } catch {
3383
+ );
3384
+ return nextItem;
3385
+ }
3386
+ async function downloadWorker() {
3387
+ while (!isDone && !signal?.aborted) {
3388
+ let item;
3389
+ try {
3390
+ const next = await getNextItem();
3391
+ if (next.done) {
3392
+ isDone = true;
3393
+ break;
3394
+ }
3395
+ consecutiveErrors = 0;
3396
+ item = next.value;
3397
+ } catch (err) {
3398
+ consecutiveErrors++;
3399
+ const errMsg = err instanceof Error ? err.message : String(err);
3400
+ errors.push(`Extraction stream error: ${errMsg}`);
3401
+ const cleanErr = errMsg.includes("429") ? "rate limited by platform (HTTP 429)" : errMsg.length > 50 ? `${errMsg.slice(0, 47)}\u2026` : errMsg;
3402
+ onProgress?.({
3403
+ job: jobName,
3404
+ downloadedCount: downloaded,
3405
+ skippedCount: skipped,
3406
+ currentFile: "",
3407
+ statusText: `Extractor error: ${cleanErr}`
3408
+ });
3409
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS || signal?.aborted) {
3410
+ isDone = true;
3411
+ break;
2649
3412
  }
3413
+ continue;
3414
+ }
3415
+ const ext = item.extension.replace(/^\./, "") || (item.type === "video" ? "mp4" : "jpg");
3416
+ const finalFilename = sanitizeFilename(item.filename, item.id, ext);
3417
+ const finalPath = path5.join(destDir, finalFilename);
3418
+ if (archive.has(item.id) && fs6.existsSync(finalPath)) {
3419
+ skipped++;
3420
+ onProgress?.({
3421
+ job: jobName,
3422
+ downloadedCount: downloaded,
3423
+ skippedCount: skipped,
3424
+ currentFile: finalFilename,
3425
+ statusText: `Skipping already downloaded: ${finalFilename}`
3426
+ });
3427
+ continue;
2650
3428
  }
2651
- await fsPromises2.rename(tmpPath, finalPath);
2652
- archive.add(item.id);
2653
- downloaded++;
2654
3429
  onProgress?.({
2655
3430
  job: jobName,
2656
3431
  downloadedCount: downloaded,
2657
3432
  skippedCount: skipped,
2658
3433
  currentFile: finalFilename,
2659
- statusText: `Saved: ${finalFilename}`
3434
+ statusText: `Downloading: ${finalFilename}`
2660
3435
  });
2661
- } catch (err) {
3436
+ const tmpPath = `${finalPath}.download`;
2662
3437
  try {
2663
- if (fs6.existsSync(tmpPath)) {
2664
- await fsPromises2.unlink(tmpPath);
3438
+ const reqHeaders = {};
3439
+ if (options.referer) {
3440
+ reqHeaders["Referer"] = options.referer;
2665
3441
  }
2666
- } catch {
2667
- }
2668
- if (signal?.aborted) {
2669
- break;
3442
+ const response = await httpClient.request(item.url, {
3443
+ signal,
3444
+ cookiePath: options.cookiePath,
3445
+ headers: reqHeaders
3446
+ });
3447
+ if (!response.ok || !response.body) {
3448
+ try {
3449
+ await response.body?.cancel();
3450
+ } catch {
3451
+ }
3452
+ throw new Error(`HTTP ${response.status} (${response.statusText}) for ${item.url}`);
3453
+ }
3454
+ const fileStream = fs6.createWriteStream(tmpPath);
3455
+ await pipeline(Readable.fromWeb(response.body), fileStream, { signal });
3456
+ if (item.date) {
3457
+ try {
3458
+ const mtime = new Date(item.date * 1e3);
3459
+ await fsPromises2.utimes(tmpPath, mtime, mtime);
3460
+ } catch {
3461
+ }
3462
+ }
3463
+ await fsPromises2.rename(tmpPath, finalPath);
3464
+ archive.add(item.id);
3465
+ downloaded++;
3466
+ onProgress?.({
3467
+ job: jobName,
3468
+ downloadedCount: downloaded,
3469
+ skippedCount: skipped,
3470
+ currentFile: finalFilename,
3471
+ statusText: `Saved: ${finalFilename}`
3472
+ });
3473
+ } catch (err) {
3474
+ try {
3475
+ if (fs6.existsSync(tmpPath)) {
3476
+ await fsPromises2.unlink(tmpPath);
3477
+ }
3478
+ } catch {
3479
+ }
3480
+ if (signal?.aborted) {
3481
+ break;
3482
+ }
3483
+ const errMsg = err instanceof Error ? err.message : String(err);
3484
+ errors.push(`${finalFilename}: ${errMsg}`);
3485
+ onProgress?.({
3486
+ job: jobName,
3487
+ downloadedCount: downloaded,
3488
+ skippedCount: skipped,
3489
+ currentFile: finalFilename,
3490
+ statusText: `Failed: ${finalFilename} (${errMsg})`
3491
+ });
2670
3492
  }
2671
- const errMsg = err instanceof Error ? err.message : String(err);
2672
- errors.push(`${finalFilename}: ${errMsg}`);
2673
- onProgress?.({
2674
- job: jobName,
2675
- downloadedCount: downloaded,
2676
- skippedCount: skipped,
2677
- currentFile: finalFilename,
2678
- statusText: `Failed: ${finalFilename} (${errMsg})`
2679
- });
2680
3493
  }
2681
3494
  }
3495
+ const workers = Array.from({ length: concurrency }, () => downloadWorker());
3496
+ await Promise.all(workers);
2682
3497
  await saveArchive(destDir, archive);
2683
3498
  return { downloaded, skipped, errors };
2684
3499
  }
@@ -2989,8 +3804,8 @@ var GDL_ARCHIVE = "archive_gallery-dl.sqlite3";
2989
3804
  var UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
2990
3805
  var PLATFORM_SLEEP = {
2991
3806
  tiktok: "0.6-1.2",
2992
- instagram: "2.0-2.8",
2993
- facebook: "1.5-2.5"
3807
+ instagram: "1.0-1.8",
3808
+ facebook: "1.0-1.8"
2994
3809
  };
2995
3810
  function buildGalleryDlArgs(options) {
2996
3811
  const { destDir, subDir, platform, cleanUrl, username, cookiePath } = options;
@@ -3030,12 +3845,12 @@ function buildGalleryDlArgs(options) {
3030
3845
  } else if (subDir === "Stories") {
3031
3846
  if (platform === "instagram") {
3032
3847
  targetUrl = `https://www.instagram.com/stories/${encodeURIComponent(username)}/`;
3033
- args.push("--sleep-request", "2.0-3.0");
3848
+ args.push("--sleep-request", "1.0-1.8");
3034
3849
  }
3035
3850
  } else if (subDir === "Highlights") {
3036
3851
  if (platform === "instagram") {
3037
3852
  targetUrl = `https://www.instagram.com/${encodeURIComponent(username)}/highlights/`;
3038
- args.push("--sleep-request", "2.0-3.0");
3853
+ args.push("--sleep-request", "1.0-1.8");
3039
3854
  }
3040
3855
  }
3041
3856
  if (cookiePath) {
@@ -3059,8 +3874,9 @@ async function runGalleryDl(options) {
3059
3874
  let downloadedCount = 0;
3060
3875
  let skippedCount = 0;
3061
3876
  let errorOutput = "";
3877
+ let engineCmdStr = "gallery-dl";
3062
3878
  try {
3063
- await ensureGalleryDl((status) => {
3879
+ engineCmdStr = await ensureGalleryDl((status) => {
3064
3880
  onProgress?.({
3065
3881
  job: subDir,
3066
3882
  downloadedCount: 0,
@@ -3076,9 +3892,15 @@ async function runGalleryDl(options) {
3076
3892
  error: err.message || String(err)
3077
3893
  };
3078
3894
  }
3079
- const engineCmd = resolveEngineCommand("gallery-dl");
3895
+ const engineCmd = engineCmdStr.includes(path8.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("gallery-dl");
3080
3896
  const fullArgs = [...engineCmd.baseArgs, ...args];
3081
3897
  return new Promise((resolve) => {
3898
+ onProgress?.({
3899
+ job: subDir,
3900
+ downloadedCount,
3901
+ skippedCount,
3902
+ statusText: "downloading with gallery-dl\u2026"
3903
+ });
3082
3904
  const child = spawn2(engineCmd.command, fullArgs, {
3083
3905
  windowsHide: true,
3084
3906
  stdio: ["ignore", "pipe", "pipe"],
@@ -3202,8 +4024,9 @@ async function runYtDlp(options) {
3202
4024
  let downloadedCount = 0;
3203
4025
  let skippedCount = 0;
3204
4026
  let errorOutput = "";
4027
+ let engineCmdStr = "yt-dlp";
3205
4028
  try {
3206
- await ensureYtDlp((status) => {
4029
+ engineCmdStr = await ensureYtDlp((status) => {
3207
4030
  onProgress?.({
3208
4031
  job: "Videos",
3209
4032
  downloadedCount: 0,
@@ -3219,9 +4042,15 @@ async function runYtDlp(options) {
3219
4042
  error: err.message || String(err)
3220
4043
  };
3221
4044
  }
3222
- const engineCmd = resolveEngineCommand("yt-dlp");
4045
+ const engineCmd = engineCmdStr.includes(path9.sep) ? { command: engineCmdStr, baseArgs: [] } : resolveEngineCommand("yt-dlp");
3223
4046
  const fullArgs = [...engineCmd.baseArgs, ...args];
3224
4047
  return new Promise((resolve) => {
4048
+ onProgress?.({
4049
+ job: "Videos",
4050
+ downloadedCount,
4051
+ skippedCount,
4052
+ statusText: "downloading with yt-dlp\u2026"
4053
+ });
3225
4054
  const child = spawn3(engineCmd.command, fullArgs, {
3226
4055
  windowsHide: true,
3227
4056
  stdio: ["ignore", "pipe", "pipe"],
@@ -3313,14 +4142,14 @@ function resolveDownloadBaseDir(customBaseDir) {
3313
4142
  if (customBaseDir && customBaseDir.trim()) {
3314
4143
  return path10.resolve(expandPath(customBaseDir.trim()));
3315
4144
  }
3316
- const configDir = loadConfig().downloadDir;
3317
- if (configDir && configDir.trim()) {
3318
- return path10.resolve(expandPath(configDir.trim()));
3319
- }
3320
4145
  const envDir = process.env.OPEN_NAMI_DIR || process.env.NAMI_DIR;
3321
4146
  if (envDir && envDir.trim()) {
3322
4147
  return path10.resolve(expandPath(envDir.trim()));
3323
4148
  }
4149
+ const configDir = loadConfig().downloadDir;
4150
+ if (configDir && configDir.trim()) {
4151
+ return path10.resolve(expandPath(configDir.trim()));
4152
+ }
3324
4153
  if (process.env.NAMI_BASE_DIR && process.env.NAMI_BASE_DIR.trim()) {
3325
4154
  const raw = process.env.NAMI_BASE_DIR.trim();
3326
4155
  return path10.resolve(expandPath(raw.endsWith("downloads") ? raw : path10.join(raw, "downloads")));
@@ -3358,7 +4187,7 @@ async function dispatchDownload(options) {
3358
4187
  jobs.push({ type: "highlights" });
3359
4188
  }
3360
4189
  }
3361
- const runNative = async (subDir) => {
4190
+ const defaultRunNative = async (subDir) => {
3362
4191
  const dest = path10.join(targetDir, subDir);
3363
4192
  try {
3364
4193
  const extractor = getExtractor(profile.platform);
@@ -3391,27 +4220,34 @@ async function dispatchDownload(options) {
3391
4220
  };
3392
4221
  }
3393
4222
  };
3394
- const runGdl = (subDir) => runGalleryDl({
4223
+ const runNative = options.runnerOverrides?.nativeRunner ?? defaultRunNative;
4224
+ const runGdl = options.runnerOverrides?.gdlRunner ?? ((subDir) => runGalleryDl({
3395
4225
  profile,
3396
4226
  targetDir,
3397
4227
  subDir,
3398
4228
  cookiePath: cookieCopy?.filePath,
3399
4229
  signal: options.signal,
3400
4230
  onProgress
3401
- });
3402
- const runYt = () => runYtDlp({
4231
+ }));
4232
+ const runYt = options.runnerOverrides?.ytRunner ?? (() => runYtDlp({
3403
4233
  profile,
3404
4234
  targetDir,
3405
4235
  cookiePath: cookieCopy?.filePath,
3406
4236
  signal: options.signal,
3407
4237
  onProgress
3408
- });
4238
+ }));
3409
4239
  for (const job of jobs) {
3410
4240
  if (options.signal?.aborted) break;
3411
4241
  if (job.type === "photos" || job.type === "stories" || job.type === "highlights") {
3412
4242
  const subDir = job.type === "photos" ? "Photos" : job.type === "stories" ? "Stories" : "Highlights";
3413
4243
  let res = await runNative(subDir);
3414
4244
  if ((!res.success || res.downloaded === 0 && res.skipped === 0) && !options.signal?.aborted) {
4245
+ onProgress?.({
4246
+ job: subDir,
4247
+ downloadedCount: totalDownloaded,
4248
+ skippedCount: totalSkipped,
4249
+ statusText: "switching to gallery-dl engine\u2026"
4250
+ });
3415
4251
  const gdlRes = await runGdl(subDir);
3416
4252
  if (gdlRes.downloaded > 0 || gdlRes.skipped > 0 || gdlRes.success) {
3417
4253
  res = gdlRes;
@@ -3426,12 +4262,24 @@ async function dispatchDownload(options) {
3426
4262
  totalDownloaded += res.downloaded;
3427
4263
  totalSkipped += res.skipped;
3428
4264
  } else if (!options.signal?.aborted) {
4265
+ onProgress?.({
4266
+ job: "Videos",
4267
+ downloadedCount: totalDownloaded,
4268
+ skippedCount: totalSkipped,
4269
+ statusText: "switching to yt-dlp engine\u2026"
4270
+ });
3429
4271
  const fbRes = await runYt();
3430
4272
  if (fbRes.downloaded > 0 || fbRes.skipped > 0 || fbRes.success) {
3431
4273
  totalDownloaded += fbRes.downloaded;
3432
4274
  totalSkipped += fbRes.skipped;
3433
4275
  if (!fbRes.success && fbRes.error) errors.push(`[Videos] ${fbRes.error}`);
3434
4276
  } else if (!options.signal?.aborted) {
4277
+ onProgress?.({
4278
+ job: "Videos",
4279
+ downloadedCount: totalDownloaded,
4280
+ skippedCount: totalSkipped,
4281
+ statusText: "switching to gallery-dl engine\u2026"
4282
+ });
3435
4283
  const gdlRes = await runGdl("Videos");
3436
4284
  totalDownloaded += gdlRes.downloaded;
3437
4285
  totalSkipped += gdlRes.skipped;