diff --git a/eagle-twitter-tags.user.js b/eagle-twitter-tags.user.js
new file mode 100644
index 0000000..0e94708
--- /dev/null
+++ b/eagle-twitter-tags.user.js
@@ -0,0 +1,135 @@
+// ==UserScript==
+// @name Eagle 自動 Twitter 標籤
+// @version 1.0.0
+// @description Eagle 自動 Twitter 標籤
+// @author Shiaupiau
+// @namespace https://github.com/stu43005
+// @match *://twitter.com/*
+// @match *://x.com/*
+// @run-at document-end
+// @noframes
+// @grant GM_xmlhttpRequest
+// ==/UserScript==
+// @ts-check
+///
+"use strict";
+
+class EagleApi {
+ // Eagle API URL
+ static EAGLE_SERVER_URL = "http://localhost:41595";
+ static TAG_LIST_API_URL = `${this.EAGLE_SERVER_URL}/api/tag/list`;
+
+ /** @type {Promise | null} */
+ static tagListPromise = null;
+ /** @type {string[] | null} */
+ static tags = null;
+
+ /**
+ * @template T
+ * @typedef {Object} EagleApiResult
+ * @prop {string} status
+ * @prop {T} data
+ */
+ /**
+ * @typedef {Object} EagleTagItem
+ * @prop {string} name
+ * @prop {number} imageCount
+ * @prop {string[]} groups
+ * @prop {string} pinyin
+ */
+
+ /**
+ * fetch tag list
+ * @returns {Promise}
+ */
+ static fetchTagList() {
+ return new Promise((resolve, reject) => {
+ GM_xmlhttpRequest({
+ url: this.TAG_LIST_API_URL,
+ method: "GET",
+ onload: (response) => {
+ try {
+ /** @type {EagleApiResult} */
+ const result = JSON.parse(response.response);
+ if (result.status === "success" && result.data) {
+ console.log(`Eagle tag list:`, result.data);
+ resolve(result.data);
+ } else {
+ reject(result);
+ }
+ } catch (err) {
+ reject(err);
+ }
+ },
+ });
+ });
+ }
+
+ static async getTags() {
+ try {
+ if (!this.tags) {
+ this.tagListPromise ??= EagleApi.fetchTagList();
+ this.tags = (await this.tagListPromise).map((item) => item.name);
+ }
+ return this.tags;
+ } catch (error) {
+ this.tagListPromise = null;
+ console.log(`Unable to fetch Eagle tag list`);
+ throw error;
+ }
+ }
+}
+
+/**
+ * @param {Element} article
+ */
+async function eagleAttributes(article) {
+ try {
+ const tweetPhotos = Array.from(
+ article.querySelectorAll(`[data-testid="tweetPhoto"] img`)
+ ).filter((img) => !img.hasAttribute("eagle-tags"));
+ const hashtags = Array.from(article.querySelectorAll(`a[href^="/hashtag/"]`))
+ .map((element) => element.textContent?.replace(/^#/, ""))
+ .filter((text) => text !== undefined);
+ if (tweetPhotos.length > 0 && hashtags.length > 0) {
+ const eagleTags = await EagleApi.getTags();
+ const addTags = eagleTags.filter((tag) =>
+ hashtags.find((hashtag) => tag.toLowerCase() === hashtag.toLowerCase())
+ );
+
+ for (const img of tweetPhotos) {
+ img.setAttribute("eagle-tags", addTags.join(","));
+ }
+ }
+ } catch (error) {
+ console.log(error);
+ }
+}
+
+const ARTICLE_SELECTOR = `article[data-testid="tweet"]`;
+
+const observer = new MutationObserver((mutationList) => {
+ for (const mutation of mutationList) {
+ for (const addedNode of mutation.addedNodes) {
+ if (addedNode instanceof Element) {
+ if (addedNode.matches(ARTICLE_SELECTOR)) {
+ eagleAttributes(addedNode);
+ } else {
+ const closestArticle = addedNode.closest(ARTICLE_SELECTOR);
+ if (closestArticle) {
+ eagleAttributes(closestArticle);
+ } else {
+ const articles = Array.from(addedNode.querySelectorAll(ARTICLE_SELECTOR));
+ for (const article of articles) {
+ eagleAttributes(article);
+ }
+ }
+ }
+ }
+ }
+ }
+});
+observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+});