supermemory/apps/extension/src/twitter.ts
2025-02-18 20:43:29 -07:00

470 lines
14 KiB
TypeScript

import { getBaseURL } from "@/lib/utils";
import { features } from "./twitter.constants";
const DEBUG = true;
const log = (message: string, ...args: any[]) => {
if (DEBUG) {
console.log("[twitter]", message, ...args);
}
};
const logError = (message: string, error?: any) => {
// Always log errors
console.error(message, error);
};
export const waitForRequiredData = () => {
return new Promise((resolve) => {
const checkData = () => {
chrome.storage.local.get(
["bookmarksApiId", "cookie", "csrf", "auth"],
(result) => {
if (
result.bookmarksApiId &&
result.cookie &&
result.csrf &&
result.auth
) {
log("Got all data needed to fetch bookmarks");
resolve(true);
} else {
setTimeout(checkData, 100); // Check again after 100ms
}
}
);
};
checkData();
});
};
export const handleExportXBookmarks = async () => {
log("Received export request from popup");
// First, create the Twitter tab
const tab = await chrome.tabs.create({
url: "https://x.com/i/bookmarks/all",
});
// Wait for the tab to finish loading
await new Promise((resolve) => {
const checkTab = () => {
if (tab.id) {
chrome.tabs.get(tab.id, (updatedTab) => {
if (updatedTab.status === "complete") {
resolve(true);
} else {
setTimeout(checkTab, 100);
}
});
}
};
checkTab();
});
void chrome.storage.local.set({ attemptingImportCurrently: true });
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const currentTab = tabs[0];
if (currentTab.id) {
try {
await chrome.scripting.executeScript({
target: { tabId: currentTab.id },
files: ["scripts/content.js"],
});
} catch (error) {
console.error("Error injecting content script:", error);
}
}
});
// Wait for required data and start the bookmark export
await waitForRequiredData();
await sendProgressUpdate(0, 200);
await getBookmarks();
return { status: 200 };
};
const getNextCursor = (entries: any[]) => {
const cursorEntry = entries.find((entry) =>
entry.entryId.startsWith("cursor-bottom-")
);
return cursorEntry ? cursorEntry.content.value : null;
};
type MessageType = "IMPORT_PROGRESS_UPDATE" | "IMPORT_COMPLETE";
const sendMessageToTwitterTabs = async (
message: {
type: MessageType;
payload: any;
},
retries = 3
) => {
try {
log(`Sending message of type ${message.type}:`, message.payload);
const tabs = await chrome.tabs.query({
url: ["*://*.twitter.com/*", "*://*.x.com/*"],
});
if (tabs.length === 0) {
if (retries > 0) {
// Wait and retry if no matching tab found
await new Promise((resolve) => setTimeout(resolve, 1000));
return sendMessageToTwitterTabs(message, retries - 1);
}
throw new Error("No Twitter tab found");
}
const messagePromises = tabs.map((tab) => {
if (!tab.id) return Promise.resolve();
return chrome.tabs.sendMessage(tab.id, message).catch((error) => {
logError(`Failed to send message to tab ${tab.id}:`, error);
});
});
await Promise.all(messagePromises);
log(`Message sent to ${tabs.length} tabs`);
} catch (error) {
logError(`Error sending message of type ${message.type}:`, error);
}
};
const sendProgressUpdate = (progress: number, status: number) => {
return sendMessageToTwitterTabs({
type: "IMPORT_PROGRESS_UPDATE",
payload: { progress, status },
});
};
const sendImportComplete = () => {
return sendMessageToTwitterTabs({
type: "IMPORT_COMPLETE",
payload: { success: true },
});
};
const getBookmarks = async (cursor = "", totalImported = 0, allTweets = []) => {
try {
const getSessionData = (): Promise<{
cookie: string;
csrf: string;
auth: string;
bookmarksApiId: string;
}> => {
return new Promise((resolve) => {
const checkData = () => {
chrome.storage.local.get(
["cookie", "csrf", "auth", "bookmarksApiId"],
(result) => {
if (
result.cookie &&
result.csrf &&
result.auth &&
result.bookmarksApiId
) {
resolve({
cookie: result.cookie,
csrf: result.csrf,
auth: result.auth,
bookmarksApiId: result.bookmarksApiId,
});
} else {
setTimeout(checkData, 3000); // Check again after 3 seconds
}
}
);
};
checkData();
});
};
const sessionResult = await getSessionData();
const baseURL = await getBaseURL();
const headers = new Headers();
headers.append("Cookie", sessionResult.cookie);
headers.append("X-Csrf-token", sessionResult.csrf);
headers.append("Authorization", sessionResult.auth);
const variables = {
count: 100,
cursor: cursor,
includePromotedContent: false,
};
const API_URL = `https://x.com/i/api/graphql/${
sessionResult.bookmarksApiId
}/Bookmarks?features=${encodeURIComponent(
JSON.stringify(features)
)}&variables=${encodeURIComponent(JSON.stringify(variables))}` as const;
const response = await fetch(API_URL, {
method: "GET",
headers: headers,
redirect: "follow",
});
if (response.status === 429) {
log("Rate limited, waiting 60 seconds before retrying...");
await sendProgressUpdate(totalImported, 429);
await new Promise((resolve) => setTimeout(resolve, 60000));
return getBookmarks(cursor, totalImported, allTweets);
}
let data = (await response.json()) as any;
// Handle 400 error with missing features
if (response.status === 400 && data.errors?.[0]?.message) {
const errorMsg = data.errors[0].message;
const missingFeatures = errorMsg.match(
/following features cannot be null: (.*?)$/
)?.[1];
if (missingFeatures) {
const featuresList = missingFeatures.split(", ");
const updatedFeatures = { ...features };
featuresList.forEach((feature: string) => {
updatedFeatures[feature as keyof typeof updatedFeatures] = true;
});
// Retry with updated features
const newUrl = `https://x.com/i/api/graphql/${
sessionResult.bookmarksApiId
}/Bookmarks?features=${encodeURIComponent(
JSON.stringify(updatedFeatures)
)}&variables=${encodeURIComponent(JSON.stringify(variables))}`;
const retryResponse = await fetch(newUrl, {
method: "GET",
headers: headers,
redirect: "follow",
});
const retryData = await retryResponse.json();
data = retryData;
}
}
const entries =
data.data?.bookmark_timeline_v2?.timeline?.instructions?.[0]?.entries ||
[];
const tweetEntries = entries.filter((entry: { entryId: string }) =>
entry.entryId.startsWith("tweet-")
);
// Process tweets and create URLs
const tweetUrls = tweetEntries
.map((tweet: any) => {
const tweetId = tweet?.entryId?.split("-")[1];
const username =
tweet?.content?.itemContent?.tweet_results?.result?.core?.user_results
?.result?.legacy?.screen_name;
if (!tweetId) return null;
return `https://x.com/${username || "supermemoryai"}/status/${tweetId}`;
})
.filter(Boolean);
// Send progress update before processing batch
await sendProgressUpdate(totalImported, 102); // 102 = Processing batch
// Send all tweets in parallel
const addRequests = tweetUrls.map((tweetUrl: string) =>
fetch(`${baseURL}/backend/v1/add`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: tweetUrl,
}),
})
);
const results = await Promise.all(addRequests);
// Track different response statuses
const statusCounts = results.reduce((acc: { [key: number]: number }, r) => {
const status = r.status;
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
// Log status counts
Object.entries(statusCounts).forEach(([status, count]) => {
log(`Status ${status}: ${count} tweets`);
});
// If we have any 409s, send that in the progress update
if (statusCounts[409]) {
await sendProgressUpdate(totalImported, 409);
}
// Consider request successful even if some tweets already exist (409)
const failedRequests = results.filter(
(r) => !r.ok && r.status !== 409
).length;
if (failedRequests > 0) {
await sendProgressUpdate(totalImported, 500); // 500 = Some requests failed
}
allTweets = allTweets.concat(tweetEntries);
const newTweetsCount = tweetEntries.length;
totalImported += newTweetsCount;
// Update progress based on total tweets found so far
const estimatedTotalTweets = Math.max(totalImported, 200); // Adjust estimate as we find more
await sendProgressUpdate(
totalImported,
failedRequests > 0 ? 500 : statusCounts[409] ? 409 : 200
);
log(
`Processing batch: ${newTweetsCount} new tweets, total: ${totalImported}`
);
const nextCursor = getNextCursor(entries);
if (nextCursor && newTweetsCount > 0) {
// Add delay between batches to avoid rate limiting
await new Promise((resolve) => setTimeout(resolve, 1500));
return getBookmarks(nextCursor, totalImported, allTweets);
} else {
// Final success update
await sendImportComplete();
chrome.storage.local.set({ bookmarks: allTweets }, () => {
log("Bookmarks stored in local storage");
});
const timestamp = new Date().toISOString();
chrome.storage.local.get(["successful_exports"], (result) => {
const successful_exports = result.successful_exports || [];
successful_exports.push({
timestamp,
tweetCount: allTweets.length,
});
chrome.storage.local.set({ successful_exports }, () => {
log(`Export completed: ${allTweets.length} tweets`);
});
});
void chrome.storage.local.set({ attemptingImportCurrently: false });
// After successful import completion:
window.postMessage(
{ type: "TWITTER_IMPORT_COMPLETE" },
window.location.origin
);
}
} catch (error) {
logError("Error fetching bookmarks:", error);
void chrome.storage.local.set({ attemptingImportCurrently: false });
}
};
export const setupTwitterHeaderListener = () => {
const extraOptions =
chrome.runtime.getManifest().manifest_version === 2
? ["blocking", "requestHeaders"]
: ["requestHeaders"];
// Add extraHeaders only if we're on a Chromium-based browser
if (!navigator.userAgent.includes("Firefox")) {
extraOptions.push("extraHeaders");
}
chrome.webRequest.onBeforeSendHeaders.addListener(
(details) => {
try {
// Validate input
if (!details || !details.url) {
console.error("Invalid details object received");
return;
}
// Check if URL is from Twitter/X
if (
!(
details.url.includes("x.com") || details.url.includes("twitter.com")
)
) {
return;
}
// Ensure requestHeaders exists
if (!details.requestHeaders) {
console.error("No request headers found");
return;
}
// Check if stuff is already stored
chrome.storage.local.get(
["bookmarksApiId", "cookie", "csrf", "auth"],
(result) => {
try {
// Check if the URL matches the pattern for bookmarks API
const bookmarksUrlPattern =
/https:\/\/x\.com\/i\/api\/graphql\/([^/]+)\/Bookmarks\?/;
const match = details.url.match(bookmarksUrlPattern);
if (match?.[1] && !result.bookmarksApiId) {
const bookmarksApiId = match[1];
chrome.storage.local.set({ bookmarksApiId }, () => {
if (chrome.runtime.lastError) {
console.error(
"Error storing bookmarksApiId:",
chrome.runtime.lastError
);
return;
}
console.log(`Stored bookmarksApiId: ${bookmarksApiId}`);
});
}
// Extract headers with error handling
const getHeaderValue = (name: string): string => {
const header = details.requestHeaders?.find(
(h) => h.name.toLowerCase() === name.toLowerCase()
);
return header?.value || "";
};
const auth = getHeaderValue("authorization");
const cookie = getHeaderValue("cookie");
const csrf = getHeaderValue("x-csrf-token");
// Only update storage if values have changed
if (
result.cookie !== cookie ||
result.csrf !== csrf ||
result.auth !== auth
) {
chrome.storage.local.set({ cookie, csrf, auth }, () => {
if (chrome.runtime.lastError) {
console.error(
"Error updating credentials:",
chrome.runtime.lastError
);
return;
}
console.log(
"Updated cookie, csrf, and auth in local storage"
);
});
}
} catch (err) {
console.error("Error processing request headers:", err);
}
}
);
} catch (err) {
console.error("Top level error in onBeforeSendHeaders listener:", err);
}
},
{ urls: ["*://x.com/*", "*://twitter.com/*"] },
extraOptions
);
};