Skip to content

Commit

Permalink
feat: support TikTock
Browse files Browse the repository at this point in the history
  • Loading branch information
kawamataryo committed Jan 5, 2025
1 parent a758b3a commit 3be16f4
Show file tree
Hide file tree
Showing 6 changed files with 155 additions and 2 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "sky-follower-bridge",
"displayName": "Sky Follower Bridge",
"version": "2.3.3",
"version": "2.4.0",
"description": "__MSG_extension_description__",
"author": "kawamataryou",
"scripts": {
Expand Down Expand Up @@ -73,7 +73,8 @@
"https://twitter.com/*",
"https://x.com/*",
"https://www.threads.net/*",
"https://www.instagram.com/*"
"https://www.instagram.com/*",
"https://www.tiktok.com/*"
],
"browser_specific_settings": {
"gecko": {
Expand Down
2 changes: 2 additions & 0 deletions src/contents/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const config: PlasmoCSConfig = {
"https://x.com/*",
"https://www.threads.net/*",
"https://www.instagram.com/*",
"https://www.tiktok.com/*",
],
all_frames: true,
};
Expand Down Expand Up @@ -101,6 +102,7 @@ const App = () => {
.with(SERVICE_TYPE.X, () => "X")
.with(SERVICE_TYPE.THREADS, () => "Threads")
.with(SERVICE_TYPE.INSTAGRAM, () => "Instagram")
.with(SERVICE_TYPE.TIKTOK, () => "TikTok")
.exhaustive();
}, [currentService]);

Expand Down
4 changes: 4 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const MESSAGE_NAMES = {
SEARCH_BSKY_USER_ON_BLOCK_PAGE: "search_bsky_user_on_block_page",
SEARCH_BSKY_USER_ON_THREADS_PAGE: "search_bsky_user_on_threads_page",
SEARCH_BSKY_USER_ON_INSTAGRAM_PAGE: "search_bsky_user_on_instagram_page",
SEARCH_BSKY_USER_ON_TIKTOK_PAGE: "search_bsky_user_on_tiktok_page",
} as const;

export const ACTION_MODE = {
Expand All @@ -20,6 +21,7 @@ export const MESSAGE_NAME_TO_ACTION_MODE_MAP = {
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: ACTION_MODE.BLOCK,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_THREADS_PAGE]: ACTION_MODE.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_INSTAGRAM_PAGE]: ACTION_MODE.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_TIKTOK_PAGE]: ACTION_MODE.FOLLOW,
};

const STORAGE_PREFIX = "sky_follower_bridge_storage";
Expand All @@ -41,6 +43,7 @@ export const TARGET_URLS_REGEX = {
BLOCK: /^https:\/\/(twitter|x)\.com\/settings\/blocked/,
THREADS: /^https:\/\/www\.threads\.net/,
INSTAGRAM: /^https:\/\/www\.instagram\.com\/[^/]+\/(followers|following)\/?/,
TIKTOK: /^https:\/\/www\.tiktok\.com/,
} as const;

export const MESSAGE_TYPE = {
Expand Down Expand Up @@ -115,4 +118,5 @@ export const SERVICE_TYPE = {
X: "x",
THREADS: "threads",
INSTAGRAM: "instagram",
TIKTOK: "tiktok",
} as const;
6 changes: 6 additions & 0 deletions src/lib/hooks/useRetrieveBskyUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MESSAGE_NAMES, SERVICE_TYPE, STORAGE_KEYS } from "~lib/constants";
import { searchBskyUser } from "~lib/searchBskyUsers";
import { InstagramService } from "~lib/services/instagramService";
import { ThreadsService } from "~lib/services/threadsService";
import { TikTokService } from "~lib/services/tikTokService";
import { XService } from "~lib/services/xService";
import { wait } from "~lib/utils";
import type {
Expand Down Expand Up @@ -37,6 +38,10 @@ const getServiceType = (messageName: MessageName): ServiceType => {
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_INSTAGRAM_PAGE,
() => SERVICE_TYPE.INSTAGRAM,
)
.with(
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_TIKTOK_PAGE,
() => SERVICE_TYPE.TIKTOK,
)
.run();
};

Expand All @@ -49,6 +54,7 @@ const buildService = (
.with(SERVICE_TYPE.X, () => new XService(messageName))
.with(SERVICE_TYPE.THREADS, () => new ThreadsService(messageName))
.with(SERVICE_TYPE.INSTAGRAM, () => new InstagramService(messageName))
.with(SERVICE_TYPE.TIKTOK, () => new TikTokService(messageName))
.run();
};

Expand Down
136 changes: 136 additions & 0 deletions src/lib/services/tikTokService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { findFirstScrollableElements } from "~lib/utils";
import type { CrawledUserInfo, IService, MessageName } from "~types";

