├── LICENSE ├── README.md └── script.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 TJ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TwitterSearchTokenTest 2 | 3 | Comma AI guy made a Tweet ;) 4 | 5 | https://twitter.com/realGeorgeHotz/status/1595270867402956801?s=20&t=zL5Om6wTGfjsFiik4Z63Pw 6 | 7 | I made this: 8 | 9 | ![Demo Screenshot](https://cdn.discordapp.com/attachments/803131522571829289/1044900876772446219/image.png) 10 | 11 | [Demo Video](https://twitter.com/TJEvarts/status/1595600733914669062?s=20&t=_cdefDme6RcCFnC4_3ngkQ) 12 | 13 | Thank you George! I am honored! Big thanks to the Twitter community and can't wait to see Twitter 2.0 develop. I do love working on cool projects so hit me up on Twitter anyone working on changing the world. 14 | 15 | ![The Results](https://cdn.discordapp.com/attachments/803131522571829289/1045765758073978931/image.png) 16 | 17 | --- 18 | 19 | [Challenge Results Thread](https://twitter.com/realGeorgeHotz/status/1596003668599328768?s=20&t=aOEUqX3LdV3dOEh3sTIevg) 20 | 21 | ## Version 0.1.3!!! SUPER SUPER GREAT NOW! 22 | 23 | ### What's new? 24 | 25 | - Completed the functionality of tokens using twitter advanced search 26 | - Created way to define arbitrary search tokens using JSON 27 | - Added TypeAhead aka Suggestions Box for multiple types of tokens 28 | - Added support for non-text Tokens (dates, times, colors, etc) 29 | - Added support for client side suggestion sorting with custom tokens 30 | - Added Keyboard Navigation 31 | - Styling update 32 | - Attempt to hook in search clear button if it exists 33 | - You can now tab to select suggestions 34 | - Dark Mode Support 35 | - Works on mobile! 36 | 37 | ## Usage 38 | 39 | Copy and paste the contents of script.js into the JS console of any twitter page with a search bar (i.e. [https://twitter.com/explore](https://twitter.com/explore) 40 | 41 | ![Usage Example](https://cdn.discordapp.com/attachments/803131522571829289/1045770003087114351/instructions.png) 42 | 43 | I tried to keep the code/approach and usage as simple as possible. 44 | 45 | ![Demo Screenshot](https://cdn.discordapp.com/attachments/803131522571829289/1044900876772446219/image.png) 46 | 47 | ## Contribute 48 | 49 | Feel free to fork and make it your own! Also I eat PR's for breakfast :) 50 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | /* Twitter Search Tokenization UI Demo 2 | * Author: TJ Evarts 3 | * Date: 11/23/2022 4 | * Version: 0.1.3 5 | * Free to Use by anybody 6 | * 7 | * Usage: Copy and paste this whole file into the js console of any twitter page that has a search box 8 | * and start typing in search and some of the supported tokens defined below 9 | * 10 | * Future Expansion 11 | * Impliment the token Pre (prefix). Add all supported advanced search supported tokens. Maybe also add client side sorting features to add additional 12 | * Capabilities to the search. Add bookmarks search token. Figure out how to load search results without refreshing page. 13 | */ 14 | 15 | //main search mod object 16 | const search = { 17 | default: `
Gimme Some Letters...
`, 18 | //define supported tokens: 19 | tokenObjects: [ 20 | { 21 | t: "from:", 22 | pre: "@", 23 | type: "text", //html input type to take advantage of formatting and native pickers 24 | q: (v) => `from:${v}`, //function used to resolve token to query string 25 | searchMethod: "users", //define what kind of suggestions script should show the user 26 | }, 27 | { 28 | t: "to:", 29 | pre: "@", 30 | type: "text", 31 | q: (v) => `to:${v}`, 32 | searchMethod: "users", 33 | }, 34 | { 35 | t: "mention:", 36 | pre: "@", 37 | type: "text", 38 | q: (v) => `(${v})`, 39 | searchMethod: "users", 40 | }, 41 | { 42 | t: "before:", 43 | pre: " ", 44 | type: "date", 45 | q: (v) => `until:${v}`, 46 | searchMethod: "", 47 | }, 48 | { 49 | t: "after:", 50 | pre: " ", 51 | type: "date", 52 | q: (v) => `since:${v}`, 53 | searchMethod: "", 54 | }, 55 | { 56 | t: "exact:", 57 | pre: " ", 58 | type: "text", 59 | q: (v) => `"${v.substring(1)}"`, 60 | searchMethod: "", 61 | }, // This token below totally works! Uncomment to check it out: 62 | // { 63 | // t: "time:", 64 | // pre: " ", 65 | // type: "time", 66 | // q: (v) => `"${v}"`, 67 | // searchMethod: "", 68 | // }, 69 | ], 70 | 71 | //set up helper functions 72 | container: (val) => (document.querySelector(".filter-container").innerHTML = val), 73 | containerLive: (val) => (document.querySelector(".filter-container-live").innerHTML = val), 74 | listBox: () => document.querySelector("[role=listbox]") || document.querySelector("[role=search] div.r-zchlnj"), 75 | input: () => document.querySelector("[data-testid=SearchBox_Search_Input]"), 76 | tokenTemplate: (key, value) => { 77 | return `
${key} ${value}
`; 78 | }, 79 | liveTokenTemplate: (key, value) => { 80 | return `
${value}
`; 81 | }, 82 | 83 | //Inject Token CSS 84 | injectCSS: () => { 85 | var css = ` 86 | .filter-container, .filter-container-live, .flex-center{ 87 | white-space: nowrap; 88 | } 89 | .flex-center { 90 | align-items: center; 91 | } 92 | .filter-wrapper{ 93 | max-width: 50%; 94 | overflow-x: hidden; 95 | white-space: nowrap; 96 | cursor:grab; 97 | } 98 | .token { 99 | white-space: nowrap; 100 | display: inline-block; 101 | padding: 3px 13px; 102 | background-color: #1d9bf0; 103 | color: white; 104 | border-radius: 1em; 105 | margin-right: 1px; 106 | } 107 | .token.live{ 108 | background-color: #dadada; 109 | color: #222; 110 | } 111 | .token.live::after { 112 | content: "|"; 113 | opacity: 0.1; 114 | animation: blink 0.5s infinite alternate; 115 | } 116 | @keyframes blink { 117 | 0%{ 118 | opacity:0.1; 119 | } 120 | 100% { 121 | opacity:1; 122 | } 123 | } 124 | .list-item.active, .list-item:hover{ 125 | background-color: rgb(29, 155, 240); 126 | } 127 | .list-item .text-wrap{ 128 | display:inline-block; 129 | } 130 | .list-item .text-wrap, .list-item img{ 131 | vertical-align:middle; 132 | } 133 | input:not([type=text]) { 134 | opacity:0; 135 | } 136 | .darkmode .list-item div{ 137 | color: rgb(247, 249, 249); 138 | } 139 | .filter-wrapper.darkmode div{ 140 | color: rgb(247, 249, 249); 141 | } 142 | .filter-wrapper.darkmode .live{ 143 | color: #222; 144 | } 145 | `, 146 | head = document.head || document.getElementsByTagName("head")[0], 147 | style = document.createElement("style"); 148 | head.appendChild(style); 149 | style.type = "text/css"; 150 | style.id = "injected_css"; 151 | if (style.styleSheet) { 152 | // This is required for IE8 and below. 153 | style.styleSheet.cssText = css; 154 | } else { 155 | style.appendChild(document.createTextNode(css)); 156 | } 157 | search.input().parentNode.classList.add("flex-center"); 158 | }, 159 | 160 | //tokens are stored in data attributes in the search input tag 161 | init: () => { 162 | search.reset(); 163 | search.injectCSS(); 164 | //intialize dom containers 165 | search.input().parentNode.innerHTML = "" + $("[data-testid=SearchBox_Search_Input]").parentNode.innerHTML; 166 | //add event listeners on input 167 | search.addListeners(); 168 | search.input().dataset.tokens = ""; 169 | search.input().dataset.liveToken = ""; 170 | //mouse click and drag listeners for token container 171 | let slider = document.querySelector(".filter-wrapper"); 172 | slider.addEventListener("mousedown", (e) => { 173 | search.slide.isDown = true; 174 | slider.classList.add("active"); 175 | search.slide.startX = e.pageX - slider.offsetLeft; 176 | search.slide.scrollLeft = slider.scrollLeft; 177 | }); 178 | slider.addEventListener("mouseleave", () => { 179 | search.slide.isDown = false; 180 | slider.classList.remove("active"); 181 | }); 182 | slider.addEventListener("mouseup", () => { 183 | search.slide.isDown = false; 184 | slider.classList.remove("active"); 185 | }); 186 | slider.addEventListener("mousemove", (e) => { 187 | if (!search.slide.isDown) return; 188 | e.preventDefault(); 189 | const x = e.pageX - slider.offsetLeft; 190 | const walk = (x - search.slide.startX) * 3; //scroll-fast 191 | slider.scrollLeft = search.slide.scrollLeft - walk; 192 | }); 193 | search.checkDarkMode(); 194 | }, 195 | checkDarkMode: () => { 196 | //check for darkmode 197 | if ((document.querySelector("body").style.backgroundColor !== "rgb(255, 255, 255)" && document.querySelector("body").style.backgroundColor !== "#FFFFFF") || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)) { 198 | document.querySelector(".filter-wrapper").classList.add("darkmode"); 199 | try { 200 | search.listBox().classList.add("darkmode"); 201 | } catch {} 202 | } 203 | }, 204 | getTokens: (obj) => { 205 | if (!obj.dataset || !obj.dataset.tokens || obj.dataset.tokens === "") return []; 206 | else return JSON.parse(obj.dataset.tokens); 207 | }, 208 | saveTokens: (obj, tokens) => { 209 | obj.dataset.tokens = JSON.stringify(tokens); 210 | return tokens; 211 | }, 212 | liveToken: (obj, val) => { 213 | if (val || val === "") obj.dataset.liveToken = val; 214 | return obj.dataset.liveToken; 215 | }, 216 | renderTokens: (obj) => { 217 | let res = ""; 218 | search.getTokens(obj).forEach((ele) => { 219 | res += search.tokenTemplate(ele.k, ele.v); 220 | }); 221 | return res; 222 | }, 223 | renderLiveToken: (obj, input) => { 224 | return search.liveTokenTemplate(search.liveToken(obj), search.liveToken(obj) + input); 225 | }, 226 | slide: { isDown: false, startX: 0, scrollLeft: 0 }, 227 | addListeners: (e) => { 228 | search.input().addEventListener("input", search.tokenizer, false); 229 | search.input().addEventListener("keydown", search.inputHandler, false); 230 | search.input().addEventListener( 231 | "focus", 232 | (ev) => { 233 | setTimeout(() => { 234 | //attempt to attach listener to search clear button if it exists 235 | try { 236 | search.checkDarkMode(); 237 | document.querySelector("[role=search] [data-testid=clearButton]").addEventListener("click", (e) => { 238 | e.preventDefault(); 239 | e.stopImmediatePropagation(); 240 | search.input().dataset.tokens = ""; 241 | search.input().dataset.liveToken = ""; 242 | search.input().value = ""; 243 | search.container(""); 244 | search.containerLive(""); 245 | search.clearList(); 246 | }); 247 | } catch {} 248 | }, 200); 249 | }, 250 | false 251 | ); 252 | }, 253 | tokenizer: (e) => { 254 | let activeTok = ""; 255 | if ( 256 | search.tokenObjects 257 | .map((o) => o.t) 258 | .some((t) => { 259 | if (e.target.value.includes(t)) activeTok = t; 260 | return e.target.value.includes(t); 261 | }) > 0 262 | ) { 263 | search.liveToken(e.target, e.target.value); 264 | //clear input and make further text invisible; 265 | e.target.style.color = "transparent"; 266 | e.target.style.width = "50%"; 267 | e.target.type = search.tokenObjects.filter((o) => o.t === activeTok)[0].type; 268 | e.target.showPicker(); 269 | if (e.target.type !== "text") 270 | setTimeout(() => { 271 | e.target.addEventListener( 272 | "change", 273 | (e) => { 274 | search.switchToken(e.target); 275 | let elClone = search.input().cloneNode(true); 276 | search.input().parentNode.replaceChild(elClone, search.input()); 277 | search.addListeners(); 278 | search.input().focus(); 279 | }, 280 | false 281 | ); 282 | }, 200); 283 | e.target.value = " "; //this space is important 284 | search.clearList(); 285 | } 286 | //update live token 287 | if (search.liveToken(e.target) !== "") { 288 | search.containerLive(search.renderLiveToken(e.target, e.target.value)); 289 | let ltok = search.tokenObjects.filter((o) => search.liveToken(e.target) === o.t)[0]; 290 | if (e.target.value.length > 1 && ltok.searchMethod !== "") search.predict(ltok.searchMethod, e.target.value); 291 | document.querySelector(".filter-wrapper").scrollLeft += 500; 292 | } else { 293 | search.predict("tokens", e.target.value); 294 | } 295 | }, 296 | popToken: function (obj) { 297 | //Remove Last saved token from UI and memory 298 | let appended = search.getTokens(obj); 299 | appended.pop(); 300 | search.saveTokens(obj, appended); 301 | search.container(search.renderTokens(obj)); 302 | }, 303 | inputHandler: (e) => { 304 | //look for keyboard navigation 305 | if (e.key === "Tab" || (e.key === "Enter" && search.liveToken(e.target) !== "" && !search.listBox().querySelector(".list-item.active"))) { 306 | e.preventDefault(); 307 | e.stopImmediatePropagation(); 308 | if (e.key === "Tab" && search.listBox().querySelector(".list-item.active")) { 309 | search.listBox().querySelector(".list-item.active").click(); 310 | return; 311 | } 312 | search.switchToken(e.target); 313 | } else if (e.key === "Backspace" && e.target.value.length == 0) { 314 | e.target.style.color = "inherit"; 315 | if (search.liveToken(e.target) === "") { 316 | e.preventDefault(); 317 | e.stopImmediatePropagation(); 318 | search.popToken(e.target); 319 | } else { 320 | search.liveToken(e.target, ""); 321 | search.containerLive(""); 322 | } 323 | } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { 324 | let actv = search.listBox().querySelector(".list-item.active"); 325 | if (actv) { 326 | actv.classList.remove("active"); 327 | let nxt = e.key === "ArrowDown" ? actv.nextSibling : actv.previousSibling; 328 | nxt.classList.add("active"); 329 | } else { 330 | search.listBox().querySelector(".list-item").classList.add("active"); 331 | } 332 | } else if (e.key === "Enter") { 333 | e.preventDefault(); 334 | e.stopImmediatePropagation(); 335 | if (search.listBox().querySelector(".list-item.active")) { 336 | search.listBox().querySelector(".list-item.active").click(); 337 | return; 338 | } 339 | //clear live tokens 340 | search.search(e.target); 341 | } 342 | }, 343 | switchToken: (obj) => { 344 | //switch live token to saved token 345 | obj.style.color = "inherit"; 346 | obj.type = "text"; 347 | //add live token to tokens 348 | let appended = search.getTokens(obj); 349 | appended.push({ k: search.liveToken(obj), v: obj.value }); 350 | search.saveTokens(obj, appended); 351 | search.container(search.renderTokens(obj)); 352 | //clear live token 353 | search.liveToken(obj, ""); 354 | search.containerLive(""); 355 | obj.value = ""; 356 | search.clearList(); 357 | search.clearDebounceTimeout(); 358 | document.querySelector(".filter-wrapper").scrollLeft += 500; 359 | }, 360 | getTokenString: (obj) => { 361 | //assemble query string from tokens 362 | return search 363 | .getTokens(obj) 364 | .map((o) => { 365 | let tok = search.tokenObjects.filter((f) => f.t === o.k)[0]; 366 | return `${tok.q(o.v)} `; 367 | }) 368 | .join(""); 369 | }, 370 | search: (obj) => { 371 | //simple version, anything further requires Twitter API 372 | window.open(`https://twitter.com/search?q=${search.getTokenString(obj)}${obj.value}`, "_self"); 373 | // window.location = `https://twitter.com/search?q=${search.getTokenString(obj)}${obj.value}`; 374 | }, 375 | predict: async (listType, query) => { 376 | //get type ahead list 377 | let url = null, 378 | userString = null, 379 | insert, 380 | list, 381 | hint; 382 | if (query) { 383 | if (query[0] == " ") query = query.substring(1); 384 | if (listType === "tokens") { 385 | insert = `
ele.t
`; 386 | list = search.tokenObjects; 387 | hint = "t"; 388 | } else if (listType === "users") { 389 | insert = `
ele.name
@ele.screen_name
`; 390 | userString = query.replace(" ", ""); 391 | //v2 api - very limited: 392 | //url = "https://api.twitter.com/2/users/by?user.fields=created_at,description,name,profile_image_url,url,username,verified&usernames=" + query.replace(" ", ""); 393 | //hint = "username"; 394 | hint = ""; 395 | } 396 | if (url != null) { 397 | const params = { method: "GET", headers: { Authorization: authorization_t } }; 398 | list = await fetch(url, params) 399 | .then((data) => data.json()) 400 | .then((data) => data.data); 401 | } else if (userString != null) { 402 | list = await search.debounceGetTypeahead(userString).then((data) => JSON.parse(data).users); 403 | } 404 | if (list) search.renderList(list, insert, hint); 405 | } 406 | }, 407 | renderList: (list, insert, hint = "") => { 408 | //render suggestions list 409 | //client side sorting: 410 | if (hint !== "") { 411 | list.sort((o1, o2) => o2[hint].indexOf(search.input().value)); 412 | } 413 | let res = list 414 | .map((ele, i) => { 415 | let item = `
${insert}
`; 416 | delete ele["profile_image_url"]; 417 | Object.keys(ele).forEach((e) => { 418 | item = item.replaceAll("ele." + e, ele[e]); 419 | }); 420 | return item; 421 | }) 422 | .join(""); 423 | 424 | search.listBox().innerHTML = res; 425 | document.querySelectorAll(".list-item").forEach((box) => { 426 | box.addEventListener( 427 | "click", 428 | (e) => { 429 | search.input().value = e.target.closest(".list-item").querySelector(".list-value").innerHTML; 430 | search.tokenizer({ target: search.input() }); 431 | search.input().focus(); 432 | if (!e.target.closest(".list-item").querySelector(".list-value").classList.contains("tokens")) search.switchToken(search.input()); 433 | setTimeout(search.clearList, 200); 434 | }, 435 | false 436 | ); 437 | }); 438 | return res; 439 | }, 440 | clearList: () => { 441 | search.listBox().innerHTML = search.default; 442 | }, 443 | //Optional reset function that allows you to keep pasting this file into the js console multiple times without errors 444 | reset: () => { 445 | search.input().type = "text"; 446 | search.input().style.color = "inherit"; 447 | search.input().removeEventListener("input", search.tokenizer, true); 448 | search.input().removeEventListener("keydown", search.inputHandler, true); 449 | try { 450 | document.querySelector("#injected_css").remove(); 451 | document.querySelector(".filter-container").remove(); 452 | document.querySelector(".filter-container-live").remove(); 453 | } catch {} 454 | }, 455 | /* More twitter api 1.1 helper functions: 456 | * =================================== 457 | * The code below was by Yaroslav (@512x512), the mad lad cracking the private non-documented Twitter apis. Go show him some love! 458 | * =================================== 459 | */ 460 | getCookie: (cname) => { 461 | let name = cname + "="; 462 | let ca = document.cookie.split(";").find((v) => { 463 | return v.match(name); 464 | }); 465 | return ca ? decodeURIComponent(ca).trim().replace(name, "") : ""; 466 | }, 467 | typeAheadUrl: "https://twitter.com/i/api/1.1/search/typeahead.json", 468 | getTypeAhead: (twitterHandle) => { 469 | return new Promise((resolve, reject) => { 470 | const requestUrl = new URL(search.typeAheadUrl); 471 | const csrfToken = search.getCookie("ct0"); 472 | const gt = search.getCookie("gt"); 473 | 474 | // constant in twitter js code 475 | const authorization = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; 476 | 477 | requestUrl.searchParams.set("include_ext_is_blue_verified", 1); 478 | requestUrl.searchParams.set("q", `@${twitterHandle}`); 479 | requestUrl.searchParams.set("src", "search_box"); 480 | requestUrl.searchParams.set("result_type", "users"); 481 | 482 | const xmlHttp = new XMLHttpRequest(); 483 | xmlHttp.open("GET", requestUrl.toString(), false); 484 | xmlHttp.setRequestHeader("x-csrf-token", csrfToken); 485 | xmlHttp.setRequestHeader("x-twitter-active-user", "yes"); 486 | 487 | if (search.getCookie("twid")) { 488 | //check if user is logged in 489 | xmlHttp.setRequestHeader("x-twitter-auth-type", "OAuth2Session"); 490 | } else { 491 | xmlHttp.setRequestHeader("x-guest-token", gt); 492 | } 493 | xmlHttp.setRequestHeader("x-twitter-client-language", "en"); 494 | xmlHttp.setRequestHeader("authorization", `Bearer ${authorization}`); 495 | 496 | xmlHttp.onload = (e) => { 497 | if (xmlHttp.readyState === 4) { 498 | if (xmlHttp.status === 200) { 499 | resolve(xmlHttp.responseText); 500 | } else { 501 | reject(xmlHttp.statusText); 502 | } 503 | } 504 | }; 505 | 506 | xmlHttp.onerror = (e) => { 507 | reject(xmlHttp.statusTexT); 508 | }; 509 | 510 | xmlHttp.send(null); 511 | }); 512 | }, 513 | debounceGetTypeaheadTimeout: null, 514 | clearDebounceTimeout: () => { 515 | if (search.debounceGetTypeaheadTimeout) { 516 | clearTimeout(search.debounceGetTypeaheadTimeout); 517 | } 518 | }, 519 | debounceGetTypeahead: (twitterHandle) => { 520 | return new Promise((resolve, reject) => { 521 | search.clearDebounceTimeout(); 522 | 523 | search.debounceGetTypeaheadTimeout = setTimeout(() => { 524 | search 525 | .getTypeAhead(twitterHandle) 526 | .then((data) => { 527 | resolve(data); 528 | }) 529 | .catch((err) => { 530 | reject(err); 531 | }); 532 | }, 400); 533 | }); 534 | }, 535 | }; 536 | search.init(); 537 | --------------------------------------------------------------------------------