A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add support for website card embeds (open graph)

jack 821cd15f d292138b

+386
+315
package-lock.json
··· 14 14 "axios": "^1.13.2", 15 15 "bcryptjs": "^3.0.3", 16 16 "better-sqlite3": "^12.5.0", 17 + "cheerio": "^1.1.2", 17 18 "commander": "^14.0.2", 18 19 "cors": "^2.8.5", 19 20 "dotenv": "^17.2.3", ··· 30 31 "@biomejs/biome": "^1.9.4", 31 32 "@types/bcryptjs": "^2.4.6", 32 33 "@types/better-sqlite3": "^7.6.13", 34 + "@types/cheerio": "^0.22.35", 33 35 "@types/cors": "^2.8.19", 34 36 "@types/express": "^5.0.6", 35 37 "@types/inquirer": "^9.0.9", ··· 1599 1601 "@types/node": "*" 1600 1602 } 1601 1603 }, 1604 + "node_modules/@types/cheerio": { 1605 + "version": "0.22.35", 1606 + "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz", 1607 + "integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==", 1608 + "dev": true, 1609 + "license": "MIT", 1610 + "dependencies": { 1611 + "@types/node": "*" 1612 + } 1613 + }, 1602 1614 "node_modules/@types/connect": { 1603 1615 "version": "3.4.38", 1604 1616 "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", ··· 2062 2074 "url": "https://opencollective.com/express" 2063 2075 } 2064 2076 }, 2077 + "node_modules/boolbase": { 2078 + "version": "1.0.0", 2079 + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", 2080 + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", 2081 + "license": "ISC" 2082 + }, 2065 2083 "node_modules/buffer": { 2066 2084 "version": "5.7.1", 2067 2085 "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", ··· 2144 2162 "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", 2145 2163 "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", 2146 2164 "license": "MIT" 2165 + }, 2166 + "node_modules/cheerio": { 2167 + "version": "1.1.2", 2168 + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", 2169 + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", 2170 + "license": "MIT", 2171 + "dependencies": { 2172 + "cheerio-select": "^2.1.0", 2173 + "dom-serializer": "^2.0.0", 2174 + "domhandler": "^5.0.3", 2175 + "domutils": "^3.2.2", 2176 + "encoding-sniffer": "^0.2.1", 2177 + "htmlparser2": "^10.0.0", 2178 + "parse5": "^7.3.0", 2179 + "parse5-htmlparser2-tree-adapter": "^7.1.0", 2180 + "parse5-parser-stream": "^7.1.2", 2181 + "undici": "^7.12.0", 2182 + "whatwg-mimetype": "^4.0.0" 2183 + }, 2184 + "engines": { 2185 + "node": ">=20.18.1" 2186 + }, 2187 + "funding": { 2188 + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" 2189 + } 2190 + }, 2191 + "node_modules/cheerio-select": { 2192 + "version": "2.1.0", 2193 + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", 2194 + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", 2195 + "license": "BSD-2-Clause", 2196 + "dependencies": { 2197 + "boolbase": "^1.0.0", 2198 + "css-select": "^5.1.0", 2199 + "css-what": "^6.1.0", 2200 + "domelementtype": "^2.3.0", 2201 + "domhandler": "^5.0.3", 2202 + "domutils": "^3.0.1" 2203 + }, 2204 + "funding": { 2205 + "url": "https://github.com/sponsors/fb55" 2206 + } 2147 2207 }, 2148 2208 "node_modules/chownr": { 2149 2209 "version": "1.1.4", ··· 2362 2422 "node": ">= 0.10" 2363 2423 } 2364 2424 }, 2425 + "node_modules/css-select": { 2426 + "version": "5.2.2", 2427 + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", 2428 + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", 2429 + "license": "BSD-2-Clause", 2430 + "dependencies": { 2431 + "boolbase": "^1.0.0", 2432 + "css-what": "^6.1.0", 2433 + "domhandler": "^5.0.2", 2434 + "domutils": "^3.0.1", 2435 + "nth-check": "^2.0.1" 2436 + }, 2437 + "funding": { 2438 + "url": "https://github.com/sponsors/fb55" 2439 + } 2440 + }, 2441 + "node_modules/css-what": { 2442 + "version": "6.2.2", 2443 + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", 2444 + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", 2445 + "license": "BSD-2-Clause", 2446 + "engines": { 2447 + "node": ">= 6" 2448 + }, 2449 + "funding": { 2450 + "url": "https://github.com/sponsors/fb55" 2451 + } 2452 + }, 2365 2453 "node_modules/data-uri-to-buffer": { 2366 2454 "version": "6.0.2", 2367 2455 "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", ··· 2459 2547 "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", 2460 2548 "license": "BSD-3-Clause" 2461 2549 }, 2550 + "node_modules/dom-serializer": { 2551 + "version": "2.0.0", 2552 + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", 2553 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 2554 + "license": "MIT", 2555 + "dependencies": { 2556 + "domelementtype": "^2.3.0", 2557 + "domhandler": "^5.0.2", 2558 + "entities": "^4.2.0" 2559 + }, 2560 + "funding": { 2561 + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" 2562 + } 2563 + }, 2564 + "node_modules/domelementtype": { 2565 + "version": "2.3.0", 2566 + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", 2567 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", 2568 + "funding": [ 2569 + { 2570 + "type": "github", 2571 + "url": "https://github.com/sponsors/fb55" 2572 + } 2573 + ], 2574 + "license": "BSD-2-Clause" 2575 + }, 2576 + "node_modules/domhandler": { 2577 + "version": "5.0.3", 2578 + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", 2579 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 2580 + "license": "BSD-2-Clause", 2581 + "dependencies": { 2582 + "domelementtype": "^2.3.0" 2583 + }, 2584 + "engines": { 2585 + "node": ">= 4" 2586 + }, 2587 + "funding": { 2588 + "url": "https://github.com/fb55/domhandler?sponsor=1" 2589 + } 2590 + }, 2591 + "node_modules/domutils": { 2592 + "version": "3.2.2", 2593 + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", 2594 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 2595 + "license": "BSD-2-Clause", 2596 + "dependencies": { 2597 + "dom-serializer": "^2.0.0", 2598 + "domelementtype": "^2.3.0", 2599 + "domhandler": "^5.0.3" 2600 + }, 2601 + "funding": { 2602 + "url": "https://github.com/fb55/domutils?sponsor=1" 2603 + } 2604 + }, 2462 2605 "node_modules/dotenv": { 2463 2606 "version": "17.2.3", 2464 2607 "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", ··· 2515 2658 "node": ">= 0.8" 2516 2659 } 2517 2660 }, 2661 + "node_modules/encoding-sniffer": { 2662 + "version": "0.2.1", 2663 + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", 2664 + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", 2665 + "license": "MIT", 2666 + "dependencies": { 2667 + "iconv-lite": "^0.6.3", 2668 + "whatwg-encoding": "^3.1.1" 2669 + }, 2670 + "funding": { 2671 + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" 2672 + } 2673 + }, 2674 + "node_modules/encoding-sniffer/node_modules/iconv-lite": { 2675 + "version": "0.6.3", 2676 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 2677 + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 2678 + "license": "MIT", 2679 + "dependencies": { 2680 + "safer-buffer": ">= 2.1.2 < 3.0.0" 2681 + }, 2682 + "engines": { 2683 + "node": ">=0.10.0" 2684 + } 2685 + }, 2518 2686 "node_modules/end-of-stream": { 2519 2687 "version": "1.4.5", 2520 2688 "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", ··· 2522 2690 "license": "MIT", 2523 2691 "dependencies": { 2524 2692 "once": "^1.4.0" 2693 + } 2694 + }, 2695 + "node_modules/entities": { 2696 + "version": "4.5.0", 2697 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 2698 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 2699 + "license": "BSD-2-Clause", 2700 + "engines": { 2701 + "node": ">=0.12" 2702 + }, 2703 + "funding": { 2704 + "url": "https://github.com/fb55/entities?sponsor=1" 2525 2705 } 2526 2706 }, 2527 2707 "node_modules/es-define-property": { ··· 3089 3269 "node": ">= 0.4" 3090 3270 } 3091 3271 }, 3272 + "node_modules/htmlparser2": { 3273 + "version": "10.0.0", 3274 + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", 3275 + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", 3276 + "funding": [ 3277 + "https://github.com/fb55/htmlparser2?sponsor=1", 3278 + { 3279 + "type": "github", 3280 + "url": "https://github.com/sponsors/fb55" 3281 + } 3282 + ], 3283 + "license": "MIT", 3284 + "dependencies": { 3285 + "domelementtype": "^2.3.0", 3286 + "domhandler": "^5.0.3", 3287 + "domutils": "^3.2.1", 3288 + "entities": "^6.0.0" 3289 + } 3290 + }, 3291 + "node_modules/htmlparser2/node_modules/entities": { 3292 + "version": "6.0.1", 3293 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", 3294 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", 3295 + "license": "BSD-2-Clause", 3296 + "engines": { 3297 + "node": ">=0.12" 3298 + }, 3299 + "funding": { 3300 + "url": "https://github.com/fb55/entities?sponsor=1" 3301 + } 3302 + }, 3092 3303 "node_modules/http-errors": { 3093 3304 "version": "2.0.1", 3094 3305 "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", ··· 3532 3743 "node": ">=6.0.0" 3533 3744 } 3534 3745 }, 3746 + "node_modules/nth-check": { 3747 + "version": "2.1.1", 3748 + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", 3749 + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", 3750 + "license": "BSD-2-Clause", 3751 + "dependencies": { 3752 + "boolbase": "^1.0.0" 3753 + }, 3754 + "funding": { 3755 + "url": "https://github.com/fb55/nth-check?sponsor=1" 3756 + } 3757 + }, 3535 3758 "node_modules/object-assign": { 3536 3759 "version": "4.1.1", 3537 3760 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", ··· 3606 3829 "node": ">= 14" 3607 3830 } 3608 3831 }, 3832 + "node_modules/parse5": { 3833 + "version": "7.3.0", 3834 + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", 3835 + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", 3836 + "license": "MIT", 3837 + "dependencies": { 3838 + "entities": "^6.0.0" 3839 + }, 3840 + "funding": { 3841 + "url": "https://github.com/inikulin/parse5?sponsor=1" 3842 + } 3843 + }, 3844 + "node_modules/parse5-htmlparser2-tree-adapter": { 3845 + "version": "7.1.0", 3846 + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", 3847 + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", 3848 + "license": "MIT", 3849 + "dependencies": { 3850 + "domhandler": "^5.0.3", 3851 + "parse5": "^7.0.0" 3852 + }, 3853 + "funding": { 3854 + "url": "https://github.com/inikulin/parse5?sponsor=1" 3855 + } 3856 + }, 3857 + "node_modules/parse5-parser-stream": { 3858 + "version": "7.1.2", 3859 + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", 3860 + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", 3861 + "license": "MIT", 3862 + "dependencies": { 3863 + "parse5": "^7.0.0" 3864 + }, 3865 + "funding": { 3866 + "url": "https://github.com/inikulin/parse5?sponsor=1" 3867 + } 3868 + }, 3869 + "node_modules/parse5/node_modules/entities": { 3870 + "version": "6.0.1", 3871 + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", 3872 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", 3873 + "license": "BSD-2-Clause", 3874 + "engines": { 3875 + "node": ">=0.12" 3876 + }, 3877 + "funding": { 3878 + "url": "https://github.com/fb55/entities?sponsor=1" 3879 + } 3880 + }, 3609 3881 "node_modules/parseurl": { 3610 3882 "version": "1.3.3", 3611 3883 "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", ··· 4458 4730 "multiformats": "^9.4.2" 4459 4731 } 4460 4732 }, 4733 + "node_modules/undici": { 4734 + "version": "7.18.2", 4735 + "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz", 4736 + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", 4737 + "license": "MIT", 4738 + "engines": { 4739 + "node": ">=20.18.1" 4740 + } 4741 + }, 4461 4742 "node_modules/undici-types": { 4462 4743 "version": "6.21.0", 4463 4744 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", ··· 4500 4781 "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz", 4501 4782 "integrity": "sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==", 4502 4783 "license": "Apache-2.0" 4784 + }, 4785 + "node_modules/whatwg-encoding": { 4786 + "version": "3.1.1", 4787 + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", 4788 + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", 4789 + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", 4790 + "license": "MIT", 4791 + "dependencies": { 4792 + "iconv-lite": "0.6.3" 4793 + }, 4794 + "engines": { 4795 + "node": ">=18" 4796 + } 4797 + }, 4798 + "node_modules/whatwg-encoding/node_modules/iconv-lite": { 4799 + "version": "0.6.3", 4800 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 4801 + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 4802 + "license": "MIT", 4803 + "dependencies": { 4804 + "safer-buffer": ">= 2.1.2 < 3.0.0" 4805 + }, 4806 + "engines": { 4807 + "node": ">=0.10.0" 4808 + } 4809 + }, 4810 + "node_modules/whatwg-mimetype": { 4811 + "version": "4.0.0", 4812 + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", 4813 + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", 4814 + "license": "MIT", 4815 + "engines": { 4816 + "node": ">=18" 4817 + } 4503 4818 }, 4504 4819 "node_modules/wrap-ansi": { 4505 4820 "version": "9.0.2",
+2
package.json
··· 30 30 "axios": "^1.13.2", 31 31 "bcryptjs": "^3.0.3", 32 32 "better-sqlite3": "^12.5.0", 33 + "cheerio": "^1.1.2", 33 34 "commander": "^14.0.2", 34 35 "cors": "^2.8.5", 35 36 "dotenv": "^17.2.3", ··· 46 47 "@biomejs/biome": "^1.9.4", 47 48 "@types/bcryptjs": "^2.4.6", 48 49 "@types/better-sqlite3": "^7.6.13", 50 + "@types/cheerio": "^0.22.35", 49 51 "@types/cors": "^2.8.19", 50 52 "@types/express": "^5.0.6", 51 53 "@types/inquirer": "^9.0.9",
+69
src/index.ts
··· 12 12 import iso6391 from 'iso-639-1'; 13 13 import os from 'node:os'; 14 14 import puppeteer from 'puppeteer-core'; 15 + import * as cheerio from 'cheerio'; 15 16 import sharp from 'sharp'; 16 17 import { getConfig } from './config-manager.js'; 17 18 ··· 498 499 return blob!; 499 500 } 500 501 502 + async function fetchEmbedUrlCard(agent: BskyAgent, url: string): Promise<any> { 503 + try { 504 + const response = await axios.get(url, { 505 + headers: { 506 + 'User-Agent': 'Mozilla/5.0 (compatible; Tweets2Bsky/1.0; +https://github.com/j4ckxyz/tweets-2-bsky)', 507 + }, 508 + timeout: 10000, 509 + }); 510 + 511 + const $ = cheerio.load(response.data); 512 + const title = $('meta[property="og:title"]').attr('content') || $('title').text() || ''; 513 + const description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || ''; 514 + let thumbBlob: BlobRef | undefined; 515 + 516 + let imageUrl = $('meta[property="og:image"]').attr('content'); 517 + if (imageUrl) { 518 + if (!imageUrl.startsWith('http')) { 519 + const baseUrl = new URL(url); 520 + imageUrl = new URL(imageUrl, baseUrl.origin).toString(); 521 + } 522 + try { 523 + const { buffer, mimeType } = await downloadMedia(imageUrl); 524 + thumbBlob = await uploadToBluesky(agent, buffer, mimeType); 525 + } catch (e) { 526 + console.warn(`Failed to upload thumbnail for ${url}:`, e); 527 + } 528 + } 529 + 530 + if (!title && !description) return null; 531 + 532 + const external: any = { 533 + uri: url, 534 + title: title || url, 535 + description: description, 536 + }; 537 + 538 + if (thumbBlob) { 539 + external.thumb = thumbBlob; 540 + } 541 + 542 + return { 543 + $type: 'app.bsky.embed.external', 544 + external, 545 + }; 546 + 547 + } catch (err) { 548 + console.warn(`Failed to fetch embed card for ${url}:`, err); 549 + return null; 550 + } 551 + } 552 + 501 553 async function uploadVideoToBluesky(agent: BskyAgent, buffer: Buffer, filename: string): Promise<BlobRef> { 502 554 const sanitizedFilename = filename.split("?")[0] || "video.mp4"; 503 555 console.log( ··· 847 899 // 3. Quoting Logic 848 900 let quoteEmbed: { $type: string; record: { uri: string; cid: string } } | null = null; 849 901 let externalQuoteUrl: string | null = null; 902 + let linkCard: any = null; 850 903 851 904 if (tweet.is_quote_status && tweet.quoted_status_id_str) { 852 905 const quoteId = tweet.quoted_status_id_str; ··· 882 935 console.log(`[${twitterUsername}] 🔁 Quoted tweet is a self-quote, skipping link.`); 883 936 } 884 937 } 938 + } else if (images.length === 0 && !videoBlob) { 939 + // If no media and no quote, check for external links to embed 940 + // We prioritize the LAST link found as it's often the main content 941 + const potentialLinks = urls 942 + .map(u => u.expanded_url) 943 + .filter(u => u && !u.includes('twitter.com') && !u.includes('x.com')) as string[]; 944 + 945 + if (potentialLinks.length > 0) { 946 + const linkToEmbed = potentialLinks[potentialLinks.length - 1]; 947 + if (linkToEmbed) { 948 + console.log(`[${twitterUsername}] 🃏 Fetching link card for: ${linkToEmbed}`); 949 + linkCard = await fetchEmbedUrlCard(agent, linkToEmbed); 950 + } 951 + } 885 952 } 886 953 887 954 // Only append link for external quotes IF we couldn't natively embed it OR screenshot it ··· 930 997 } 931 998 } else if (quoteEmbed) { 932 999 postRecord.embed = quoteEmbed; 1000 + } else if (linkCard) { 1001 + postRecord.embed = linkCard; 933 1002 } 934 1003 } 935 1004