const SCROLL_TARGET_SELECTOR = '[data-e2e="follow-info-popup"]';

const searchUserCells = (userCell: HTMLElement): HTMLElement[] => {
if (!userCell) {
return [];
}
const cellTextCount = (userCell.innerText ?? "").split("\n").length;
const hasAvatar = !!userCell.querySelector("img");
if (1 <= cellTextCount && cellTextCount <= 3 && hasAvatar) {
return [userCell];
}
if (userCell.children.length === 0) {
return [];
}
return Array.from(userCell.children).flatMap(searchUserCells);
};

export class TikTokService implements IService {
messageName: MessageName;
crawledUserCells: Set<HTMLElement>;

constructor(messageName: MessageName) {
this.messageName = messageName;
this.crawledUserCells = new Set();
}

async processExtractedData(user: CrawledUserInfo): Promise<CrawledUserInfo> {
const avatarUrl = user.originalAvatar;
if (avatarUrl) {
try {
const response = await fetch(avatarUrl);
const blob = await response.blob();
const reader = new FileReader();
const base64Url = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
user.originalAvatar = base64Url;
} catch (error) {
console.error("Failed to convert avatar to base64:", error);
}
}
return user;
}

isTargetPage(): [boolean, string] {
const isTargetPage = document.querySelector(SCROLL_TARGET_SELECTOR);
if (!isTargetPage) {
return [false, chrome.i18n.getMessage("error_invalid_page_in_threads")];
}
return [true, ""];
}

extractUserData(userCell: Element): CrawledUserInfo {
const [displayName, _accountName] =
(userCell as HTMLElement).innerText
?.split("\n")
.map((t) => t.trim())
.filter((t) => t) ?? [];
const accountName = _accountName.replaceAll(".", "");
const accountNameRemoveUnderscore = accountName.replaceAll("_", ""); // bsky does not allow underscores in handle, so remove them.
const accountNameReplaceUnderscore = accountName.replaceAll("_", "-");
const avatarElement = userCell.querySelector('[shape="circle"]>img');
const avatarSrc = avatarElement?.getAttribute("src") ?? "";

const user = {
accountName,
displayName,
accountNameRemoveUnderscore,
accountNameReplaceUnderscore,
bskyHandle: "",
originalAvatar: avatarSrc,
originalProfileLink: `https://www.tiktok.com/@${_accountName}`,
};
return user;
}

getCrawledUsers(): CrawledUserInfo[] {
const userCells = searchUserCells(
document.querySelector(SCROLL_TARGET_SELECTOR),
);
let newUserCellsSet: Set<HTMLElement>;

if (
typeof this.crawledUserCells.difference === "function" &&
typeof this.crawledUserCells.union === "function"
) {
newUserCellsSet = new Set(userCells).difference(this.crawledUserCells);
this.crawledUserCells = this.crawledUserCells.union(newUserCellsSet);
} else {
newUserCellsSet = new Set(
Array.from(userCells).filter(
(userCell) => !this.crawledUserCells.has(userCell),
),
);
for (const userCell of newUserCellsSet) {
this.crawledUserCells.add(userCell);
}
}

const newUserCells = Array.from(newUserCellsSet);
return newUserCells.map((userCell) => this.extractUserData(userCell));
}

getScrollTarget() {
return findFirstScrollableElements(
document.querySelector<HTMLElement>(SCROLL_TARGET_SELECTOR),
);
}

async scrollToBottom(): Promise<void> {
const scrollTarget = this.getScrollTarget();
if (!scrollTarget) {
return;
}
const initialScrollHeight = scrollTarget.scrollHeight;
scrollTarget.scrollTop += initialScrollHeight;
}

checkEnd(): boolean {
const scrollTarget = this.getScrollTarget();
if (!scrollTarget) {
return true;
}

const hasReachedEnd =
scrollTarget.scrollTop + scrollTarget.clientHeight >=
scrollTarget.scrollHeight;

return hasReachedEnd;
}
}
4 changes: 4 additions & 0 deletions src/popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ function IndexPopup() {
P.when((url) => TARGET_URLS_REGEX.INSTAGRAM.test(url)),
() => MESSAGE_NAMES.SEARCH_BSKY_USER_ON_INSTAGRAM_PAGE,
)
.with(
P.when((url) => TARGET_URLS_REGEX.TIKTOK.test(url)),
() => MESSAGE_NAMES.SEARCH_BSKY_USER_ON_TIKTOK_PAGE,
)
.run();

await chrome.storage.local.set({
Expand Down

0 comments on commit 3be16f4

Please sign in to comment.