From 3be16f400267e7ed849b23f4954b67d1dc9e5e0c Mon Sep 17 00:00:00 2001 From: kawamataryo Date: Wed, 18 Dec 2024 22:39:30 +0900 Subject: [PATCH] feat: support TikTock --- package.json | 5 +- src/contents/App.tsx | 2 + src/lib/constants.ts | 4 + src/lib/hooks/useRetrieveBskyUsers.ts | 6 ++ src/lib/services/tikTokService.ts | 136 ++++++++++++++++++++++++++ src/popup.tsx | 4 + 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/lib/services/tikTokService.ts diff --git a/package.json b/package.json index 33c6afd..0250684 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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": { diff --git a/src/contents/App.tsx b/src/contents/App.tsx index 4b96ddd..f82282d 100644 --- a/src/contents/App.tsx +++ b/src/contents/App.tsx @@ -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, }; @@ -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]); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9938ebb..0a2b083 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { @@ -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"; @@ -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 = { @@ -115,4 +118,5 @@ export const SERVICE_TYPE = { X: "x", THREADS: "threads", INSTAGRAM: "instagram", + TIKTOK: "tiktok", } as const; diff --git a/src/lib/hooks/useRetrieveBskyUsers.ts b/src/lib/hooks/useRetrieveBskyUsers.ts index d03ddc5..31a8ff8 100644 --- a/src/lib/hooks/useRetrieveBskyUsers.ts +++ b/src/lib/hooks/useRetrieveBskyUsers.ts @@ -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 { @@ -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(); }; @@ -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(); }; diff --git a/src/lib/services/tikTokService.ts b/src/lib/services/tikTokService.ts new file mode 100644 index 0000000..9e83c8e --- /dev/null +++ b/src/lib/services/tikTokService.ts @@ -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; + + constructor(messageName: MessageName) { + this.messageName = messageName; + this.crawledUserCells = new Set(); + } + + async processExtractedData(user: CrawledUserInfo): Promise { + 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((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; + + 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(SCROLL_TARGET_SELECTOR), + ); + } + + async scrollToBottom(): Promise { + 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; + } +} diff --git a/src/popup.tsx b/src/popup.tsx index c4922de..0c85f8e 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -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({