commit 7bc1dfcc3b3b8672fccfc4ec96bc28904c8b9ea9 Author: moro Date: Fri Oct 17 00:14:13 2025 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e432355 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + + +htmlexample +results \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..2cd965c --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,47 @@ +version: "3" + +tasks: + build: + desc: Build TypeScript files in src/ to dist/ using Bun + cmds: + - bun build ./src/popup/popup.ts --outdir ./dist/popup + - bun build ./src/background.ts --outdir ./dist + - bun build ./src/contentScript.ts --outdir ./dist + + - cp ./src/popup/popup.html ./dist/popup/popup.html + - cp ./src/popup/popup.css ./dist/popup/popup.css + - cp ./src/public/manifest.json ./dist/manifest.json + - cp ./src/mark.css ./dist/mark.css + - zip -r ./public/extension.zip ./dist + + dev: + desc: Watch mode development + cmds: + - task: clean + - task: build + - | + bun --watch build --outdir ./dist/popup ./src/popup/popup.ts & + bun --watch build --outdir ./dist ./src/background.ts & + bun --watch build --outdir ./dist ./src/contentScript.ts & + while inotifywait -e modify,create,delete -r ./src/popup/popup.html ./src/popup/popup.css ./src/public/manifest.json .src/; do + cp ./src/popup/popup.html ./dist/popup/popup.html + cp ./src/popup/popup.css ./dist/popup/popup.css + cp ./src/mark.css ./dist/mark.css + cp ./src/public/manifest.json ./dist/manifest.json + done + wait + + clean: + desc: Remove dist directory + cmds: + - rm -rf dist + + lint: + desc: Run TypeScript compiler for type checking + cmds: + - tsc --noEmit + + start: + desc: Alias for dev + cmds: + - task: dev diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0546077 --- /dev/null +++ b/bun.lock @@ -0,0 +1,288 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "new-browser-input", + "dependencies": { + "@types/turndown": "^5.0.5", + "turndown": "^7.2.0", + "turndown-plugin-gfm": "^1.0.2", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/chrome": "^0.0.326", + "@typescript-eslint/eslint-plugin": "^8.34.0", + "@typescript-eslint/parser": "^8.34.0", + "eslint": "^9.28.0", + }, + "peerDependencies": { + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.2.2", "", {}, "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.28.0", "", {}, "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + + "@types/chrome": ["@types/chrome@0.0.326", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], + + "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], + + "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.0.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg=="], + + "@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.34.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/type-utils": "8.34.0", "@typescript-eslint/utils": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.34.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.34.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.34.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.34.0", "@typescript-eslint/types": "^8.34.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0" } }, "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.34.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.34.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.34.0", "@typescript-eslint/utils": "8.34.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.34.0", "", {}, "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.34.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.34.0", "@typescript-eslint/tsconfig-utils": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.34.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", "@typescript-eslint/typescript-estree": "8.34.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.34.0", "", { "dependencies": { "@typescript-eslint/types": "8.34.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.28.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + + "turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + } +} diff --git a/example-json/instagram-post.json b/example-json/instagram-post.json new file mode 100644 index 0000000..371d7ff --- /dev/null +++ b/example-json/instagram-post.json @@ -0,0 +1,766 @@ +[ + { + "facts": [ + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/p/DMVZrA6hg4s/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "content", + "type": "string", + "value": "Terima kasih Bapak Presiden Prabowo yang telah berkenan berkunjung kerumah keluarga kami di Solo" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "likeCount", + "type": "number", + "value": "480461" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "commentCount", + "type": "number", + "value": "13247" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/", + "name": "createdAt", + "type": "datetime", + "value": "1753024829" + } + ], + "savedAt": "2025-07-22T13:00:16.478Z", + "type": "post", + "url": "https://www.instagram.com/p/DMVZrA6hg4s/" + }, + { + "facts": [ + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "username", + "type": "string", + "value": "jeus.simorangkir" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "content", + "type": "string", + "value": "TOMBOL HIDUP PAK JOKOWI DAN SEHAT SELALU๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/jeus.simorangkir/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/322979687_211880454702196_544204852800779256_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby44MjguYzIifQ&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=104&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=GqDz8hC__CIQ7kNvwF9-w44&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfT_j_J85vGg7Dh5NbYez0nQ6jgaKggFeqrTGvnqOUpgdA&oe=68854C2E&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:32:08.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "likes", + "type": "number", + "value": "7457" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=jeus.simorangkir", + "name": "replies", + "type": "number", + "value": "86" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "username", + "type": "string", + "value": "angga.sudibyo" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "content", + "type": "string", + "value": "Presiden terbaik dan mantan presiden terbaik, sehat2 selalu pak Jokowi dan pak Prabowo..โค๏ธ" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/angga.sudibyo/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/130992335_2899596323615403_6005254264143156705_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby43MjAuYzIifQ&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=103&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=98ypeD2jh0UQ7kNvwG3K6qe&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfSWbG3p05bu31rxEAX4E7rrj4sG3EvPzDIBmqx8gBeGfg&oe=68857447&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:21:50.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "likes", + "type": "number", + "value": "6374" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=angga.sudibyo", + "name": "replies", + "type": "number", + "value": "138" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "username", + "type": "string", + "value": "hubbimuhammad" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "content", + "type": "string", + "value": "Wiwok detok" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/hubbimuhammad/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/236438774_1003962147037394_5773809719213231100_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=102&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=Uj3-r0VSatoQ7kNvwFmHm57&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfTe1e2-KCVDwT9pylBSDKZPk5Cbblq8XHWSovIYuRTB3Q&oe=688554AB&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:46:10.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "likes", + "type": "number", + "value": "201" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=hubbimuhammad", + "name": "replies", + "type": "number", + "value": "6" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "username", + "type": "string", + "value": "dr_amar___Verified" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "content", + "type": "string", + "value": "dr_amar___" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/dr_amar___/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/371372306_352964293817124_2624783820092888996_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDc5LmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=100&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=E8WNw887aPEQ7kNvwFFJyCm&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfSfxrjfovS9WBPQon5Z3LX6n-3FwCzM-lxuKY-keNE60Q&oe=68856DAF&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:30:13.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "likes", + "type": "number", + "value": "3850" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=dr_amar___Verified", + "name": "replies", + "type": "number", + "value": "27" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "username", + "type": "string", + "value": "0_stanly_0" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "content", + "type": "string", + "value": "Indah nya kebersamaan para pemimpin bangsa" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/0_stanly_0/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/321843554_1273204200194583_2783570366240423599_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=103&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=oBdLq4jqMKYQ7kNvwGT8ws8&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfRRh7dEs0eJuwaJx_wLnzxTsSg3Qo988djd9ehPS_SLqQ&oe=68854E46&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:32:17.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "likes", + "type": "number", + "value": "1587" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=0_stanly_0", + "name": "replies", + "type": "number", + "value": "17" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "username", + "type": "string", + "value": "riansya_14" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "content", + "type": "string", + "value": "Full penjilat๐Ÿ˜‚" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/riansya_14/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/481788343_1696217614321706_2181028944150197706_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=102&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=a10J0WzO0pwQ7kNvwFBpxu6&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfTYsWzU58ByhjeDbSCnu_r_9PgcKiBuAmoDV4-ne6qalQ&oe=688568FB&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T22:36:53.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "likes", + "type": "number", + "value": "251" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=riansya_14", + "name": "replies", + "type": "number", + "value": "8" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "username", + "type": "string", + "value": "cipris_1303" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "content", + "type": "string", + "value": "Presiden terbaik dan mantan presiden terbaik ๐Ÿ‡ฎ๐Ÿ‡ฉsehat selalu pak dan penuh berkat" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/cipris_1303/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/142858735_422560428973422_381148379720372387_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=106&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=s86wIuGVFJgQ7kNvwG0sf68&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfTe6WX77ADhOfPa3zr6FuhRt4QuvoSLXK01Wxcj4edIvA&oe=6885429A&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T23:28:05.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "likes", + "type": "number", + "value": "1506" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=cipris_1303", + "name": "replies", + "type": "number", + "value": "19" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "username", + "type": "string", + "value": "prayogaza1" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "content", + "type": "string", + "value": "Merusak Tatanan negara" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/prayogaza1/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/515761503_18081358546871883_7627397364361180093_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=103&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=irohdz2KZJEQ7kNvwEZpRIF&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfTm_Qsry34RyzquINRic-v-bSIuKf8VJMEM2qhfagVWzw&oe=688546BC&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-21T00:56:42.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "likes", + "type": "number", + "value": "565" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=prayogaza1", + "name": "replies", + "type": "number", + "value": "14" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "username", + "type": "string", + "value": "togarsitumorangofficialVerified" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "content", + "type": "string", + "value": "togarsitumorangofficial" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/togarsitumorangofficial/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/402739086_1059202125232608_3579411792619616185_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=100&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=HYiQ5qjvW4EQ7kNvwHRMkUe&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfT5oO3RDah0Srg61xAT-LB5p3mndqL_ti1Iv4vsEFyV7A&oe=6885559F&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T23:11:43.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "likes", + "type": "number", + "value": "2959" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=togarsitumorangofficialVerified", + "name": "replies", + "type": "number", + "value": "86" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "username", + "type": "string", + "value": "28_rynt" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "content", + "type": "string", + "value": "banyak buzzer nya ya ternyata ๐Ÿ˜‚" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/28_rynt/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/518501742_17939081997031581_6677039797516417206_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=109&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=wSTQ-uSfplkQ7kNvwH1I5Wg&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfS1lyPhB-WlI5qjJ8tn1KgqjpjG7ObK7Ws9HGtvF4nUlw&oe=68856E37&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:37:45.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "likes", + "type": "number", + "value": "729" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=28_rynt", + "name": "replies", + "type": "number", + "value": "26" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "username", + "type": "string", + "value": "luxvillle_" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "content", + "type": "string", + "value": "ijazah mana wi" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/luxvillle_/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fcpq1-1.fna.fbcdn.net/v/t51.2885-19/464760996_1254146839119862_3605321457742435801_n.png?stp=dst-jpg_e0_s150x150_tt6&cb=8577c754-c2464923&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xNTAuYzIifQ&_nc_ht=instagram.fcpq1-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2QEaG6Jc7yy7TBh3OXus9ZPqiTE7msO3-ppRfRbIu3XYolX60KH8ILHIlfhHK3GQ1OnFv5A20Yos2h-2EiD8Pa6B&_nc_ohc=wwy9HdRJcYoQ7kNvwFOLNAK&_nc_gid=WjEoo0wXG3yPxgmIBxxJCw&edm=AB11_MABAAAA&ccb=7-5&ig_cache_key=YW5vbnltb3VzX3Byb2ZpbGVfcGlj.3-ccb7-5-cb8577c754-c2464923&oh=00_AfQbu6nENHFskdPLtIGGTEEjmuDgY-MTOzx28TFmSGks7w&oe=68855F28&_nc_sid=dc5e7f" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:55:10.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "likes", + "type": "number", + "value": "141" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=luxvillle_", + "name": "replies", + "type": "number", + "value": "4" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "username", + "type": "string", + "value": "srikandi_sarangheyo" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "content", + "type": "string", + "value": "bpk jokowi ttp dihati rakyatnya,,masyaallah sht sll bpk smoga allah mlindungi bpk bserta kluarga,,bpk adlh president terbaik yg prnh bangsa indonesia miliki๐Ÿ”ฅ๐Ÿ”ฅ" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/srikandi_sarangheyo/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/511638400_18008389931784271_6744633162976478269_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=107&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=uv_u2hKQs0EQ7kNvwEdYLR7&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfS8u2iUSeHLBq-BGzAAnvIawjWLOR_epP0b-TCYAEyUCw&oe=6885504A&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:25:59.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "likes", + "type": "number", + "value": "1589" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=srikandi_sarangheyo", + "name": "replies", + "type": "number", + "value": "34" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "username", + "type": "string", + "value": "aufa_suryanata" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "content", + "type": "string", + "value": "Anjay banyak bner buzer 58๐Ÿ”ฅ" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/aufa_suryanata/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fcpq1-1.fna.fbcdn.net/v/t51.2885-19/464760996_1254146839119862_3605321457742435801_n.png?stp=dst-jpg_e0_s150x150_tt6&cb=8577c754-c2464923&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xNTAuYzIifQ&_nc_ht=instagram.fcpq1-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2QEaG6Jc7yy7TBh3OXus9ZPqiTE7msO3-ppRfRbIu3XYolX60KH8ILHIlfhHK3GQ1OnFv5A20Yos2h-2EiD8Pa6B&_nc_ohc=wwy9HdRJcYoQ7kNvwFOLNAK&_nc_gid=WjEoo0wXG3yPxgmIBxxJCw&edm=AB11_MABAAAA&ccb=7-5&ig_cache_key=YW5vbnltb3VzX3Byb2ZpbGVfcGlj.3-ccb7-5-cb8577c754-c2464923&oh=00_AfQbu6nENHFskdPLtIGGTEEjmuDgY-MTOzx28TFmSGks7w&oe=68855F28&_nc_sid=dc5e7f" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-22T07:19:31.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "timestampDisplay", + "type": "string", + "value": "5h" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=aufa_suryanata", + "name": "likes", + "type": "number", + "value": "20" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "username", + "type": "string", + "value": "mhmmdalfarhzy" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "content", + "type": "string", + "value": "Neraka pedih loh pak" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/mhmmdalfarhzy/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/469247047_2288541594840403_8660919058584834110_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=107&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=hLDSRNi7BUIQ7kNvwFRdWyf&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfTOj2NHnotkdZC8GdpMEiCOrqXIL9H7XfmKnXkfdALcxQ&oe=688561D1&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-21T05:49:38.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "likes", + "type": "number", + "value": "93" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=mhmmdalfarhzy", + "name": "replies", + "type": "number", + "value": "1" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "username", + "type": "string", + "value": "nicke_cubby" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "content", + "type": "string", + "value": "Ma Sya Allah ๐Ÿ’• adem liat 2 idola rakyat Indonesia ini ๐Ÿ˜โค๏ธ" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "profileUrl", + "type": "url", + "value": "https://www.instagram.com/nicke_cubby/" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/109421841_2712419008993610_1068649358446491562_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=102&_nc_oc=Q6cZ2QEaTl0keKoiKKc6csP2iVA4GuXZLcz8QtSg9_FYgwP0G4r2rVJmXH2pJfYTzpTRXeI&_nc_ohc=wvTdnd3HqdcQ7kNvwHkdBNY&_nc_gid=_-U-WBOzzjboJzKoYa-Reg&edm=APs17CUBAAAA&ccb=7-5&oh=00_AfRmc1NYxyktG2CjJar9oaULcg6tYkN7QcWF1lKcEsOgUw&oe=68855D4B&_nc_sid=10d13b" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "timestamp", + "type": "datetime", + "value": "2025-07-20T15:33:03.000Z" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "timestampDisplay", + "type": "string", + "value": "1d" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "likes", + "type": "number", + "value": "1138" + }, + { + "context": "https://www.instagram.com/p/DMVZrA6hg4s/#comment_by=nicke_cubby", + "name": "replies", + "type": "number", + "value": "19" + } + ], + "savedAt": "2025-07-22T13:00:16.481Z", + "type": "comment", + "url": "https://www.instagram.com/p/DMVZrA6hg4s/" + } +] \ No newline at end of file diff --git a/example-json/instagram-profile.json b/example-json/instagram-profile.json new file mode 100644 index 0000000..a6e873b --- /dev/null +++ b/example-json/instagram-profile.json @@ -0,0 +1,262 @@ +[ + { + "facts": [ + { + "context": "https://www.instagram.com/jokowi/", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://www.instagram.com/jokowi/", + "name": "profileImage", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-19/323796872_186095804235435_4099239256456033831_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2QFFdz0HZSILtfF6Rzwy-7QTXpo6wKoqXnj-acYERdRgEiC2b09IA6YcYhLsoIcwtnM&_nc_ohc=FBLkTWMPdcEQ7kNvwEsWlAd&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&edm=AP4sbd4BAAAA&ccb=7-5&oh=00_AfQLAy2aidNZT4Wwk2yjo-vzoQnYGy3TGkcncOMrfS0Fig&oe=68856664&_nc_sid=7a9f4b" + }, + { + "context": "https://www.instagram.com/jokowi/", + "name": "isVerified", + "type": "boolean", + "value": "true" + }, + { + "context": "https://www.instagram.com/jokowi/", + "name": "postCount", + "type": "number", + "value": "5775" + }, + { + "context": "https://www.instagram.com/jokowi/", + "name": "followingCount", + "type": "number", + "value": "0" + } + ], + "savedAt": "2025-07-22T12:58:32.657Z", + "type": "profile", + "url": "https://www.instagram.com/jokowi/" + }, + { + "facts": [ + { + "context": "https://www.instagram.com/jokowi/reel/DMYCVUvB9nM/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DMYCVUvB9nM/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMYCVUvB9nM/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/521411526_1921944571903368_794586515625105760_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=1&ig_cache_key=MzY4MTcwMjk1Njg0MTI5NDI4NA%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=2zdzHEyojxUQ7kNvwEPv5y-&_nc_oc=AdkldIrtVzgWwYEOaLiluo1rvyS_A2-B693SDTzUAO3OYgrQU5x1xPjBZG_R3ImK3eE&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfRIXv_U69iPTc24p6k-cAozoWWtH2OkttS6sBK8AdyvUw&oe=688559A1" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMYCVUvB9nM/", + "name": "content", + "type": "string", + "value": "Saya melepas kepulangan Bapak Presiden Prabowo di Bandara Adi Soemarmo. Semoga lancar dan aman sampai tujuan." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMXGi5vhtc0/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DMXGi5vhtc0/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMXGi5vhtc0/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/522690099_2768611616656149_5103259914330463227_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=1&ig_cache_key=MzY4MTQ0MDAwNzE0MDU5NTUwOA%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=C7PTKqmsmd0Q7kNvwE3rKzd&_nc_oc=AdnzWqP91dz_O-galyznJsFEEVa-gde1YhxXUIjyv0ksUGdWV1WE-2C3fUZT9kHESEA&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfT-kRhFgWph3lL0bQl7yD9bIjADvynfJcWHZLN0JP7PGw&oe=6885662A" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMXGi5vhtc0/", + "name": "content", + "type": "string", + "value": "Saya mengajak Bapak Presiden Prabowo menikmati hidangan Bakmi Bu Citro di Solo. Rumah makan sederhana yang punya rasa istimewa. Kami duduk santai, berbincang ringan ditemani semangkuk bakmi hangat. Sebuah momen personal, jauh dari agenda kenegaraan, dan berkesan. Terima kasih Pak Presiden" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMVZrA6hg4s/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DMVZrA6hg4s/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMVZrA6hg4s/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/521542555_1681705549156478_6214360678065370356_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=1&ig_cache_key=MzY4MDk2MTE3NzUyNzM4OTc0MA%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=cZRzNEaAtoMQ7kNvwEwQnCJ&_nc_oc=AdngYOxEGuzX9uDXG93nZ4gJHlfFPstMyFFdtBQ6InHETh-psATyJflNlMaPqxh2cfI&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfRXFZaASm9kMqbECDOXllWQryvG0UEe3_OIrsahtVIlNg&oe=68855335" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMVZrA6hg4s/", + "name": "content", + "type": "string", + "value": "Terima kasih Bapak Presiden Prabowo yang telah berkenan berkunjung kerumah keluarga kami di Solo" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMFubsQhxmM/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DMFubsQhxmM/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMFubsQhxmM/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/519411790_1237579021145938_1676132418911526372_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=100&ig_cache_key=MzY3NjU0ODg4MzkwNTE5MDI4NA%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=mjXPCkkF1BEQ7kNvwFelyVV&_nc_oc=AdkgQSZbSinyAtN9uVIVIFoVwYjJK53PQ_lG-e-JjKvuiYpi26wTgh5XSbI1AItKglA&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfSKXEpujxufjLtS3TD1IzmRvIoB6wy6bq0JH3v3TiPmwQ&oe=6885762A" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DMFubsQhxmM/", + "name": "content", + "type": "string", + "value": "Pertunjukan spesial dari mahasiswa-mahasiswi UKI Yogyakarta di depan kediaman. Terima kasih atas perhatian dan dukungannya yang hangat!" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLumay5Bx0t/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DLumay5Bx0t/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLumay5Bx0t/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/516449709_712998244821804_8121518606345055304_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=110&ig_cache_key=MzY3MDAzOTcxMzQ3MTIwODc0OQ%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=BUh_kYhiXRkQ7kNvwGT773l&_nc_oc=AdmtFFurX4j0wddMlHXHQBL3o1LjYNen-NEX4IW10pFiQwqvSo503GSRLiea0UXXGvE&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfQLiieHKzgC3DTqATTd57PSootEGOdsz655J1KEZhymoQ&oe=6885746E" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLumay5Bx0t/", + "name": "content", + "type": "string", + "value": "Pagi ini saya mencoba naik ATV di pantai bersama cucu-cucu. Ternyata sulit sekali untuk berhenti ๐Ÿ˜„" + }, + { + "context": "https://www.instagram.com/jokowi/p/DLooTsthS_m/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/p/DLooTsthS_m/" + }, + { + "context": "https://www.instagram.com/jokowi/p/DLooTsthS_m/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://instagram.fdps17-1.fna.fbcdn.net/v/t51.2885-15/514848573_18487163449075048_432826180196670794_n.webp?efg=eyJ2ZW5jb2RlX3RhZyI6IkNBUk9VU0VMX0lURU0uaW1hZ2VfdXJsZ2VuLjE0NDB4MTkyMC5zZHIuZjgyNzg3LmRlZmF1bHRfaW1hZ2UuYzIifQ&_nc_ht=instagram.fdps17-1.fna.fbcdn.net&_nc_cat=104&_nc_oc=Q6cZ2QFFdz0HZSILtfF6Rzwy-7QTXpo6wKoqXnj-acYERdRgEiC2b09IA6YcYhLsoIcwtnM&_nc_ohc=tc2BcnYHFecQ7kNvwEgTAT8&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&edm=AP4sbd4BAAAA&ccb=7-5&ig_cache_key=MzY2ODM1OTE1OTY0MjE0NTAzNw%3D%3D.3-ccb7-5&oh=00_AfSLMXnOqzAwvenVnnk6b9pbQ4NVMMC3EfSZoP415asR6w&oe=688558A2&_nc_sid=7a9f4b" + }, + { + "context": "https://www.instagram.com/jokowi/p/DLooTsthS_m/", + "name": "content", + "type": "string", + "value": "Senang dapat bermain bersama cucu-cucu di pantai saat liburan sekolah. Momen seperti ini sederhana, tapi menyegarkan pikiran dan menenangkan hati." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLbyOP8hJu2/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DLbyOP8hJu2/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLbyOP8hJu2/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/510435322_3173564999475228_4451451499021688820_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=111&ig_cache_key=MzY2NDc0MzYwMzMxNTcxMDkwMg%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=AkQwuEW_EDkQ7kNvwEvFDYy&_nc_oc=AdnMGkD2AfN02pLszomu44YXNAEJRsmBoBpYFPw6zCkjA6zRavvMKon9eqjVDRm8LuY&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfSslKc6ESnaZ0WgbyG71KTu8cuAyGcKu2hxD-bSKywLbQ&oe=688575D0" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLbyOP8hJu2/", + "name": "content", + "type": "string", + "value": "Pak Harsudi dari Sukabumi menunjukkan bahwa kepedulian pada lingkungan bisa diwujudkan dengan cara yang sederhana namun penuh makna. Selama hampir lima tahun, beliau mengumpulkan limbah plastik dan merajutnya menjadi karpet daur ulang sepanjang 11 meter. Bukan hanya karya seni, tapi juga simbol ketekunan dan kreativitas.\n\nKarya ini mengingatkan kita bahwa setiap tindakan kecil bisa membawa dampak besar. Dengan niat yang tulus dan proses yang konsisten, bahkan limbah pun bisa menjadi sesuatu yang bernilai dan menginspirasi banyak orang. Terima kasih atas semangat dan dedikasinya, Pak Harsudi." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLY6SUmh5c5/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DLY6SUmh5c5/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLY6SUmh5c5/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/510435059_1012640944365927_3735622432573815901_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=111&ig_cache_key=MzY2MzkzNDY0MjYzNTM4MDUzNw%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=13LX2_NgLBcQ7kNvwGLI9VY&_nc_oc=Adl4-EAsojhjH0WGHAd_qhYVCG7sAFkXgnR41qqFTsCIelQTduNlITFoC3seo3quhYM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfQJIR9uSqyHls1ci1nCXQqHU5sPM9R_u31OHnCI6H6A-A&oe=68854E0C" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLY6SUmh5c5/", + "name": "content", + "type": "string", + "value": "Selamat Tahun Baru Islam 1447 Hijriyah.\nMomen ini mengingatkan kita bahwa perubahan sejati datang dari ketulusan dan kebersamaan.\nMari terus jaga semangat kerukunan dan perdamaian." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLOTHJxBinr/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DLOTHJxBinr/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLOTHJxBinr/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/509890521_1078717800840222_2399100765814600721_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=111&ig_cache_key=MzY2MDk0NzYwMTUwNTAwNDAxMQ%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=dvxpahQ3jxkQ7kNvwG0eACM&_nc_oc=Adk55rv_61TRv88p9UeFgmUy6wfLCNY4EwSNlUsz5H8PwYJ8OpaEoPhYvu8NorJvZCw&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfSxRdG_yej7D-r4xhMMU7_DJnUO_wtPdzQj2pPB_GQJ2Q&oe=688559D5" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DLOTHJxBinr/", + "name": "content", + "type": "string", + "value": "Terima kasih untuk setiap doa, ucapan, dan karangan bunga.\nSaya dan keluarga merasa sangat tersentuh dan dikuatkan oleh perhatian dan kasih sayang yang diberikan oleh Bapak Presiden @prabowo, para sahabat dan masyarakat dari berbagai penjuru negeri.\n\nSemoga semua kebaikan yang disampaikan dibalas berlipat ganda oleh Tuhan Yang Maha Esa. ๐Ÿ™" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKjzwc1hu3h/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DKjzwc1hu3h/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKjzwc1hu3h/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/503869592_1344177693307964_4256342620284667217_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=103&ig_cache_key=MzY0ODk4Nzc1Mjk2OTk4OTYwMQ%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=768D6mzr0VcQ7kNvwHr2gaW&_nc_oc=AdnIO2SkIRPGLosYEkkqzBK7eJoCYNXoMLSW1DYc75tjZvOr2eM6T88jDgincWRtyz4&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfSc5A5I8n59F7xNrK7bpn3unnzMG_TYG_3xRsBwDV5quA&oe=688576B1" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKjzwc1hu3h/", + "name": "content", + "type": "string", + "value": "Jum'at pagi tadi saya melaksanakan Salat Iduladha di halaman Gedung Graha Saba, Solo, kemudian bersilaturahmi dan beramah tamah bersama warga sekitar.\n\nSelamat merayakan Iduladha bagi seluruh Umat Islam, di mana pun berada." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKimymYh3R0/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DKimymYh3R0/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKimymYh3R0/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.71878-15/504107158_1249135363550582_4731561455237896905_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=100&ig_cache_key=MzY0ODY0OTI1MTA3ODUwMTQ5Mg%3D%3D.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjY0MHgxMTM2LnNkciJ9&_nc_ohc=u7LQpfNHPKsQ7kNvwGR0CaP&_nc_oc=AdmQ65GSIGuY_rQTVVwajV9vpe25A5BTfLY0y1FAE99b9z6nsaplQkQnCi3i9xMLrzY&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfQXIlc1LIdrgRZZuj5hca5U4xSMEe0gmQXPvJFLQwMH1w&oe=688554A3" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKimymYh3R0/", + "name": "content", + "type": "string", + "value": "Semoga semangat berkurban menumbuhkan keikhlasan, kepedulian terhadap sesama, dan keteguhan iman dalam diri kita. Mohon maaf lahir dan batin.\n\nSelamat Hari Raya Iduladha 1446 Hijriah. Semoga Allah SWT senantiasa memberkahi langkah kita semua." + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKiV5v1B_XH/", + "name": "url", + "type": "url", + "value": "https://www.instagram.com/jokowi/reel/DKiV5v1B_XH/" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKiV5v1B_XH/", + "name": "thumbnailUrl", + "type": "url", + "value": "https://scontent.cdninstagram.com/v/t51.75761-15/504271573_18482683051075048_3073227615232722894_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=104&ig_cache_key=MzY0ODU3NDk3NTQ2NjAxMDA1NTE4NDgyNjgzMDQ4MDc1MDQ4.3-ccb1-7&ccb=1-7&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6InhwaWRzLjU0MHg5NjAuc2RyIn0%3D&_nc_ohc=uGujMGeVkbUQ7kNvwFolwtb&_nc_oc=Admpr0cVy-TQAu2oBchjp9MVZIPeJNggP6XfeP9pf7xlY-A5CtbGily_of6xkJEJgYo&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=scontent.cdninstagram.com&_nc_gid=wq3phUaaZcxz05d9N8p1CQ&oh=00_AfTNxK_mkS4aF_9fkKEDmhElDQptpl0xk3A5-T3P1JK74A&oe=68857230" + }, + { + "context": "https://www.instagram.com/jokowi/reel/DKiV5v1B_XH/", + "name": "content", + "type": "string", + "value": "Kita bersyukur dan bangga. Terima kasih kepada seluruh pemain dan tim pelatih Timnas Indonesia atas semangat juang dan kerja keras yang luar biasa. Kemenangan atas China bukan hanya hasil di atas lapangan, tetapi juga menjadi bukti bahwa Indonesia berhasil menempati peringkat keempat klasemen grup dan melaju ke putaran keempat kualifikasi Piala Dunia. Selamat untuk Timnas Garuda. Kalian telah mengharumkan nama bangsa." + } + ], + "savedAt": "2025-07-22T12:58:32.659Z", + "type": "post", + "url": "https://www.instagram.com/jokowi/" + } +] \ No newline at end of file diff --git a/example-json/linkedin-post.json b/example-json/linkedin-post.json new file mode 100644 index 0000000..d536f39 --- /dev/null +++ b/example-json/linkedin-post.json @@ -0,0 +1,142 @@ +[ + { + "facts": [ + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "content", + "type": "string", + "value": "The freeCodeCamp community is publishing SO MANY books and courses. We're building a life-long learner's paradise. Here are this week's 5 resources worth your time: \n \n1\\. freeCodeCamp just published this comprehensive guide to AI app security and the most common vulnerabilities. You'll learn about Threat Modeling, Prompt Injection, Data Poisoning, supply chain risks, and more. (1 hour YouTube course): [https://lnkd.in/gzEnPDnt](https://lnkd.in/gzEnPDnt) \n \n2\\. JavaScript is the most popular language on the planet. But it's also super duper prone to errors. Luckily, freeCodeCamp just dropped this comprehensive handbook to help you understand how JavaScript's error handling works. You'll learn about Try-Catch, Error Rethrowing, the Finally keyword, and the Error Object itself. We also show tons of example code that you can scrutinize and learn from. (full-length handbook): [https://lnkd.in/gm8tpKu9](https://lnkd.in/gm8tpKu9) \n \n3\\. On this week's podcast, I interview a developer who had to apply to 800 jobs, but eventually landed one. Braydon Coyer started out building mobile apps in high school. At one point his iPhone game even out-sold Angry Birds for a day or two. He shares tons of strategies for applying for developer roles, sane ways to integrate AI into your developer workflows, and how to switch from mobile app dev to full stack dev. This dude is a blast. (1 hour watch or listen in your favorite podcast app): [https://lnkd.in/gGjSbXMw](https://lnkd.in/gGjSbXMw) \n \n4\\. I'm a huge fan of data visualization, and I love me some D3.js. So I was jazzed about this new course that'll help you shore up your Data Viz fundamentals. You'll go from bare-bones scatter plots to dynamically updating charts with fancy animations. If you want to learn how to make your data more accessible and more fun, this course is for you. (90 minute YouTube course): [https://lnkd.in/g6UnAK6M](https://lnkd.in/g6UnAK6M) \n \n5\\. As you may know, freeCodeCamp is a big open source project. And we have tons of developers who jump in to help us improve our curriculum and our codebase. But it's common for many of them to lose steam and drop off the map. This guide will help ensure that this doesn't happen to you. It'll give you actionable tips for setting open source goals, finding the right projects to get involved in, engaging with fellow devs, and more. (15 minute read): [https://lnkd.in/gr-\\_Wexr](https://lnkd.in/gr-_Wexr) \n \nQuote of the Week: \nโ€œWhen I was learning to code, the big picture didnโ€™t click until I needed to solve real problems. Thatโ€™s when variables and arrays started making sense โ€” not in theory, but in practice.โ€ โ€” Software Engineer Braydon Coyer on this week's freeCodeCamp podcast \n \nIf you read this far... support our charity's mission: [https://lnkd.in/gSGSfE8b](https://lnkd.in/gSGSfE8b) ๐Ÿ•๏ธ\n\nLove this, Quincy. Much needed for me.\n\nThanks for sharing, Quincy\n\nThanks for sharing, Quincy\n\n![Image](https://media.licdn.com/dms/image/v2/D5622AQGCqdEED0k1dQ/feedshare-shrink_800/B56Zgd4ZYjIAAk-/0/1752847980986?e=1756339200&v=beta&t=Fe1phU1c5qBRr48DJ--427KaqqZUxyfhMZyKa_oYys8)" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "likeCount", + "type": "number", + "value": "233" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "commentCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "shareCount", + "type": "number", + "value": "20" + } + ], + "savedAt": "2025-07-22T12:54:19.113Z", + "type": "post", + "url": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/" + }, + { + "facts": [ + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=rex-kishore", + "name": "displayName", + "type": "string", + "value": "Kishore Kumar" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=rex-kishore", + "name": "content", + "type": "string", + "value": "Love this, Quincy. Much needed for me." + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=rex-kishore", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=rex-kishore", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=rex-kishore", + "name": "createdAt", + "type": "timestr", + "value": "3hari" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=oluwafemi-adebayo-606688159", + "name": "displayName", + "type": "string", + "value": "Oluwafemi Adebayo" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=oluwafemi-adebayo-606688159", + "name": "content", + "type": "string", + "value": "Thanks for sharing, Quincy" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=oluwafemi-adebayo-606688159", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=oluwafemi-adebayo-606688159", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=oluwafemi-adebayo-606688159", + "name": "createdAt", + "type": "timestr", + "value": "1hari" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=daretechie", + "name": "displayName", + "type": "string", + "value": "Dare A." + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=daretechie", + "name": "content", + "type": "string", + "value": "Thanks for sharing, Quincy" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=daretechie", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=daretechie", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/#comment_by=daretechie", + "name": "createdAt", + "type": "timestr", + "value": "3hari" + } + ], + "savedAt": "2025-07-22T12:54:19.136Z", + "type": "comment", + "url": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216/" + } +] \ No newline at end of file diff --git a/example-json/linkedin-profile.json b/example-json/linkedin-profile.json new file mode 100644 index 0000000..339644d --- /dev/null +++ b/example-json/linkedin-profile.json @@ -0,0 +1,220 @@ +[ + { + "facts": [ + { + "context": "https://www.linkedin.com/in/quincylarson", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/in/quincylarson", + "name": "description", + "type": "string", + "value": "Teacher and founder of freeCodeCamp.org ๐Ÿ•๏ธ Host of the freeCodeCamp Podcast ๐ŸŽง" + }, + { + "context": "https://www.linkedin.com/in/quincylarson", + "name": "profileImage", + "type": "url", + "value": "https://media.licdn.com/dms/image/v2/C5603AQFET2zJ01O5Ug/profile-displayphoto-shrink_100_100/profile-displayphoto-shrink_100_100/0/1617221558171?e=1756339200&v=beta&t=rhCIDfIiPMN-COe0-niFes8YhUdBXBrulQHxCT7pxf4" + }, + { + "context": "https://www.linkedin.com/in/quincylarson", + "name": "followerCount", + "type": "number", + "value": "4109" + } + ], + "savedAt": "2025-07-22T12:53:23.051Z", + "type": "profile", + "url": "https://www.linkedin.com/in/quincylarson/recent-activity/all/" + }, + { + "facts": [ + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "content", + "type": "string", + "value": "The Tokyo subway system UX is on another level. Check out this handy chart of which train car you should choose for the fastest exit from each destination station.\n\n![Image](https://media.licdn.com/dms/image/v2/D4E22AQEgtdGCmc3b6A/feedshare-shrink_800/B4EZgwWduIGoAg-/0/1753157853212?e=1756339200&v=beta&t=QQT2VPNn_MGEsKDtx-cHrstALBCi7iY1aX3iBeXW5pY)" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "likeCount", + "type": "number", + "value": "30" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "commentCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7353277001924837376", + "name": "shareCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "content", + "type": "string", + "value": "The freeCodeCamp community is publishing SO MANY books and courses. We're building a life-long learner's paradise. Here are this week's 5 resources worth your time: \n \n1\\. freeCodeCamp just published this comprehensive guide to AI app security and the most common vulnerabilities. You'll learn about Threat Modeling, Prompt Injection, Data Poisoning, supply chain risks, and more. (1 hour YouTube course): [https://lnkd.in/gzEnPDnt](https://lnkd.in/gzEnPDnt) \n \n2\\. JavaScript is the most popular language on the planet. But it's also super duper prone to errors. Luckily, freeCodeCamp just dropped this comprehensive handbook to help you understand how JavaScript's error handling works. You'll learn about Try-Catch, Error Rethrowing, the Finally keyword, and the Error Object itself. We also show tons of example code that you can scrutinize and learn from. (full-length handbook): [https://lnkd.in/gm8tpKu9](https://lnkd.in/gm8tpKu9) \n \n3\\. On this week's podcast, I interview a developer who had to apply to 800 jobs, but eventually landed one. Braydon Coyer started out building mobile apps in high school. At one point his iPhone game even out-sold Angry Birds for a day or two. He shares tons of strategies for applying for developer roles, sane ways to integrate AI into your developer workflows, and how to switch from mobile app dev to full stack dev. This dude is a blast. (1 hour watch or listen in your favorite podcast app): [https://lnkd.in/gGjSbXMw](https://lnkd.in/gGjSbXMw) \n \n4\\. I'm a huge fan of data visualization, and I love me some D3.js. So I was jazzed about this new course that'll help you shore up your Data Viz fundamentals. You'll go from bare-bones scatter plots to dynamically updating charts with fancy animations. If you want to learn how to make your data more accessible and more fun, this course is for you. (90 minute YouTube course): [https://lnkd.in/g6UnAK6M](https://lnkd.in/g6UnAK6M) \n \n5\\. As you may know, freeCodeCamp is a big open source project. And we have tons of developers who jump in to help us improve our curriculum and our codebase. But it's common for many of them to lose steam and drop off the map. This guide will help ensure that this doesn't happen to you. It'll give you actionable tips for setting open source goals, finding the right projects to get involved in, engaging with fellow devs, and more. (15 minute read): [https://lnkd.in/gr-\\_Wexr](https://lnkd.in/gr-_Wexr) \n \nQuote of the Week: \nโ€œWhen I was learning to code, the big picture didnโ€™t click until I needed to solve real problems. Thatโ€™s when variables and arrays started making sense โ€” not in theory, but in practice.โ€ โ€” Software Engineer Braydon Coyer on this week's freeCodeCamp podcast \n \nIf you read this far... support our charity's mission: [https://lnkd.in/gSGSfE8b](https://lnkd.in/gSGSfE8b) ๐Ÿ•๏ธ\n\n![Image](https://media.licdn.com/dms/image/v2/D5622AQGCqdEED0k1dQ/feedshare-shrink_800/B56Zgd4ZYjIAAk-/0/1752847980986?e=1756339200&v=beta&t=Fe1phU1c5qBRr48DJ--427KaqqZUxyfhMZyKa_oYys8)" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "likeCount", + "type": "number", + "value": "233" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "commentCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7351977300806025216", + "name": "shareCount", + "type": "number", + "value": "20" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "content", + "type": "string", + "value": "I just gave my first-ever presentation completely in Japanese to the Keio Computer Society. ๆ—ฅๆœฌ่ชžๅ‹‰ๅผทๅคงๅฅฝใ๐Ÿฃ If youโ€™re a native Japanese speaker near Shinjuku and want to language exchange DM me. Iโ€™m ~N3 level and want to practice more dev-focused terms. ไผš่ฉฑใ—ใ‚ˆใ†ใ€‚โ˜•๏ธ" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "likeCount", + "type": "number", + "value": "347" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "commentCount", + "type": "number", + "value": "38" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7350887231995199490", + "name": "shareCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "displayName", + "type": "string", + "value": "Quincy Larson" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "content", + "type": "string", + "value": "I just sent out this week's \"5 freeCodeCamp resources worth your time.\" Some heaters in here including a 2-hour course on agentic \"vibe coding\" with n8n. \n \n1\\. freeCodeCamp just published this comprehensive course that will teach you how to build apps using the LAMP Stack โ€“ Linux, Apache, MySQL, PHP. This is a classic toolchain that a huge number of websites still use to this day. You can code along at home and build your own clone of Google Calendar โ€“ complete with multi-appointment support and booking conflict logic. Then you'll use JavaScript to add a dynamic user interface. This project will help you expand your horizons and get in some serious reps. (3 hour YouTube course): [https://lnkd.in/gw56n5dr](https://lnkd.in/gw56n5dr) \n \n2\\. You may have heard the term โ€œvibe codingโ€, where you write the specifications for your app, then hand it over to code generation agents who write the code for you. This approach has been a bit polarizing in the developer community, with vocal critics and vocal advocates. My advice is don't listen to any of them. Think for yourself. freeCodeCamp just published this course that will introduce you to vibe coding tools like n8n โ€“ an open source workflow automation tool โ€“ so you can wire together APIs and services without needing to write a ton of code manually. (2 hour YouTube course): [https://lnkd.in/gzM6-9ws](https://lnkd.in/gzM6-9ws) \n \n3\\. On this week's podcast I interview Joe Hill. He's a software engineer who works on a data platform for NASA. Joe taught himself programming for 4 years while working as a janitor. As the single father of two Autistic boys, he first used his programming skills to build an iPad app to help them learn how to talk. He shares tons of practical tips for learning new skills and for getting things done inside big orgs. (1 hour watch or listen in your favorite podcast app): [https://lnkd.in/gsvEUU-E](https://lnkd.in/gsvEUU-E) \n \n4\\. Learn how to write Python tests so you can make your codebase more robust. This tutorial will teach you the basics of pytest and show you tons of examples. You'll learn about Markers, Fixtures, Parameterization, and more. (20 minute read): [https://lnkd.in/gafgJ8nq](https://lnkd.in/gafgJ8nq) \n \n5\\. Tell your gardener friends: freeCodeCamp just published a tutorial on how to monitor the moisture of your soil using an Arduino microcontroller. This tutorial will show you what hardware to get and how to wire it up. Then it'll walk you through the code you'll run, and explain how everything works. (30 minute read): [https://lnkd.in/gggjuHhU](https://lnkd.in/gggjuHhU) \n \nQuote of the Week: \nโ€œBecoming tool agnostic is one of the biggest strengths you will ever have as a programmer. If you walk into a place and they say โ€œwe use XYZโ€, you can go โ€œGreat. I'll learn it.โ€ That is so powerful.โ€ - Joe Hill on this week's podcast\n\n![Image](https://media.licdn.com/dms/image/v2/D5622AQEORoqOyAGGXw/feedshare-shrink_800/B56Zf6LgS9HUAg-/0/1752249010762?e=1756339200&v=beta&t=i6MSgn38LfvuZEXXx1JfdzQwGo5IomNqgJBW8C3CPnc)" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "likeCount", + "type": "number", + "value": "193" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "commentCount", + "type": "number", + "value": "7" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7349465038635614209", + "name": "shareCount", + "type": "number", + "value": "9" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "url", + "type": "string", + "value": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "displayName", + "type": "string", + "value": "Nielda Karla Melo" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "content", + "type": "string", + "value": "Quando comecei a escrever esse post me veio um grande sorriso no rosto. Isso porque eu tรด aqui para avisar que o episรณdio desse mรชs do [freeCodeCamp](https://www.linkedin.com/company/free-code-camp/) em portuguรชs รฉ a minha entrevista com o [](/in/teocalvo/)[Tรฉo Calvo](https://www.linkedin.com/in/ACoAAA3X1sABWVV0Ru1ijaPUvBgcXVc7xsZR6Eo). \nEu me diverti tanto nessa conversa! Espero que vocรชs curtam tambรฉm. \n \nVem assistir na integra aqui: [https://lnkd.in/dAsgBXVP](https://lnkd.in/dAsgBXVP) \n \nTรฉo, obrigada pela troca e espero em breve poder conversar de novo! ๐Ÿ˜ƒ\n\n![Image](https://media.licdn.com/dms/image/v2/D4D05AQHNVrZueTgIEg/videocover-high/B4DZfq66rLHACM-/0/1751993010271?e=1753794000&v=beta&t=ia0dZ95oreLUORtZJgaROO8IcHe6GG1dWvrkGe3bn5k)" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "likeCount", + "type": "number", + "value": "59" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "commentCount", + "type": "number", + "value": "4" + }, + { + "context": "https://www.linkedin.com/feed/update/urn:li:activity:7348694351415341056", + "name": "shareCount", + "type": "number", + "value": "1" + } + ], + "savedAt": "2025-07-22T12:53:23.052Z", + "type": "post", + "url": "https://www.linkedin.com/in/quincylarson/recent-activity/all/" + } +] \ No newline at end of file diff --git a/example-json/tiktok-post.json b/example-json/tiktok-post.json new file mode 100644 index 0000000..9396e3d --- /dev/null +++ b/example-json/tiktok-post.json @@ -0,0 +1,508 @@ +[ + { + "facts": [ + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "displayName", + "type": "string", + "value": "Metro TV" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "content", + "type": "string", + "value": "Kelangkaan air bersih di Gaza, Palestina sebabkan warga harus hidup 4-5 hari tanpa air karena bergantung pada pengiriman truk air. \n \n[**#tiktokmetrotv**](/tag/tiktokmetrotv) [**#tiktokberita**](/tag/tiktokberita) [**#beritaterkini**](/tag/beritaterkini) [**#metrotv**](/tag/metrotv) [**#viral**](/tag/viral) [**#fyp**](/tag/fyp) [**#gaza**](/tag/gaza) [**#air**](/tag/air) [**#airbersih**](/tag/airbersih) [**#warga**](/tag/warga) [**#palestine**](/tag/palestine)" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "likeCount", + "type": "number", + "value": "576" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "commentCount", + "type": "number", + "value": "16" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "createdAt", + "type": "timestr", + "value": "20m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529858656179932436" + } + ], + "savedAt": "2025-07-22T12:50:59.763Z", + "type": "post", + "url": "https://www.tiktok.com/@metro_tv/video/7529858656179932436" + }, + { + "facts": [ + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aabbaa1207", + "name": "displayName", + "type": "string", + "value": "abaโ€ข" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aabbaa1207", + "name": "username", + "type": "string", + "value": "aabbaa1207" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aabbaa1207", + "name": "content", + "type": "string", + "value": "Free Palestine ๐Ÿ‡ต๐Ÿ‡ธ๐Ÿ‡ต๐Ÿ‡ธ๐Ÿ‡ต๐Ÿ‡ธ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aabbaa1207", + "name": "likeCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aabbaa1207", + "name": "createdAt", + "type": "timestr", + "value": "15m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aditaputra59", + "name": "displayName", + "type": "string", + "value": "putra A nya alicia๐Ÿ˜‹๐Ÿคญ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aditaputra59", + "name": "username", + "type": "string", + "value": "aditaputra59" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aditaputra59", + "name": "content", + "type": "string", + "value": "pada sewot cepetยฒ an komen jir๐Ÿ˜น๐Ÿคญ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aditaputra59", + "name": "likeCount", + "type": "number", + "value": "2" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=aditaputra59", + "name": "createdAt", + "type": "timestr", + "value": "16m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=billieeilishgxxx", + "name": "displayName", + "type": "string", + "value": "S" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=billieeilishgxxx", + "name": "username", + "type": "string", + "value": "billieeilishgxxx" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=billieeilishgxxx", + "name": "content", + "type": "string", + "value": "sementara itu di tanah Papua tidak ada yang perduli" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=billieeilishgxxx", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=billieeilishgxxx", + "name": "createdAt", + "type": "timestr", + "value": "8m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=surayaismail06", + "name": "displayName", + "type": "string", + "value": "suraya" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=surayaismail06", + "name": "username", + "type": "string", + "value": "surayaismail06" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=surayaismail06", + "name": "content", + "type": "string", + "value": "sabar2 Allah bersama kalian saudaraku๐Ÿ˜ญ๐Ÿ˜ญ๐Ÿ˜ญ๐Ÿ˜ญ๐Ÿ˜ญ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=surayaismail06", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=surayaismail06", + "name": "createdAt", + "type": "timestr", + "value": "14m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "displayName", + "type": "string", + "value": "korizen_sastra" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "username", + "type": "string", + "value": "korizen_sastra45" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "content", + "type": "string", + "value": "pertama" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "createdAt", + "type": "timestr", + "value": "20m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=user421553031236", + "name": "displayName", + "type": "string", + "value": "reza" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=user421553031236", + "name": "username", + "type": "string", + "value": "user421553031236" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=user421553031236", + "name": "content", + "type": "string", + "value": "astagfirullah" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=user421553031236", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=user421553031236", + "name": "createdAt", + "type": "timestr", + "value": "9m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=latifh4785", + "name": "displayName", + "type": "string", + "value": "latifh" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=latifh4785", + "name": "username", + "type": "string", + "value": "latifh4785" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=latifh4785", + "name": "content", + "type": "string", + "value": "free palestina" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=latifh4785", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=latifh4785", + "name": "createdAt", + "type": "timestr", + "value": "11m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=nabila_azzahra700", + "name": "displayName", + "type": "string", + "value": "Nabila Azzahra Nst" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=nabila_azzahra700", + "name": "username", + "type": "string", + "value": "nabila_azzahra700" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=nabila_azzahra700", + "name": "content", + "type": "string", + "value": "FREE PALESTINE\nSAVE PALESTINE" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=nabila_azzahra700", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=nabila_azzahra700", + "name": "createdAt", + "type": "timestr", + "value": "4m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=geren096", + "name": "displayName", + "type": "string", + "value": "GREN.FR" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=geren096", + "name": "username", + "type": "string", + "value": "geren096" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=geren096", + "name": "content", + "type": "string", + "value": "ke 4" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=geren096", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=geren096", + "name": "createdAt", + "type": "timestr", + "value": "18m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "displayName", + "type": "string", + "value": "korizen_sastra" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "username", + "type": "string", + "value": "korizen_sastra45" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "content", + "type": "string", + "value": "yess pertama" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "createdAt", + "type": "timestr", + "value": "20m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=1nuranisa", + "name": "displayName", + "type": "string", + "value": "hamba allah" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=1nuranisa", + "name": "username", + "type": "string", + "value": "1nuranisa" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=1nuranisa", + "name": "content", + "type": "string", + "value": "free palestine" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=1nuranisa", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=1nuranisa", + "name": "createdAt", + "type": "timestr", + "value": "13m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "displayName", + "type": "string", + "value": "korizen_sastra" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "username", + "type": "string", + "value": "korizen_sastra45" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "content", + "type": "string", + "value": "sebelum ribuan" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=korizen_sastra45", + "name": "createdAt", + "type": "timestr", + "value": "20m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=abang_ojol30", + "name": "displayName", + "type": "string", + "value": "Herman _MAXIM OJEK" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=abang_ojol30", + "name": "username", + "type": "string", + "value": "abang_ojol30" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=abang_ojol30", + "name": "content", + "type": "string", + "value": "free Israel" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=abang_ojol30", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=abang_ojol30", + "name": "createdAt", + "type": "timestr", + "value": "4m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "displayName", + "type": "string", + "value": "CELLO๐Ÿ—ฟ๐Ÿ˜Ž" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "username", + "type": "string", + "value": "the_cello7" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "content", + "type": "string", + "value": "ke 10 cuyyyy๐Ÿ—ฟ๐Ÿ˜" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "commentCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=the_cello7", + "name": "createdAt", + "type": "timestr", + "value": "12m ago" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=urungkan21", + "name": "displayName", + "type": "string", + "value": "ใ‚†ใ†ใจใฟใ‚“ใจ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=urungkan21", + "name": "username", + "type": "string", + "value": "urungkan21" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=urungkan21", + "name": "content", + "type": "string", + "value": "gue yang Habis WD25jt ๐Ÿ˜น\ntangkap Anisss ๐Ÿ˜ก\nyones Mana yones ๐Ÿ˜Œ" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=urungkan21", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436#comment_by=urungkan21", + "name": "createdAt", + "type": "timestr", + "value": "13m ago" + } + ], + "savedAt": "2025-07-22T12:50:59.774Z", + "type": "comment", + "url": "https://www.tiktok.com/@metro_tv/video/7529858656179932436" + } +] \ No newline at end of file diff --git a/example-json/tiktok-profile.json b/example-json/tiktok-profile.json new file mode 100644 index 0000000..b88d369 --- /dev/null +++ b/example-json/tiktok-profile.json @@ -0,0 +1,274 @@ +[ + { + "facts": [ + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "username", + "type": "string", + "value": "metro_tv" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "displayName", + "type": "string", + "value": "Metro TV" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "description", + "type": "string", + "value": "The Official Account of Metro TV" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "image", + "type": "url", + "value": "https://p16-sign-sg.tiktokcdn.com/tos-alisg-avt-0068/7312074011570733058~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=14579&refresh_token=71d036a5&x-expires=1753358400&x-signature=eW0JTtwT%2BGOp5MhV%2FNuRCE9tDK8%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=my2" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "followerCount", + "type": "number", + "value": "8300000" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "followingCount", + "type": "number", + "value": "5" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "postCount", + "type": "number", + "value": "15700" + }, + { + "context": "https://www.tiktok.com/@metro_tv", + "name": "likeCount", + "type": "number", + "value": "260400000" + } + ], + "savedAt": "2025-07-22T12:50:22.676Z", + "type": "profile", + "url": "https://www.tiktok.com/@metro_tv" + }, + { + "facts": [ + { + "context": "https://www.tiktok.com/@metro_tv/video/7529876715401743624", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529876715401743624" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858656179932436", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529858656179932436" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529877544443596052", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529877544443596052" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529876838890441991", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529876838890441991" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529876619029237013", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529876619029237013" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529873788788378886", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529873788788378886" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529868651105651975", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529868651105651975" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858317242469639", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529858317242469639" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858529126026516", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529858529126026516" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529858036026985749", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529858036026985749" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529857576016612613", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529857576016612613" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529848682011708688", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529848682011708688" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529843661165563143", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529843661165563143" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529843648142363905", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529843648142363905" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529843258269224209", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529843258269224209" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529843252602686741", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529843252602686741" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529827885771705620", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529827885771705620" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529827374465076501", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529827374465076501" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529812013032246548", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529812013032246548" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529805928460209429", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529805928460209429" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529827622180703509", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529827622180703509" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529826652558298385", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529826652558298385" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529825206412561670", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529825206412561670" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529821248117820688", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529821248117820688" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529818903556541717", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529818903556541717" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529805726156426514", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529805726156426514" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529754318732414229", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529754318732414229" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529805514620947733", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529805514620947733" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529807347103862024", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529807347103862024" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529796779689184532", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529796779689184532" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529752657255288084", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529752657255288084" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529796521202666772", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529796521202666772" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529789760177442104", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529789760177442104" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529752692411878677", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529752692411878677" + }, + { + "context": "https://www.tiktok.com/@metro_tv/video/7529756053068631312", + "name": "url", + "type": "url", + "value": "https://www.tiktok.com/@metro_tv/video/7529756053068631312" + } + ], + "savedAt": "2025-07-22T12:50:22.680Z", + "type": "post", + "url": "https://www.tiktok.com/@metro_tv" + } +] \ No newline at end of file diff --git a/example-json/twitter-post.json b/example-json/twitter-post.json new file mode 100644 index 0000000..d485429 --- /dev/null +++ b/example-json/twitter-post.json @@ -0,0 +1,292 @@ +[ + { + "facts": [ + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "url", + "type": "url", + "value": "https://x.com/jokowi/status/1947327953580196042" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "displayName", + "type": "string", + "value": "Joko Widodo" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "content", + "type": "string", + "value": "Saya melepas kepulangan Bapak Presiden Prabowo di Bandara Adi Soemarmo. Semoga lancar dan aman sampai tujuan. 1:41 1:41" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "likeCount", + "type": "number", + "value": "1389" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "shareCount", + "type": "number", + "value": "176" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "commentCount", + "type": "number", + "value": "282" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "bookmarkCount", + "type": "number", + "value": "26" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "viewCount", + "type": "number", + "value": "111728" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-21T16:08:58.000Z" + } + ], + "savedAt": "2025-07-22T12:49:01.646Z", + "type": "post", + "url": "https://x.com/jokowi/status/1947327953580196042" + }, + { + "facts": [ + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "url", + "type": "url", + "value": "https://x.com/megapand137/status/1947637749789683895" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-22T12:39:59.000Z" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "displayName", + "type": "string", + "value": "Megapand137" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "username", + "type": "string", + "value": "megapand137" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "content", + "type": "string", + "value": "Jokowi merasa dihina sehina hinanya katanya atas polemik Ijazahnya...Bukannya bapak selama jadi Presiden sering melakukan pekerjaan pekerjaan hina pak ? Contoh kecilnya...Ngomongnya \" apa \" , tapi yg dikerjakan \"apa \" Apakah iyu tidak hina ?" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "likeCount", + "type": "number", + "value": "2" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "shareCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/megapand137/status/1947637749789683895", + "name": "viewCount", + "type": "number", + "value": "69" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "url", + "type": "url", + "value": "https://x.com/Rojikin14775945/status/1947633517837619228" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-22T12:23:10.000Z" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "displayName", + "type": "string", + "value": "Izasah Palsu" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "username", + "type": "string", + "value": "Rojikin14775945" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "content", + "type": "string", + "value": "Wi lu kapan digantung atas kebohonganya" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "shareCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/Rojikin14775945/status/1947633517837619228", + "name": "viewCount", + "type": "number", + "value": "176" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "url", + "type": "url", + "value": "https://x.com/LukkiLw/status/1947627219519738173" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-22T11:58:08.000Z" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "displayName", + "type": "string", + "value": "Lukki Lw" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "username", + "type": "string", + "value": "LukkiLw" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "content", + "type": "string", + "value": "kupas tuntas 'kebaikan joko Widodo\n\n[@jokowi](/jokowi)" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "shareCount", + "type": "number", + "value": "1" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://x.com/LukkiLw/status/1947627219519738173", + "name": "viewCount", + "type": "number", + "value": "593" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "url", + "type": "url", + "value": "https://x.com/irmawindya/status/1947391677691490518" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-21T20:22:11.000Z" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "displayName", + "type": "string", + "value": "/juหหˆnษ”ษช.ษ™/" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "username", + "type": "string", + "value": "irmawindya" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "content", + "type": "string", + "value": "![๐Ÿ˜ฌ](https://abs-0.twimg.com/emoji/v2/svg/1f62c.svg \"Grimacing face\") ![Image](https://pbs.twimg.com/media/GwaGZ2haYAAQcHW?format=jpg&name=small)" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "likeCount", + "type": "number", + "value": "156" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "shareCount", + "type": "number", + "value": "24" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "commentCount", + "type": "number", + "value": "4" + }, + { + "context": "https://x.com/irmawindya/status/1947391677691490518", + "name": "viewCount", + "type": "number", + "value": "7793" + } + ], + "savedAt": "2025-07-22T12:49:01.652Z", + "type": "comment", + "url": "https://x.com/jokowi/status/1947327953580196042" + } +] \ No newline at end of file diff --git a/example-json/twitter-profile.json b/example-json/twitter-profile.json new file mode 100644 index 0000000..6928944 --- /dev/null +++ b/example-json/twitter-profile.json @@ -0,0 +1,240 @@ + + +[ + { + "facts": [ + { + "context": "https://x.com/jokowi", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://x.com/jokowi", + "name": "displayName", + "type": "string", + "value": "Joko Widodo" + }, + { + "context": "https://x.com/jokowi", + "name": "description", + "type": "string", + "value": "Akun Twitter resmi Joko Widodo ๐Ÿ‡ฎ๐Ÿ‡ฉ." + }, + { + "context": "https://x.com/jokowi", + "name": "profileImage", + "type": "url", + "value": "https://pbs.twimg.com/profile_images/1646769127493877761/bGdslGTd_400x400.jpg" + }, + { + "context": "https://x.com/jokowi", + "name": "followerCount", + "type": "number", + "value": "21905953" + }, + { + "context": "https://x.com/jokowi", + "name": "followingCount", + "type": "number", + "value": "59" + }, + { + "context": "https://x.com/jokowi", + "name": "postCount", + "type": "number", + "value": "7025" + } + ], + "savedAt": "2025-07-22T12:48:10.097Z", + "type": "profile", + "url": "https://x.com/jokowi" + }, + { + "facts": [ + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "url", + "type": "url", + "value": "https://x.com/jokowi/status/1947327953580196042" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "displayName", + "type": "string", + "value": "Joko Widodo" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "content", + "type": "string", + "value": "Saya melepas kepulangan Bapak Presiden Prabowo di Bandara Adi Soemarmo. Semoga lancar dan aman sampai tujuan. ![](https://pbs.twimg.com/ext_tw_video_thumb/1947327709908172800/pu/img/O8G8IycYawWAMF_C.jpg) ![](https://pbs.twimg.com/ext_tw_video_thumb/1947327709908172800/pu/img/O8G8IycYawWAMF_C.jpg)" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "likeCount", + "type": "number", + "value": "1388" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "shareCount", + "type": "number", + "value": "176" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "commentCount", + "type": "number", + "value": "282" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "bookmarkCount", + "type": "number", + "value": "26" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "viewCount", + "type": "number", + "value": "111669" + }, + { + "context": "https://x.com/jokowi/status/1947327953580196042", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-21T16:08:58.000Z" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "url", + "type": "url", + "value": "https://x.com/jokowi/status/1947194422426517998" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "displayName", + "type": "string", + "value": "Joko Widodo" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "content", + "type": "string", + "value": "Saya mengajak Bapak Presiden Prabowo menikmati hidangan Bakmi Bu Citro di Solo. Rumah makan sederhana yang punya rasa istimewa. Kami duduk santai, berbincang ringan ditemani semangkuk bakmi hangat. Sebuah momen personal, jauh dari agenda kenegaraan, dan berkesan. Terima kasih Pak Presiden ![](https://pbs.twimg.com/ext_tw_video_thumb/1947194326838136832/pu/img/lNC1SMERg5j2WoTE.jpg) ![](https://pbs.twimg.com/ext_tw_video_thumb/1947194326838136832/pu/img/lNC1SMERg5j2WoTE.jpg)" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "likeCount", + "type": "number", + "value": "1755" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "shareCount", + "type": "number", + "value": "253" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "commentCount", + "type": "number", + "value": "265" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "bookmarkCount", + "type": "number", + "value": "43" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "viewCount", + "type": "number", + "value": "134879" + }, + { + "context": "https://x.com/jokowi/status/1947194422426517998", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-21T07:18:22.000Z" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "url", + "type": "url", + "value": "https://x.com/jokowi/status/1946955377771450714" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "displayName", + "type": "string", + "value": "Joko Widodo" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "username", + "type": "string", + "value": "jokowi" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "content", + "type": "string", + "value": "Terima kasih Bapak Presiden Prabowo yang telah berkenan berkunjung kerumah keluarga kami di Solo ![](https://pbs.twimg.com/ext_tw_video_thumb/1946955213375504384/pu/img/88Kmlo4g90ptYgMA.jpg) ![](https://pbs.twimg.com/ext_tw_video_thumb/1946955213375504384/pu/img/88Kmlo4g90ptYgMA.jpg)" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "likeCount", + "type": "number", + "value": "3117" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "shareCount", + "type": "number", + "value": "534" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "commentCount", + "type": "number", + "value": "355" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "bookmarkCount", + "type": "number", + "value": "81" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "viewCount", + "type": "number", + "value": "335996" + }, + { + "context": "https://x.com/jokowi/status/1946955377771450714", + "name": "createdAt", + "type": "datetime", + "value": "2025-07-20T15:28:29.000Z" + } + ], + "savedAt": "2025-07-22T12:48:10.098Z", + "type": "post", + "url": "https://x.com/jokowi" + } +] \ No newline at end of file diff --git a/example-json/youtube-post.json b/example-json/youtube-post.json new file mode 100644 index 0000000..ca172e1 --- /dev/null +++ b/example-json/youtube-post.json @@ -0,0 +1,664 @@ +[ + { + "facts": [ + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "title", + "type": "string", + "value": "Kumpulan Skak Balik Jokowi ke Prabowo di Debat Keempat Pilpres 2019" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "description", + "type": "string", + "value": "Untuk melihat video-video menarik lainnya kunjungi: https://video.medcom.id/ Dua kandidat capres Joko Widodo dan Prabowo Subianto beradu gagasan dalam debat keempat Pilpres 2019. Debat kali ini mengusung tema ideologi, pemerintahan, keamanan serta hubungan internasional. Siapa unggul pada debat kali ini?" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "date", + "type": "datetime", + "value": "2019-03-30T11:41:18-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "duration", + "type": "timestr", + "value": "PT1303S" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/_joLYHj0i3o/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "viewCount", + "type": "number", + "value": "10535799" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o", + "name": "likeCount", + "type": "number", + "value": "94857" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV", + "name": "commentCount", + "type": "number", + "value": "32029" + } + ], + "savedAt": "2025-07-22T12:57:02.567Z", + "type": "post", + "url": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV" + }, + { + "facts": [ + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hambaallah5591", + "name": "username", + "type": "string", + "value": "@hambaallah5591" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hambaallah5591", + "name": "content", + "type": "string", + "value": "Jangan lupa p prawono tgl 17april hadir dalam pelantikan bpk jokowi untuk 2 preode yang setuju mana like nya" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hambaallah5591", + "name": "likeCount", + "type": "number", + "value": "467" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hambaallah5591", + "name": "commentCount", + "type": "number", + "value": "8" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hambaallah5591", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@aztycancer7759", + "name": "username", + "type": "string", + "value": "@aztycancer7759" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@aztycancer7759", + "name": "content", + "type": "string", + "value": "Hhhhhhhh secara tdk langsung mengakui klo Dia non.....???.\nMulai Emosi ya pak..... Smgt Bpk jokowi rakyat yg cerdas pasti mendukung bpk" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@aztycancer7759", + "name": "likeCount", + "type": "number", + "value": "356" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@aztycancer7759", + "name": "commentCount", + "type": "number", + "value": "10" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@aztycancer7759", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sasmithanadra3103", + "name": "username", + "type": "string", + "value": "@sasmithanadra3103" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sasmithanadra3103", + "name": "content", + "type": "string", + "value": "Inti Debat ,\nPrabowo Maha benar,kemudiaan yg dia lakukan menyalahkan Pak Jokowi.\nKarena itu,sbg Imbalan.\nSaya Coblos No.01\nYg setuju 1..\n#LIKE ๐Ÿ–’" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sasmithanadra3103", + "name": "likeCount", + "type": "number", + "value": "205" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sasmithanadra3103", + "name": "commentCount", + "type": "number", + "value": "2" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sasmithanadra3103", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ", + "name": "username", + "type": "string", + "value": "@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ", + "name": "content", + "type": "string", + "value": "Siapa yg disini mampir lagi setelah melihat debat semalam" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ", + "name": "likeCount", + "type": "number", + "value": "84" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ", + "name": "commentCount", + "type": "number", + "value": "7" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@ุนุฑูุงู†ุงู„ุฒู‡ุฑูŠ", + "name": "createdAt", + "type": "timestr", + "value": "1 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@naylaandini8808", + "name": "username", + "type": "string", + "value": "@naylaandini8808" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@naylaandini8808", + "name": "content", + "type": "string", + "value": "Jokowi = like\nPrabowo = komen" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@naylaandini8808", + "name": "likeCount", + "type": "number", + "value": "7200" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@naylaandini8808", + "name": "commentCount", + "type": "number", + "value": "116" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@naylaandini8808", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@andrialdytaqwa9291", + "name": "username", + "type": "string", + "value": "@andrialdytaqwa9291" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@andrialdytaqwa9291", + "name": "content", + "type": "string", + "value": "militer indonesia masuk 15 besar di dunia masih dibilang rapuh\n\n\nlike kalau setuju" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@andrialdytaqwa9291", + "name": "likeCount", + "type": "number", + "value": "338" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@andrialdytaqwa9291", + "name": "commentCount", + "type": "number", + "value": "19" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@andrialdytaqwa9291", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@pegijasnil1596", + "name": "username", + "type": "string", + "value": "@pegijasnil1596" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@pegijasnil1596", + "name": "content", + "type": "string", + "value": "Pak jokowi memang cocok jadi presiden...soalnya pak jokowi membuat nengara ini semakin semangat maju. Maaf pak dulu saya gak milih bapak tapi sakarang saya pilih bapak" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@pegijasnil1596", + "name": "likeCount", + "type": "number", + "value": "260" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@pegijasnil1596", + "name": "commentCount", + "type": "number", + "value": "2" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@pegijasnil1596", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@davidabhafidz3556", + "name": "username", + "type": "string", + "value": "@davidabhafidz3556" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@davidabhafidz3556", + "name": "content", + "type": "string", + "value": "Ada ga yg nonton ini di bulan januari 2024" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@davidabhafidz3556", + "name": "likeCount", + "type": "number", + "value": "121" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@davidabhafidz3556", + "name": "commentCount", + "type": "number", + "value": "35" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@davidabhafidz3556", + "name": "createdAt", + "type": "timestr", + "value": "1 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@merchandisingawesome2600", + "name": "username", + "type": "string", + "value": "@merchandisingawesome2600" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@merchandisingawesome2600", + "name": "content", + "type": "string", + "value": "Ini seperti curhatan warga ke president \nWarga = Prabowo\nPresiden = Pak Jokowi" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@merchandisingawesome2600", + "name": "likeCount", + "type": "number", + "value": "138" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@merchandisingawesome2600", + "name": "commentCount", + "type": "number", + "value": "5" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@merchandisingawesome2600", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rezanajwa9999", + "name": "username", + "type": "string", + "value": "@rezanajwa9999" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rezanajwa9999", + "name": "content", + "type": "string", + "value": "Prabowo terus bicara masa lalunya(bicata ttg tentara terus).sementara jokowi bicara ttg pencapaian yg sdh di lakukan juga bicara masa depan..sangat kontras...(sy tdk mendengar jokowi bicara ttg usahanya)" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rezanajwa9999", + "name": "likeCount", + "type": "number", + "value": "373" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rezanajwa9999", + "name": "commentCount", + "type": "number", + "value": "13" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rezanajwa9999", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sukasukabro1137", + "name": "username", + "type": "string", + "value": "@sukasukabro1137" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sukasukabro1137", + "name": "content", + "type": "string", + "value": "saya nangkapnya kog, malah prabowo bukan memberi gagasan untuk indonesia, tapi menyangkal semua gagasan/ ide2 nya pak jokowi... semua nya di sangkal , di salahkan, tnpa membrikan gagasan yg lebih baik\n\nyg setuju like" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sukasukabro1137", + "name": "likeCount", + "type": "number", + "value": "156" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sukasukabro1137", + "name": "commentCount", + "type": "number", + "value": "7" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sukasukabro1137", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@alfarezialamsyah3975", + "name": "username", + "type": "string", + "value": "@alfarezialamsyah3975" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@alfarezialamsyah3975", + "name": "content", + "type": "string", + "value": "Ini yang namanya debat . Ngga kaya debat tahun 2024 yang menjatuhkan dan merendahkan." + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@alfarezialamsyah3975", + "name": "likeCount", + "type": "number", + "value": "17" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@alfarezialamsyah3975", + "name": "commentCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@alfarezialamsyah3975", + "name": "createdAt", + "type": "timestr", + "value": "1 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@IKEWAKI666", + "name": "username", + "type": "string", + "value": "@IKEWAKI666" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@IKEWAKI666", + "name": "content", + "type": "string", + "value": "Pak Wowo bahasanya muter\" diperang trs\nPak owi bahasnya sana sini lebih luas :v\nTrs ku lihat keknya Prabowo gk mikir buat kedepan nya bakal gimana kalo lakuin itu,\nPak Jokowi lebih bekerjasama dengan negara lain lebih berteman,pak Prabowo malah pengen Indonesia itu kek lakuin segalanya sendiri....kapan negara kita maju :v kapan negara kita bersaing kalo Indonesia cuma melakukan segalanya sendiri :v\nDunia ini bukan cuma perang :v" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@IKEWAKI666", + "name": "likeCount", + "type": "number", + "value": "290" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@IKEWAKI666", + "name": "commentCount", + "type": "number", + "value": "13" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@IKEWAKI666", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu (diedit)" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rafafebryan5626", + "name": "username", + "type": "string", + "value": "@rafafebryan5626" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rafafebryan5626", + "name": "content", + "type": "string", + "value": "Ada 3 golongan yg susah di nasehati\n\n1. Orang yg sedang jatuh cinta \n2. Orang yg sedang emosi\n3. #02" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rafafebryan5626", + "name": "likeCount", + "type": "number", + "value": "647" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rafafebryan5626", + "name": "commentCount", + "type": "number", + "value": "33" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@rafafebryan5626", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sarajevo2469", + "name": "username", + "type": "string", + "value": "@sarajevo2469" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sarajevo2469", + "name": "content", + "type": "string", + "value": "Tahun 1975 pak prabowo bilang ikut perang ke timor timor, gak salah pak!! Bukanya bapak wktu itu malah diasingkan ke bali, itu fakta lhoo pak" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sarajevo2469", + "name": "likeCount", + "type": "number", + "value": "460" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sarajevo2469", + "name": "commentCount", + "type": "number", + "value": "9" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@sarajevo2469", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@LFchannel-bb4uu", + "name": "username", + "type": "string", + "value": "@LFchannel-bb4uu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@LFchannel-bb4uu", + "name": "content", + "type": "string", + "value": "Hebatnya pak Jokowi..memilih pak prabowo sebagai menhanx yang benar2 mengetahui kondisi pertahanan dan keamanan negara kita...dan itu di buktikan oleh pak prabowo selama 5 tahun.. dan pak prabowo jg mengakui banyak belajar dr pak jokowi...mereka ini benar2 orang hebat yg di ciptakan untuk indonesia ๐Ÿซก" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@LFchannel-bb4uu", + "name": "likeCount", + "type": "number", + "value": "36" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@LFchannel-bb4uu", + "name": "commentCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@LFchannel-bb4uu", + "name": "createdAt", + "type": "timestr", + "value": "1 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@cici3644", + "name": "username", + "type": "string", + "value": "@cici3644" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@cici3644", + "name": "content", + "type": "string", + "value": "pilih prabowo hancur ~> komen\npilih jokowi makmur ~> like\n\nRakyat indonesia makin cerdas" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@cici3644", + "name": "likeCount", + "type": "number", + "value": "3200" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@cici3644", + "name": "commentCount", + "type": "number", + "value": "84" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@cici3644", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@surya247766", + "name": "username", + "type": "string", + "value": "@surya247766" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@surya247766", + "name": "content", + "type": "string", + "value": "Beda kelas brow... Meskipun pak dhe dibilang plonga plongo tapi kecerdasannya melebihi rivalnya..." + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@surya247766", + "name": "likeCount", + "type": "number", + "value": "3500" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@surya247766", + "name": "commentCount", + "type": "number", + "value": "204" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@surya247766", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hizkiakurniawan", + "name": "username", + "type": "string", + "value": "@hizkiakurniawan" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hizkiakurniawan", + "name": "content", + "type": "string", + "value": "Prabowo: saya, saya, saya, saya\nJokowi: kita, kita, kita, kita\nKeliatan yang mana yang mikirin diri sendiri" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hizkiakurniawan", + "name": "likeCount", + "type": "number", + "value": "2500" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hizkiakurniawan", + "name": "commentCount", + "type": "number", + "value": "52" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@hizkiakurniawan", + "name": "createdAt", + "type": "timestr", + "value": "6 tahun yang lalu" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@chintyaayumustika8842", + "name": "username", + "type": "string", + "value": "@chintyaayumustika8842" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@chintyaayumustika8842", + "name": "content", + "type": "string", + "value": "rindu debat ini , mereka berdua saat ini terbukti orang yang baik , sehat selalu pak jokowi dan pak prabowo" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@chintyaayumustika8842", + "name": "likeCount", + "type": "number", + "value": "75" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@chintyaayumustika8842", + "name": "commentCount", + "type": "number", + "value": "6" + }, + { + "context": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV#comment_by=@chintyaayumustika8842", + "name": "createdAt", + "type": "timestr", + "value": "1 tahun yang lalu" + } + ], + "savedAt": "2025-07-22T12:57:02.571Z", + "type": "comment", + "url": "https://www.youtube.com/watch?v=_joLYHj0i3o&ab_channel=METROTV" + } +] \ No newline at end of file diff --git a/example-json/youtube-profile.json b/example-json/youtube-profile.json new file mode 100644 index 0000000..f6b2f48 --- /dev/null +++ b/example-json/youtube-profile.json @@ -0,0 +1,1066 @@ +[ + { + "facts": [ + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "url", + "type": "url", + "value": "https://www.youtube.com/@metrotvnews/videos" + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "displayName", + "type": "string", + "value": "METRO TV " + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "username", + "type": "string", + "value": "metrotvnews" + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "profileImage", + "type": "url", + "value": "https://yt3.googleusercontent.com/qX0Or-6wqAYGXEPiWV6ELZmJGRm2JtOIYYd2r54UQqaDKFlBeezvfgT_b6SB3JZTS16RjANs=s160-c-k-c0x00ffffff-no-rj" + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "followerCount", + "type": "number", + "value": "10600000" + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "postCount", + "type": "number", + "value": "307000" + }, + { + "context": "https://www.youtube.com/@metrotvnews/videos", + "name": "description", + "type": "string", + "value": "Link\n\n![](https://encrypted-tbn2.gstatic.com/favicon-tbn?q=tbn:ANd9GcSd4pTTQhuulXwzKvJ1xO0BaIT21YLxyr2ADksAh_w7_obFoGU9ngSZi1uIn5LXj9lx53iD5mgD1baD8sohHFh7BV39Kzb-nI1L9On0LbjMkIxEQu9Ce3Vz)\n\nMetroTvNews[metrotvnews.com](https://www.youtube.com/redirect?event=channel_description&redir_token=QUFFLUhqa1AyZTZrbm5TN0ZPdUlhWkhRR0RGZzJPSnFRUXxBQ3Jtc0tuU3pHQTlUMllXTXM2dzhzQXJoRDZqVUtEVXpWX2tXYnRZdk5pNDBMSzFDVk9sRXpOcE9Ma3A5VjFuVFNsbG01T3BkNHM0VmtrV0VpSFppV1F4TlJsWEJEZkYwemNDMGlkWm43SDlnbmJOSzVTSkxEUQ&q=https%3A%2F%2Fwww.metrotvnews.com%2F)\n\n![](https://encrypted-tbn3.gstatic.com/favicon-tbn?q=tbn:ANd9GcTI-5PGHmIaNROji4Au1BCHWlIYYuBV-S5o10vNQOpO8bKYe_6qoBk4-ZuXaoJFuMvh4TIqxnwOPvBj23km7XY2pAb98oE7qC3Akz3TL6SOymq5kX9Qzg)\n\nmetrotv[instagram.com/metrotv/?hl=en](https://www.youtube.com/redirect?event=channel_description&redir_token=QUFFLUhqa1ZtM3hjUnhwUnd3Q003clBNaWhiSTQzeGN5Z3xBQ3Jtc0ttbFo4SHQ5dXk3MWJEbFM0aXpOaWhOWDB5ZzV4N3hvMFpfcmVNM1FTcHg5TG5uZmhYclNkNlBXb1M1UG9YcGJXd04wTHpMQnNvT1d1VzliTWQ0azRCTzgwc1lWWW5JalpVZzJxcnp5Z1pQTGFTdm91VQ&q=https%3A%2F%2Fwww.instagram.com%2Fmetrotv%2F%3Fhl%3Den)\n\n![](https://encrypted-tbn0.gstatic.com/favicon-tbn?q=tbn:ANd9GcSk6xnumQGuKRL6ntQgdSCexuIx-I_1vRfj2rzgm1iJXMUnsEfqrJybcxholpT5pGO-G6pWi_E-SmmiPvDCDDEG640KIPJiUwpizkxi648yRQ)\n\nMETRO TV[twitter.com/Metro\\_TV?ref\\_src=twsrc^google|twcamp^serp|twgr^author](https://www.youtube.com/redirect?event=channel_description&redir_token=QUFFLUhqa0QyRjRrV3dPM2V5MjlYVVVuSDVuZ0gtQVdzZ3xBQ3Jtc0tuMEpqSDFQNEptQUlRUTFmUFpvMERyYW1CYW9FTmJBdFB4QVVKN0J5NVFDU0s4d0tzbUp1dndGN0tReWcySkcxN1MzUXNQcGNGSURNU0RNekRZcEp3XzR4NjEtXzlrTnAzV0phdXF3TWhHTnJoQjQyMA&q=https%3A%2F%2Ftwitter.com%2FMetro_TV%3Fref_src%3Dtwsrc%255Egoogle%257Ctwcamp%255Eserp%257Ctwgr%255Eauthor)\n\n![](https://encrypted-tbn3.gstatic.com/favicon-tbn?q=tbn:ANd9GcTjE8gTHsd6LJrJNEm-VcrqEOzuiajMmEo-IXhmkXxj35vwUJA44kNbJCXo2f5bXPSVtUvTlVhQ-GOMpqRHA6gN28Az8qkd7xwYcDGCBPROyOVg97T-)\n\nMetro TV[facebook.com/metrotv](https://www.youtube.com/redirect?event=channel_description&redir_token=QUFFLUhqbjJVZ0FBYVlsNV9pWWRjeHg3VjM1SWpmU05KUXxBQ3Jtc0tsZlBjemUxcFloN3lKWlQ1dFZZMUtrNE1vWUlhdWp4N1FVblFvVjBZNGlLbmNQZmVrMmxGaFBtY0daaFE5MWowWk9XdktEdnB5dGJZdGdETEplY2hpTVJiVkNMWk43bkNRYkVJMnhCVWxhVmprQTF1aw&q=https%3A%2F%2Fwww.facebook.com%2Fmetrotv%2F)\n\n![](https://encrypted-tbn3.gstatic.com/favicon-tbn?q=tbn:ANd9GcSEII2IIRImodcWZgz02fSWMJAoch4dG8QOVwhrtt_QvRi6NJLy8IjW1yAqU5ugCsqWjUeYJzz8ETnsSGPCArXzGmknKGS3PlAQHOi_1GEZqoL3vw)\n\nmetro\\_tv[tiktok.com/@metro\\_tv?lang=en](https://www.youtube.com/redirect?event=channel_description&redir_token=QUFFLUhqbkJkNmV4U3NiQmdfeTRDaUJreThaaVkxNG9uZ3xBQ3Jtc0tsbUFDbG1ZNWFvNk1Zcnl0S1hjZjBadFVweDJZUDNMVDBGdkVUNG5ZZWlNRmQwblVuRXpxZ1g0NlNJUGpOWXBXWG9lQW5ad3d4eG5DUDdfSUc1Mk54Q3J2UG5jUXFnMUxQRWd4WkRDRmQ1R3kzZC1maw&q=https%3A%2F%2Fwww.tiktok.com%2F%40metro_tv%3Flang%3Den)\n\nInfo selengkapnya\n\n| | [www.youtube.com/@metrotvnews](http://www.youtube.com/@metrotvnews) |\n| | Indonesia |\n| | Bergabung pada 9 Okt 2007 |\n| | 10,6ย jt subscriber |\n| | 307.366 video |\n| | 6.224.491.959 x ditonton |" + } + ], + "savedAt": "2025-07-22T12:55:29.285Z", + "type": "profile", + "url": "https://www.youtube.com/@metrotvnews/videos" + }, + { + "facts": [ + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "title", + "type": "string", + "value": "TNI P3nemb4k 3 Polisi di Way Kanan Dituntut Hukuman M4ti - [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "description", + "type": "string", + "value": "MetroTV, Kopda Basyarsyah, pelaku p3nemb4kan 3 anggota polri saat penggerebekan judi sabung ayam di Way Kanan, Lampung, dituntut hukuman mati. Ia juga dipeca..." + }, + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "viewCount", + "type": "number", + "value": "1084" + }, + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "likeCount", + "type": "number", + "value": "15" + }, + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/2IFvzhRytbM/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=2IFvzhRytbM", + "name": "date", + "type": "datetime", + "value": "2025-07-22T02:53:10-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "title", + "type": "string", + "value": "World Premier JAECOO J8 SHS ARDIS di Indonesia | Primetime News" + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "description", + "type": "string", + "value": "MetroTV, JAECOO memilih Indonesia sebagai negara pertama di dunia untuk peluncuran JAECOO J8 SHS ARDIS yang digelar Senin, 21 Juli 2025 di Park Hyatt, Jakart..." + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "viewCount", + "type": "number", + "value": "37" + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/mRBsATheANM/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=mRBsATheANM", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:48:01-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "title", + "type": "string", + "value": "Trump Wants NFL, MLB Teams to Restore Controversial Names" + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "description", + "type": "string", + "value": "MetroTV, Former President Donald Trump is urging Washingtonโ€™s NFL team and Clevelandโ€™s MLB team to revert to their previous names, despite backlash from Indi..." + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "viewCount", + "type": "number", + "value": "36" + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/SX3DuGc39cY/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=SX3DuGc39cY", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:42:03-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "title", + "type": "string", + "value": "Saksi Kasus Ijazah, Kader PSI Ngaku Diintimidasi [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "description", + "type": "string", + "value": "MetroTV, Sementara kader PSI, Dian Sandi kembali diperiksa sebagai saksi dalam kasus dugaan fitnah ijazah Jokowi dan pencemaran nama baik usai pemeriksaan se..." + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "viewCount", + "type": "number", + "value": "70" + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "likeCount", + "type": "number", + "value": "2" + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/jNXvr6njsKU/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=jNXvr6njsKU", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:45:24-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "title", + "type": "string", + "value": "Kompolnas Dalami Posisi Grendel Pintu Kamar Arya Daru - [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "description", + "type": "string", + "value": "MetroTV, Selain memastikan posisi CCTV dan mendalami kantong plastik hitam yang dibuang Arya Daru, kompolnas juga mendalami posisi slot atau grendel yang ter..." + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "viewCount", + "type": "number", + "value": "442" + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "likeCount", + "type": "number", + "value": "6" + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/_l4MCSNWQVs/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=_l4MCSNWQVs", + "name": "date", + "type": "datetime", + "value": "2025-07-22T04:26:24-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "title", + "type": "string", + "value": "Pengadilan Negeri Sleman Buka Kembali Sidang Gugatan Ijazah Jokowi [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "description", + "type": "string", + "value": "MetroTV, Pengadilan Negeri Sleman, Daerah Istimewa Yogyakarta, membuka kembali sidang gugatan perbuatan melawan hukum atas terbitnya ijazah sarjana untuk Jok..." + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "viewCount", + "type": "number", + "value": "228" + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/DZG7aSA6Nio/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=DZG7aSA6Nio", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:29:54-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "title", + "type": "string", + "value": "Usai Ditahan di Myanmar, Selebgram AP Dideportasi - [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "description", + "type": "string", + "value": "MetroTV, Selebgram asal Indonesia berinisial AP yang ditahan di Myanmar karena melanggar UU anti-terorisme, telah dibebaskan dan dideportasi. DPR pun mendoro..." + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "viewCount", + "type": "number", + "value": "68" + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/JcV_P8AGKyo/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=JcV_P8AGKyo", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:30:22-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "title", + "type": "string", + "value": "Jokowi Minta Undur Jadwal karena Alasan Sakit - [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "description", + "type": "string", + "value": "MetroTV, Polda metro jaya menjadwalkan pemeriksaan terhadap mantan presiden, Jokowi terkait kasus fitnah ijazah Jokowi dan pencemaran nama baik, pada Kamis 2..." + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "viewCount", + "type": "number", + "value": "127" + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "likeCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/3av3bCu3jwo/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=3av3bCu3jwo", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:40:31-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "title", + "type": "string", + "value": "Banyak Pengganti, Langkah Vini di Madrid Bisa Terhenti!" + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "description", + "type": "string", + "value": "MetroTV, Vinicius Jr kembali jadi sorotan usai kabar ketertarikan sejumlah klub Liga Arab menguat. Tawaran fantastis siap diajukan untuk menggoda sang bintan..." + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "viewCount", + "type": "number", + "value": "105" + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/9Lc0912MifI/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=9Lc0912MifI", + "name": "date", + "type": "datetime", + "value": "2025-07-22T04:08:58-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "title", + "type": "string", + "value": "Verifikasi Data di TKP, Kompolnas Cek Kamar Kos Diplomat Arya Daru - [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "description", + "type": "string", + "value": "MetroTV, Komisi kepolisian nasional mendatangi lokasi penemuan j3naz4h diplomat kementerian luar negeri, Arya Daru Pangayunan, di guest house Gondia, jalan G..." + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "viewCount", + "type": "number", + "value": "7914" + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "likeCount", + "type": "number", + "value": "57" + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/KATKedSpUVI/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=KATKedSpUVI", + "name": "date", + "type": "datetime", + "value": "2025-07-22T02:55:53-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "title", + "type": "string", + "value": "Cek Indekos Arya Daru, Kompolnas Telah Mengetahui Isi Plastik Hitam yang Dibuang - [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "description", + "type": "string", + "value": "MetroTV, Komisi kepolisian nasional mendatangi lokasi penemuan jenazah diplomat kementerian luar negeri, Arya Daru Pangayunan, di guest house Gondia, jalan G..." + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "viewCount", + "type": "number", + "value": "1059" + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "likeCount", + "type": "number", + "value": "12" + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/zt58Hr6NXa8/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=zt58Hr6NXa8", + "name": "date", + "type": "datetime", + "value": "2025-07-22T04:25:53-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "title", + "type": "string", + "value": "Nahkoda KM Barcelona jadi Tersangka, Data Penumpang Tak Sesuai" + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "description", + "type": "string", + "value": "MetroTV, Nahkoda KM Barcelona resmi jadi tersangka! Data manifes penumpang tak sesuai, memunculkan pertanyaan besar soal kelalaian dan keselamatan pelayaran...." + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "viewCount", + "type": "number", + "value": "286" + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "likeCount", + "type": "number", + "value": "3" + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/pXY-la4PmYE/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=pXY-la4PmYE", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:01:08-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "title", + "type": "string", + "value": "Penyelidikan Kasus DIplomat Kemlu Terus Berjalan & Beragam Analisa Muncul, Lalu Apa Hasilnya?" + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "description", + "type": "string", + "value": "MetroTV, Lebih dari satu pekan penyelidikan kasus ini berjalan, beragam analisa soal penyebab k3mat1an arya daru mengemuka. ada yang menyebut kasus ini punya..." + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "viewCount", + "type": "number", + "value": "390" + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "likeCount", + "type": "number", + "value": "5" + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/d6vcd_SdcuU/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=d6vcd_SdcuU", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:02:39-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "title", + "type": "string", + "value": "Tom Lembong Ajukan Permohonan Banding [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "description", + "type": "string", + "value": "MetroTV, Terdakwa kasus dugaan korupsi impor gula, Thomas Trikasih Lembong, resmi mengajukan banding atas vonis hakim Pengadilan Negeri Jakarta Pusat yang me..." + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "viewCount", + "type": "number", + "value": "613" + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "likeCount", + "type": "number", + "value": "12" + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/fhgm_DAOSi4/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=fhgm_DAOSi4", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:46:46-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "title", + "type": "string", + "value": "KPK Berpeluang Periksa Nadiem Makarim Terkait Kasus Pengadaan Google Cloud [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "description", + "type": "string", + "value": "MetroTV, Komisi Pemberantasan Korupsi (KPK) berpeluang memeriksa mantan Mendikbudristek, Nadiem Makarim, untuk mendalami kasus dugaan korupsi pengadaan layan..." + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "viewCount", + "type": "number", + "value": "227" + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "likeCount", + "type": "number", + "value": "4" + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/a1kRCRXG9Nw/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=a1kRCRXG9Nw", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:41:23-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "title", + "type": "string", + "value": "Jelang Semifinal, Lini Depan Garuda Muda Disorot - [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "description", + "type": "string", + "value": "MetroTV, Meski lolos ke babak semifinal Piala AFF U-23 2025 dengan menahan imbang nol-nol saat melawan Timnas Malaysia U-23, namun lini serang Timnas Garuda ..." + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "viewCount", + "type": "number", + "value": "886" + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "likeCount", + "type": "number", + "value": "17" + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/pjX5EGU_vNU/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=pjX5EGU_vNU", + "name": "date", + "type": "datetime", + "value": "2025-07-22T04:28:53-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "title", + "type": "string", + "value": "Geng Motor S3r4ng Petugas Parkir RS Tentara - [Newsline]" + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "description", + "type": "string", + "value": "MetroTV, Tim gabungan Resmob Polresta dan Polda Bengkulu mengamankan 13 remaja anggota geng motor yang menjadi pelaku penyerangan terhadap petugas parkir di ..." + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "viewCount", + "type": "number", + "value": "461" + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "likeCount", + "type": "number", + "value": "4" + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/-hbsldz08MY/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=-hbsldz08MY", + "name": "date", + "type": "datetime", + "value": "2025-07-22T02:42:51-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "title", + "type": "string", + "value": "Alasan Kesehatan, Jokowi Minta Tunda Pemeriksaan [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "description", + "type": "string", + "value": "MetroTV, Mantan Presiden Joko Widodo meminta agar pemeriksaan dirinya terkait laporan fitnah ijazah palsu oleh Polda Metro Jaya dijadwalkan ulang. Pihak Joko..." + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "viewCount", + "type": "number", + "value": "230" + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "likeCount", + "type": "number", + "value": "5" + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/N1QjCu8gtb0/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=N1QjCu8gtb0", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:37:52-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "title", + "type": "string", + "value": "[FULL] Dialog - Pesta Anak KDM Berujung Maut, Siapa Tanggung Jawab? [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "description", + "type": "string", + "value": "MetroTV, Pesta pernikahan anak Gubernur Dedi Mulyadi, menimbulkan tiga korban jiwa. Hingga berita ini dibuat, belum ada satu tersangka pun dari peristiwa ter..." + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "viewCount", + "type": "number", + "value": "6052" + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "likeCount", + "type": "number", + "value": "56" + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/Bm4OS_x4YCg/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=Bm4OS_x4YCg", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:07:00-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "title", + "type": "string", + "value": "Venezuelan Families Reunite After Migrants Freed From El Salvadorโ€™s Mega-Prison" + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "description", + "type": "string", + "value": "MetroTV, Over 250 Venezuelan migrants have returned home after being released from El Salvadorโ€™s mega-prison, CECOT. Their release was part of a diplomatic e..." + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "viewCount", + "type": "number", + "value": "72" + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/2f14OsWjCvE/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=2f14OsWjCvE", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:40:25-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "title", + "type": "string", + "value": "Hanoi Streets Quiet Before Tropical Storm Wipha Hits Northern Vietnam" + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "description", + "type": "string", + "value": "MetroTV, Tropical Storm Wipha has made landfall in northern Vietnam, impacting provinces like Hung Yen and Ninh Binh with strong winds and heavy rain.#Vietna..." + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "viewCount", + "type": "number", + "value": "44" + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "likeCount", + "type": "number", + "value": "1" + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/8QRG9BgS4Ds/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=8QRG9BgS4Ds", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:38:38-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "title", + "type": "string", + "value": "INDEKOS ARYA DARU DISUSUPI? KOMPOLNAS TEMUKAN PETUNJUK KRUSIAL" + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "description", + "type": "string", + "value": "MetroTV, [FULL] Dialog - Indekos Arya Daru Disusupi? Kompolnas Temukan Petunjuk Krusial#diplomatkemlu #kasusdiplomatkemlu#kemlutewas #AryaDaru #kompolnas #b..." + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "viewCount", + "type": "number", + "value": "1778" + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "likeCount", + "type": "number", + "value": "40" + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/wg0soyZLWu0/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=wg0soyZLWu0", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:33:07-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "title", + "type": "string", + "value": "Rekaman CCTV Tak Utuh, Ada Jejak Orang Lain di K3m4t1an Diplomat Kemlu? - [Primetime News]" + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "description", + "type": "string", + "value": "MetroTV, Layaknya puzzle, potongan-potongan fakta kini tengah disusun tim penyidik polri untuk mengetahui sebab pasti k3mat1an diplomat muda kemlu - Arya Dar..." + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "viewCount", + "type": "number", + "value": "488" + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "likeCount", + "type": "number", + "value": "6" + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/xqZJ6SI0Jgc/hqdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=xqZJ6SI0Jgc", + "name": "date", + "type": "datetime", + "value": "2025-07-22T05:25:42-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "title", + "type": "string", + "value": "Prabowo Sebut Hubungan Gerindra PDIP Ibarat Kakak-Adik di Depan Puan Maharani [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "description", + "type": "string", + "value": "MetroTV, Di depan Puan Maharani, Presiden Prabowo melontarkan seloroh yang mengundang tawa. Ia mengibaratkan hubungan Partai Gerindra dengan PDI Perjuangan s..." + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "viewCount", + "type": "number", + "value": "397" + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "likeCount", + "type": "number", + "value": "5" + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/gVV5ObKlloM/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=gVV5ObKlloM", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:36:52-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "title", + "type": "string", + "value": "Pernyataan Dedi Mulyadi Sebelum & Sesudah Tragedi [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "description", + "type": "string", + "value": "MetroTV, Pesta pernikahan anak Gubernur Dedi Mulyadi, menimbulkan tiga korban jiwa. Hingga berita ini dibuat, belum ada satu tersangka pun dari peristiwa ter..." + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "viewCount", + "type": "number", + "value": "839" + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "likeCount", + "type": "number", + "value": "9" + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/NJBywpWRC5c/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=NJBywpWRC5c", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:05:50-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "title", + "type": "string", + "value": "Makan Makanan Basi, Netanyahu Sakit Radang Usus [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "description", + "type": "string", + "value": "MetroTV, Netanyahu dilaporkan mengalami keracunan makanan di tengah sidang korupsinya. Ia selama ini mengulur waktu dengan menyerang ke sejumlah negara untuk..." + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "viewCount", + "type": "number", + "value": "517" + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "likeCount", + "type": "number", + "value": "12" + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/1jFNAn57hTU/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=1jFNAn57hTU", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:39:34-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "title", + "type": "string", + "value": "Hari Ketiga, Pencarian Korban Kapal Barcelona Masih Nihil [Metro Hari Ini]" + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "description", + "type": "string", + "value": "MetroTV, Hari ketiga pencarian korban KM Barcelona masih nihil. Tim SAR terus bahu-membahu di laut dan darat, berharap ada titik terang. Semoga semuanya sege..." + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "viewCount", + "type": "number", + "value": "481" + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "likeCount", + "type": "number", + "value": "4" + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/XbGsG-S6rds/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=XbGsG-S6rds", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:30:58-07:00" + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "title", + "type": "string", + "value": "Giant Trash Trolls Are Here To Save Humans From Themselves" + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "description", + "type": "string", + "value": "MetroTV, Six massive wooden trolls are hiding in the forests of California โ€” and they have a mission: save the humans.As part of Thomas Damboโ€™s worldwide art..." + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "viewCount", + "type": "number", + "value": "20" + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "likeCount", + "type": "number", + "value": "0" + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "thumbnail", + "type": "url", + "value": "https://i.ytimg.com/vi/DWDLkllSTog/maxresdefault.jpg" + }, + { + "context": "https://www.youtube.com/watch?v=DWDLkllSTog", + "name": "date", + "type": "datetime", + "value": "2025-07-22T03:37:23-07:00" + } + ], + "savedAt": "2025-07-22T12:55:29.319Z", + "type": "post", + "url": "https://www.youtube.com/@metrotvnews/videos" + } +] \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f67b2c6 --- /dev/null +++ b/index.ts @@ -0,0 +1 @@ +console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c79bf61 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1797 @@ +{ + "name": "new-browser-input", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "new-browser-input", + "dependencies": { + "@types/turndown": "^5.0.5", + "turndown": "^7.2.0", + "turndown-plugin-gfm": "^1.0.2" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/chrome": "^0.0.326", + "@typescript-eslint/eslint-plugin": "^8.34.0", + "@typescript-eslint/parser": "^8.34.0", + "eslint": "^9.28.0" + }, + "peerDependencies": { + "typescript": "^5.8.3" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.28.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", + "integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", + "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.14.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/bun": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.15.tgz", + "integrity": "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.15" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.326", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.326.tgz", + "integrity": "sha512-WS7jKf3ZRZFHOX7dATCZwqNJgdfiSF0qBRFxaO0LhIOvTNBrfkab26bsZwp6EBpYtqp8loMHJTnD6vDTLWPKYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz", + "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/turndown": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", + "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", + "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/type-utils": "8.34.0", + "@typescript-eslint/utils": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.34.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", + "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/typescript-estree": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", + "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.34.0", + "@typescript-eslint/types": "^8.34.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", + "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", + "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", + "integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.34.0", + "@typescript-eslint/utils": "8.34.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", + "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", + "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.34.0", + "@typescript-eslint/tsconfig-utils": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", + "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/typescript-estree": "8.34.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", + "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.34.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bun-types": { + "version": "1.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.28.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", + "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.28.0", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, + "node_modules/turndown-plugin-gfm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz", + "integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==", + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb17bb8 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "new-browser-input", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest", + "@types/chrome": "^0.0.326", + "@typescript-eslint/eslint-plugin": "^8.34.0", + "@typescript-eslint/parser": "^8.34.0", + "eslint": "^9.28.0" + }, + "peerDependencies": { + "typescript": "^5.8.3" + }, + "dependencies": { + "@types/turndown": "^5.0.5", + "turndown": "^7.2.0", + "turndown-plugin-gfm": "^1.0.2" + } +} diff --git a/public/extension.zip b/public/extension.zip new file mode 100644 index 0000000..b05dba1 Binary files /dev/null and b/public/extension.zip differ diff --git a/resultFacebook.js b/resultFacebook.js new file mode 100644 index 0000000..5e363dd --- /dev/null +++ b/resultFacebook.js @@ -0,0 +1,51 @@ +[ + { + "facts": [ + { + "context": "https://www.facebook.com", + "name": "fb_post_source_url", + "type": "url", + "value": "https://www.facebook.com" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_profile_url", + "type": "url", + "value": "https://www.facebook.com/KOMPAScom" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_display_name", + "type": "name", + "value": "Kompas.com" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_content_md", + "type": "md", + "value": "[photo](https://scontent.fcgk18-1.fna.fbcdn.net/v/t39.30808-6/552480808_1307882211367890_924495273445433584_n.jpg?stp=dst-jpg_p526x296_tt6&_nc_cat=111&ccb=1-7&_nc_sid=127cfc&_nc_ohc=rmt_4eIvftYQ7kNvwGDecy3&_nc_oc=Adm4VTjN2xcbzrjqarwQfh5wArl6OaDDOgge44bwbwu0mLE98HEo_LPjTey2sV0LdR0&_nc_zt=23&_nc_ht=scontent.fcgk18-1.fna&_nc_gid=pbxoFcISGk0l9vQ7hd4buA&oh=00_AfYyGjiCbgz-zfzTKp3PpfjEbbNNpJuy-9OaKb9v4juRdA&oe=68DC6CFD)\n\nPresiden RI Prabowo Subianto menyatakan Indonesia akan mengakui Israel bila Israel mengakui Palestina.\n\nBaca selengkapnya ![๐Ÿ‘‡](https://static.xx.fbcdn.net/images/emoji.php/v9/t4f/1/16/1f447.png)\n\n[https://nasional.kompas.com/.../prabowo-di-ktt-pbb-jika...](https://l.facebook.com/l.php?u=https%3A%2F%2Fnasional.kompas.com%2Fread%2F2025%2F09%2F23%2F05583471%2Fprabowo-di-ktt-pbb-jika-israel-akui-palestina-ri-akui-israel%3Ffbclid%3DIwZXh0bgNhZW0CMTAAYnJpZBExMGVEc0Y0eGxpdGdFcE5IQwEeY5f-Tnn1TkSGDkCAf9jxR_RaW2RZ_3eGhCiWBqDNMh9tPrSqhpvkaFhZioE_aem_8X5jz7VulHNDODRDBDC2cg&h=AT2u2bZowgyWIp1xOwLXROAsM_lcjBzkuGfLKKW6LCSoV-IOzOX8FSJGw96PcCaAG-YS4DP56vMdbVPzT7eiO8XcZO3kYYZ3Ewhz14uNm1e1DGm-CPAUaufbKpdsY4nXFIz3fBvl&__tn__=-UK-R&c[0]=AT3_uBBuaqdxsDtcTjjYawf8809vYPadik6-z9Yzi-N6-vQk7SiN8BHlTARL6iWFy1OHDAKqF9be7khVqDBUwjBLM8-4Jzxffgp5KVuzioPJZNRS1423JuqTb1zFMdzZFXhh4paVd8ZdppQpMOusBoNJSqa5SmxwZlTo99n9lnWaY2CdW6WYnIgXLWcr3AGS1tQOmNn6OqprGAu8p4Cg08s86w)\n\n~LL [#Palestina](https://www.facebook.com/hashtag/palestina?__cft__[0]=AZVIekghHa8vSfH_P_pyOdhfBlcsgze3ax0vSHyQF_kQfC4UMzScNJ0un_oGTcS2oT8CUCeiKEQNURVZW_ZdWdErLCTqfK1g-dziSlyk2J1mLbZsaFKZvbto7CADJCqITYqSZ4MHoa6fFH_PCYQ2NbJ1nYY1V5iIepUval71zIAiPyptkTlNxErtcxGAaMpQNaI&__tn__=*NK-R) [#Prabowo](https://www.facebook.com/hashtag/prabowo?__cft__[0]=AZVIekghHa8vSfH_P_pyOdhfBlcsgze3ax0vSHyQF_kQfC4UMzScNJ0un_oGTcS2oT8CUCeiKEQNURVZW_ZdWdErLCTqfK1g-dziSlyk2J1mLbZsaFKZvbto7CADJCqITYqSZ4MHoa6fFH_PCYQ2NbJ1nYY1V5iIepUval71zIAiPyptkTlNxErtcxGAaMpQNaI&__tn__=*NK-R)" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_like_count", + "type": "integer", + "value": "15000" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_comment_count", + "type": "integer", + "value": "7500" + }, + { + "context": "https://www.facebook.com", + "name": "fb_post_share_count", + "type": "integer", + "value": "2000" + } + ], + "savedAt": "2025-09-26T15:01:59.619Z", + "type": "post", + "url": "https://www.facebook.com/search/top?q=prabowo" + } +] \ No newline at end of file diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..6ffdebe --- /dev/null +++ b/src/background.ts @@ -0,0 +1,128 @@ + +chrome.runtime.onInstalled.addListener(() => { + // You can perform any setup tasks here, like creating default settings +} +); + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'saveData') { + interface SaveDataItem { + type: string; + url: string; + [key: string]: any; + } + + const dataArray: SaveDataItem[] = request.data as SaveDataItem[]; + + console.log('Received data for saving:', dataArray); + + + dataArray.forEach((item: SaveDataItem) => { + sendDataToServer(item, item.type, item.url); + }); + + async function sendDataToServer(data: any, type: string, url: string) { + if (!data || !type) { + console.error('Invalid data or type for sending to server:', { data, type }); + return; + } + + var resolvedUrl = await resolveUrl(type, url); + + console.log(`Sending data to ${resolvedUrl}:`); + + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, application/problem+json' + }, + body: JSON.stringify(data) + }; + + try { + console.log(`Sending data to ${resolvedUrl}:`); + + const response = await fetch(resolvedUrl, options).then(res => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res; + }); + const responseData = await response.json(); + console.log(responseData); + + } catch (error) { + console.error(error); + } + + } + + async function resolveUrl(type: string, platform: string): Promise { + const platformMap: Record = { + "facebook.com": 'fb', + "instagram.com": 'ig', + "youtube.com": 'yt', + "linkedin.com": 'li', + "x.com": 'x', + "tiktok.com": 'tt' + }; + + // Get apiURL from storage asynchronously + const apiUrl = await new Promise((resolve) => { + chrome.storage.local.get('apiURL', (result) => { + const url = result.apiURL || 'https://dropper.nakhari.us/'; + console.log('Retrieved apiURL from storage:', url); + resolve(url); + }); + }); + + + // clean https://instagram.com/p/DMVZrA6hg4s/ + // https://instagram.com/ + const cleanedUrl = platform.replace(/https?:\/\//, '').replace(/www\./, '').split('/')[0]; + + const baseUrls: Record = { + profile: `${apiUrl}${platformMap[cleanedUrl]}/profile`, + post: `${apiUrl}${platformMap[cleanedUrl]}/post`, + comment: `${apiUrl}${platformMap[cleanedUrl]}/comment`, + }; + + console.log('Final URL:', baseUrls[type]); + return baseUrls[type]; + } + + // Save to chrome.storage.session (Chrome 102+) + if (chrome.storage && chrome.storage.session) { + const timestamp = new Date().toISOString(); + const key = `session_data_${timestamp}`; + + chrome.storage.session.set({ [key]: request.data }, () => { + if (chrome.runtime.lastError) { + console.error('Error saving to session storage:', chrome.runtime.lastError); + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + // Kirim data kembali untuk ditampilkan otomatis + sendResponse({ success: true, key: key, data: request.data }); + } + }); + } else { + // Fallback ke local storage jika session storage tidak tersedia + console.warn('Session storage not available, using local storage'); + const timestamp = new Date().toISOString(); + const key = `local_data_${timestamp}`; + + chrome.storage.local.set({ [key]: request.data }, () => { + if (chrome.runtime.lastError) { + sendResponse({ success: false, error: chrome.runtime.lastError.message }); + } else { + // Kirim data kembali untuk ditampilkan otomatis + sendResponse({ success: true, key: key, data: request.data }); + } + }); + } + + return true; // Important for async response + } +}); \ No newline at end of file diff --git a/src/contentScript.ts b/src/contentScript.ts new file mode 100644 index 0000000..77f44c6 --- /dev/null +++ b/src/contentScript.ts @@ -0,0 +1,259 @@ +import XModule from './modules/x'; +import TiktokModule from './modules/tiktok'; +import LinkedInModule from './modules/linkedin'; +import YTModule from './modules/youtube'; +import FacebookModule from './modules/facebook'; +import InstagramModule from './modules/instagram'; + + + +function getActiveModule() { + const hostname = window.location.hostname; + if (hostname.includes('tiktok.com')) return new TiktokModule(); + if (hostname.includes('x.com')) return new XModule(); + if (hostname.includes('linkedin.com')) return new LinkedInModule(); + if (hostname.includes('youtube.com')) return new YTModule(); + if (hostname.includes('facebook.com')) return new FacebookModule(); + if (hostname.includes('instagram.com')) return new InstagramModule(); + + return null; +} + +function createToggleButton(): void { + // Hindari duplikat + if (document.getElementById('my-ext-toggle-btn')) return; + + const btn = document.createElement('button'); + btn.id = 'my-ext-toggle-btn'; + btn.textContent = '<'; + btn.style.cssText = ` + position: fixed; + top: 100px; + right: 0; + z-index: 2147483646;; + width: 40px; + height: 40px; + background: #333; + color: #fff; + border: none; + border-radius: 4px; + font-size: 18px; + cursor: pointer; + `; + + btn.addEventListener('click', () => { + const sidebar = document.getElementById('my-extension-sidepanel'); + if (sidebar) { + sidebar.remove(); + } else { + createSidebarPanel(); + } + }); + + document.body.appendChild(btn); +} + +const activeModule = getActiveModule(); + +// Track URL changes for SPA navigation +let currentUrl = window.location.href; + +function reloadSidebarOnUrlChange(): void { + // Check for URL changes every 500ms + setInterval(() => { + if (window.location.href !== currentUrl) { + currentUrl = window.location.href; + console.log('URL changed, reloading sidebar...'); + + // Remove existing sidebar + const existingSidebar = document.getElementById('my-extension-sidepanel'); + if (existingSidebar) { + existingSidebar.remove(); + } + + // Create new sidebar after a small delay to ensure page content is loaded + setTimeout(() => { + createSidebarPanel(); + }, 500); + } + }, 500); +} + +// Also listen for browser navigation events +// window.addEventListener('popstate', () => { +// console.log('Browser navigation detected, reloading sidebar...'); + +// setTimeout(() => { +// const existingSidebar = document.getElementById('my-extension-sidepanel'); +// if (existingSidebar) { +// existingSidebar.remove(); +// } +// createSidebarPanel(); +// }, 500); +// }); + +// Listen for pushstate/replacestate (used by SPAs) +const originalPushState = history.pushState; +const originalReplaceState = history.replaceState; + +// history.pushState = function (...args) { +// originalPushState.apply(history, args); +// setTimeout(() => { +// console.log('SPA navigation detected (pushState), reloading sidebar...'); +// const existingSidebar = document.getElementById('my-extension-sidepanel'); +// if (existingSidebar) { +// existingSidebar.remove(); +// } +// createSidebarPanel(); +// }, 500); +// }; + +// history.replaceState = function (...args) { +// originalReplaceState.apply(history, args); +// setTimeout(() => { +// console.log('SPA navigation detected (replaceState), reloading sidebar...'); +// const existingSidebar = document.getElementById('my-extension-sidepanel'); +// if (existingSidebar) { +// existingSidebar.remove(); +// } +// createSidebarPanel(); +// }, 500); +// }; + +function createSidebarPanel(): void { + // Remove existing sidebar if any + const existingSidebar = document.getElementById('my-extension-sidepanel'); + if (existingSidebar) { + existingSidebar.remove(); + } + + const iframe = document.createElement('iframe'); + iframe.id = 'my-extension-sidepanel'; + iframe.src = chrome.runtime.getURL('popup/popup.html'); + iframe.style.position = 'fixed'; + iframe.style.top = '0'; + iframe.style.right = '0'; + iframe.style.width = '320px'; + iframe.style.minWidth = '320px'; + iframe.style.maxWidth = '320px'; + iframe.style.height = '100vh'; + iframe.style.zIndex = '2147483647'; + iframe.style.border = 'none'; + iframe.style.background = 'transparent'; + iframe.style.boxShadow = '0 0 8px rgba(0,0,0,0.15)'; + iframe.style.display = 'block'; + + document.body.appendChild(iframe); + + console.log('Sidebar panel created for:', window.location.href); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (!activeModule) { + console.warn('No active module found for this site'); + sendResponse({ success: false, error: 'No active module found' }); + return; + } + + switch (request.action) { + case 'toggleSidebar': + const sidebar = document.getElementById('my-extension-sidepanel'); + if (sidebar) { + sidebar.remove(); + } else { + createSidebarPanel(); + } + sendResponse({ success: true }); + break; + + case 'tagElement': + activeModule.tagElement(); + sendResponse({ success: true }); + console.log(resolveUrl('profile')); + break; + case 'saveData': + + // Handle async saveData for TikTok + (async () => { + try { + const data = await activeModule.saveData(); + + // data.forEach(item => { + // sendDataToServer(item, item.type); + // }) + + // Send to background for saving + chrome.runtime.sendMessage({ action: 'saveData', data }, (response) => { + if (response?.success) { + // Send data back to popup for automatic display + sendResponse({ success: true, data: response.data, key: response.key, saved: true }); + } else { + sendResponse({ success: false, error: 'Failed to save data' }); + } + }); + } catch (error) { + console.error('Error in saveData:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + sendResponse({ success: false, error: `Failed to fetch data: ${errorMessage}` }); + } + })(); + + return true; // Important: Indicates that the response is sent asynchronously + default: + sendResponse({ success: false, error: 'Unknown action' }); + } +}); + +async function sendDataToServer(data: any, type: string) { + if (!data || !type) { + console.error('Invalid data or type for sending to server:', { data, type }); + return; + } + + var url = resolveUrl(type); + + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, application/problem+json' + }, + body: JSON.stringify(data) + }; + + try { + const response = await fetch(url, options); + const data = await response.json(); + console.log(data); + } catch (error) { + console.error(error); + } + +} + +function resolveUrl(type: string): string { + + const platformMap: Record = { + "www.facebook.com": 'fb', + "www.instagram.com": 'ig', + "www.youtube.com": 'yt', + "www.linkedin.com": 'li', + "x.com": 'x', + "www.tiktok.com": 'tt' + }; + const apiUrl = 'http://192.168.10.176:8888/' + const baseUrls: Record = { + profile: `${apiUrl + platformMap[window.location.hostname]}/profile`, + post: `${apiUrl + platformMap[window.location.hostname]}/post`, + comment: `${apiUrl + platformMap[window.location.hostname]}/comment`, + }; + + return baseUrls[type]; +} + + +// Initialize sidebar and start monitoring +createSidebarPanel(); +createToggleButton(); +// reloadSidebarOnUrlChange(); \ No newline at end of file diff --git a/src/helper/parseCount.ts b/src/helper/parseCount.ts new file mode 100644 index 0000000..f5503f2 --- /dev/null +++ b/src/helper/parseCount.ts @@ -0,0 +1,61 @@ +export function parseCount(value: string): string { + if (!value || value.trim() === "") return "0"; + + let s = value.toLowerCase().trim(); + + // --- 1) Deteksi multiplier (satuan) + const units: Record = { + jt: 1_000_000, juta: 1_000_000, m: 1_000_000, mio: 1_000_000, + rb: 1_000, ribu: 1_000, k: 1_000, + b: 1_000_000_000, miliar: 1_000_000_000, milyar: 1_000_000_000, + }; + + let multiplier = 1; + for (const [key, mult] of Object.entries(units)) { + if (s.includes(key)) { + multiplier = mult; + // Hapus label satuan agar angka mudah diparse + s = s.replace(new RegExp(key, "g"), ""); + } + } + + // --- 2) Normalisasi pemisah ribuan/desimal + // Kasus A: punya titik & koma -> anggap '.' ribuan, ',' desimal => buang '.', ganti ',' -> '.' + if (s.includes(".") && s.includes(",")) { + s = s.replace(/\./g, "").replace(/,/g, "."); + } else { + // Kasus B: hanya koma + const hasOnlyComma = s.includes(",") && !s.includes("."); + // Jika ada pola ribuan '1,234' (barat), buang koma. + if (hasOnlyComma && /\b\d{1,3}(,\d{3})+\b/.test(s)) { + s = s.replace(/,/g, ""); + } else if (hasOnlyComma) { + // Anggap koma = desimal (Indonesia) + s = s.replace(/,/g, "."); + } + // Kasus C: hanya titik -> cek apakah pola ribuan Indonesia '1.234' atau desimal + if (!s.includes(",") && s.includes(".")) { + if (/\b\d{1,3}(\.\d{3})+\b/.test(s)) { + // jelas ribuan -> hapus titik + s = s.replace(/\./g, ""); + } + // else: biarkan '.' sebagai desimal + } + } + + // --- 3) Tangani pola ribuan besar yang tersisa (jaga-jaga) + if (/\d+\.\d+\.\d+/.test(s)) { + s = s.replace(/[^\d]/g, ""); // ambil digit saja + } + + // --- 4) Ambil angka pertama (boleh desimal tunggal) + const m = s.match(/(\d+(?:\.\d+)?)/); + if (!m) return "0"; + + const num = parseFloat(m[1]); + if (Number.isNaN(num)) return "0"; + + // --- 5) Hasil akhir + const out = Math.round(num * multiplier); + return out.toString(); +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..c896aba --- /dev/null +++ b/src/index.html @@ -0,0 +1,10 @@ + + + + + Document + + + + + \ No newline at end of file diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..4432c75 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,63 @@ + +interface PostResult { + ulid?: string; + url: string; + post: Post ; + uploadedAt: string; + comments: Comment[]; +} + +interface Post { + ulid?:string; + url:string + username?: string; + displayName: string; + content: string; + type: string; + likeCount:number; + shareCount:number; + commentCount:number; + viewCount?:number; //tiktok, x + bookmarkCount?:number; + savedAt?: string; // if format "6h ago" + date?: string; +} + +interface Comment{ + ulid?: string; + url?:string; + username?: string; + postUrl:string; + displayName: string; + content: string; + type: string; + likeCount:number; + shareCount?:number; + commentCount:number; + savedAt?: string; + +} + +interface ProfileResult { + ulid?:string; + url: string; + type: string; + profile: Profile; + uploadedAt: string; + posts: Post[]; +} + +interface Profile { + ulid?:string; + username?: string; + displayName: string; + description: string; + image: string; + createdAt?: string; // x only + followingCount:number; + followerCount:number; + postCount?:number; + likeCount?:number; +} + +export type { PostResult, Post, Comment, ProfileResult, Profile }; \ No newline at end of file diff --git a/src/mark.css b/src/mark.css new file mode 100644 index 0000000..b80f1a7 --- /dev/null +++ b/src/mark.css @@ -0,0 +1,9 @@ +[class*="tagged"] +{ + border: 2px solid #ff0000 !important; +} + +[class*="saved"] +{ + border: 2px solid #00ff00 !important; +} \ No newline at end of file diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..260d341 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,60 @@ +interface Fact { + context?: string; // url of the post or profile, optional + name: string; + type: 'string' | 'number' | 'url' | 'boolean' | 'datetime' | 'timestr' | 'name' | 'md' | 'text' | 'integer'; + value: string | number | boolean | object; +} + +interface FactCollection { + url: string; + facts: Fact[]; + savedAt: string; // time.now() + type: 'profile' | 'post' | 'search' |'comment'; +} + + +/** + * + * @param name property name + * @param type property type + * @param value property value + * @param context optional context, usually the url of the post or profile + * @returns + */ + +export function NewFact( name: string, type:'string' | 'number' | 'url' | 'boolean' | 'datetime' | 'timestr' | 'name' | 'md' | 'text' | 'integer', value:string, context?:string):Fact { + const fact:Fact = { + context: context, + name: name, + type:type, + value: value, + } + + return fact; +} + + +/** + * + * @param contextp url of the post or profile + * @param facts array of facts + * @param type type of the fact collection, can be 'profile', 'post', or 'comment' + * @returns + */ + +export function NewFactCollection(type: 'profile' | 'post' | 'comment' | 'search'): FactCollection { + const factCollection: FactCollection = { + url: window.location.href, // use window location href if url is not provided + facts: [], + savedAt: new Date().toISOString(), + type: type, + } + + return factCollection; +} + + +export type{ + Fact, + FactCollection, +}; \ No newline at end of file diff --git a/src/modules/facebook.ts b/src/modules/facebook.ts new file mode 100644 index 0000000..628682c --- /dev/null +++ b/src/modules/facebook.ts @@ -0,0 +1,1025 @@ +// facebook.ts +import { postgres } from "bun"; +import * as model from "../model"; +import { NewFact } from "../model"; +import { turndownService } from "../utils/turndown"; + +const ELEMENT_SELECTORS = { + profile: { + displayName: 'span[dir="auto"] > h1[class^="html-h1"]', + profilePicture: '[role="main"] > div > div:first-of-type image', + followerCount: 'a[href*="followers"] > strong', + followingCount: 'a[href*="following"] > strong', + bio: 'div[role="main"] > div:last-child > div:last-child > div > div > div:nth-child(2) > div > div > div > div > div > div > div:last-child' + }, + post: { + searchPageContainer: 'div[role="main"] > div:last-child > div:last-child > div > div:last-child > div:last-child > div', + container: + 'div[role="main"] > div:last-child > div:last-child > div > div:last-child > div:last-child', + postURL: 'a[href*="/posts/"], a[href*="/reels/"], a[href*="/videos/"]', + }, + postFromProfile: { + postUrl: 'span[dir="ltr"] > div > span a[attributionsrc]', + profileName: 'a[role="link"] > b[class*="html-b"]', + profileUrl: 'h3 a[role="link"]', + caption: 'div[data-ad-rendering-role="story_message"]', + photoUrl: 'a[attributionsrc][href*="/photo/"] img', + containerInteractions: + 'div[data-visualcompletion="ignore-dynamic"] > div > div > div > div:first-child', + like: 'div > div > div:first-child span[class*="html-span"] span[aria-hidden="true"]', + comment: 'div > div > div:nth-of-type(2) > div:nth-of-type(2) span[class*="html-span"] span[dir="auto"]', + share: 'div > div > div:nth-of-type(2) > div:nth-of-type(3) span[class*="html-span"] span[dir="auto"]', + seeMoreBtn: 'div[data-ad-comet-preview="message"] div[dir="auto"] div[role="button"]', + }, + reelFromProfile: { + postUrl: 'div[class*="html-div "] a[attributionsrc]', + profileName: 'h3[dir="auto"][class*="html-h3"] a[role="link"][attributionsrc]', + profileUrl: 'h3[dir="auto"][class*="html-h3"] a[role="link"]', + caption: 'div[class*="html-div "] a[attributionsrc] span[dir="auto"]', + containerInteractions: 'div[class*="html-div "] a[attributionsrc] > div:nth-of-type(2)', + interactionList: 'div > div > div > div > div', + seeMoreBtn: 'div[class*="html-div "] a[attributionsrc] span[dir="auto"] > div > object > div[role="button"]', + date: 'span[dir="auto"] > span > span:not([class]) > span:nth-of-type(2):not([aria-hidden])' + }, + + detailPost: { + container: 'div[role="dialog"]:nth-of-type(2)', + displayName: 'a[attributionsrc][role="link"] > b[class*="html-b"]', + profile: 'a[attributionsrc][role="link"]', + caption: 'div[data-ad-preview="message"]', + photoUrls: 'a[attributionsrc][href*="/photo/"] img', + reactionContainer: 'div[data-visualcompletion="ignore-dynamic"] > div > div > div > div:first-child', + likeCount: 'div > div > div:first-child span[class*="html-span"] span[aria-hidden="true"]', + commentCount: 'div > div > div:nth-of-type(2) > div:nth-of-type(2) span[class*="html-span"] span[dir="auto"]', + shareCount: 'div > div > div:nth-of-type(2) > div:nth-of-type(3) span[class*="html-span"] span[dir="auto"]', + }, + + commentDetailPost: { + container: 'div > div > div > div > div div[data-virtualized="false"] > div > div > div', + section: 'div[data-virtualized="false"]', + commentURL: 'a[attributionsrc][role="link"][href*="/posts/"]', + profileURL: 'a[attributionsrc]', + displayName: 'a[attributionsrc] > span > span[dir="auto"]', + profileImageUrl: 'a[attributionsrc] image', + content: 'span[dir="auto"][lang]', + likeCount: 'span[class^="html-span"] > div[role="button"][aria-label*=";"]', + replyCount: 'span[class^="html-span"] > span[dir="auto"]', + }, + + commentDetailReels: { + container: 'div[role="complementary"] div:not([class]) > div:last-of-type > div > div', + section: ':scope > div[data-virtualized="false"]', + seeMoreBtn: 'div[dir="auto"] > div[role="button"]', + displayName: 'div[role="article"] a[href][tabindex="0"]', + content: 'span[dir="auto"][lang]', + likeCount: 'div[role="button"][tabindex="0"] > div[class^="html-div"] > span', + replyCount: 'div[role=button] > span > span[dir="auto"]', + displayDate: 'li > span > div > a', + }, + detailReels: { + container: 'div[role="main"]', + displayName: 'h2[dir="auto"] a', + caption: 'div > span[dir="auto"] > div', + seeMoreBtn: 'div[role="button"]', + reactionContainer: 'div > div > div > div:nth-child(2) > div:nth-child(2) > div > div > div', + }, + detailVideos: { + // container: '[role="main"]', + videoSection: '[role="main"]', + contenVideoSection: '[role="complementary"]', + profileUrl: '[data-ad-rendering-role="profile_name"] a', + caption: 'div > div:nth-child(2) > div > div > div:nth-child(1) > div > div > div:nth-child(1) > div:nth-child(3) > div:nth-child(1)', + title: 'div > div:nth-child(2) > div > div > div:nth-child(1) > div > div > div:nth-child(1) > div:nth-child(2) > div > div > div > span', + seeMoreBtn: '[dir="auto"] > [role="button"]', + likeCount: 'div > div:nth-child(2) > div > div > div:nth-child(1) > div > div > div:nth-child(2) > div:nth-child(1) > div > div:nth-child(1) > div > span > div > span:nth-child(2) > span > span', + commentCount: 'div > div:nth-child(2) > div > div > div:nth-child(1) > div > div > div:nth-child(2) > div:nth-child(1) > div > div:nth-child(2) > div:nth-child(1) > span > div > div > div:nth-child(1) > span > span', + viewCount: 'div > div:nth-child(2) > div > div > div:nth-child(1) > div > div > div:nth-child(2) > div:nth-child(1) > div > div:nth-child(2) > div:nth-child(2) > span > span > div > div:nth-child(1) > span' + }, + commentDetailVideo: { + container: '[role="complementary"] div[style="height: auto;"]', + section: 'div[data-virtualized="false"]', + commentUrl: 'a[href*="comment_id"]', + displayName: 'a[href*="comment_id"][aria-hidden="false"]', + content: '[dir="auto"][lang]', + likeCount: 'span > [role="button"][aria-label][tabindex="0"] > div > span', + commentCount: '[role="button"][ tabindex="0"] > span > span[dir="auto"]', + } + +}; + +export default class FacebookModule { + context = window.location.href; + + private markSaved(el?: Element | null) { + el?.classList.add("saved"); + } + private isSaved(el?: Element | null) { + if (!el || el === document.body) return false; + return el.classList.contains("saved"); + } + + tagElement(): void { + + } + + + async saveData(): Promise { + const out: model.FactCollection[] = []; + + if (window.location.href.includes("/posts")) { + const post = this.saveDetailPost(); + if (post) out.push(post); + + const comments = this.saveCommentPosts( + document.querySelector(ELEMENT_SELECTORS.detailPost.container) || undefined + ); + if (comments.length) out.push(...comments); + } else if (window.location.href.includes("/reel")) { + const reel = await this.saveDetailReels(); + if (reel) out.push(reel); + + const c = await this.saveCommentReels( + document.querySelector(ELEMENT_SELECTORS.commentDetailReels.container) || undefined + ); + if (c.length) out.push(...c); + } else if (window.location.href.includes("/search/")) { + const posts = await this.saveAllPost(); + if (posts && posts.facts && posts.facts.length > 0) out.push(posts); + } else if (window.location.href.includes("/videos")) { + const post = this.saveDetailVideo(); + if (post) out.push(post); + const comments = this.saveCommentVideo() + if (comments.length) out.push(...comments); + } else { + const profile = this.saveProfile(); + if (profile) out.push(profile); + const posts = await this.saveAllPost(); + if (posts && posts.facts && posts.facts.length > 0) out.push(posts); + } + + return out; + } + + saveDetailVideo(): model.FactCollection { + const Post: model.FactCollection = model.NewFactCollection('post'); + + const context = window.location.href; + // const videoSection = document.querySelector(ELEMENT_SELECTORS.detailVideos.videoSection); + const contenVideoSection = document.querySelector(ELEMENT_SELECTORS.detailVideos.contenVideoSection); + + const postUrl = window.location.href; + + + const profileUrl = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.profileUrl)?.getAttribute('href') || ''; + + const displayName = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.profileUrl)?.textContent || ''; + + const caption = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.caption)?.textContent || ''; + + const titleContent = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.title)?.textContent || ''; + + const contentTurndown = turndownService.turndown(`${titleContent} \n ${caption}`); + + const seeMoreBtn = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.seeMoreBtn)?.textContent || ''; + + // Reactions + + const likeCount = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.likeCount)?.textContent || "0"; + + const commentCount = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.commentCount)?.textContent || "0"; + + const viewCount = contenVideoSection?.querySelector(ELEMENT_SELECTORS.detailVideos.viewCount)?.textContent || "0"; + + Post.facts.push(NewFact('fb_post_source_url', 'url', postUrl, postUrl)); + Post.facts.push(NewFact('fb_post_profile_url', 'url', profileUrl, context)); + Post.facts.push(NewFact('fb_post_display_name', 'string', displayName, context)); + Post.facts.push(NewFact('fb_post_content', 'string', contentTurndown, context)); + Post.facts.push(NewFact('fb_post_see_more_btn', 'string', seeMoreBtn, context)); + Post.facts.push(NewFact('fb_post_like_count', 'string', this.parseCount(likeCount), context)); + Post.facts.push(NewFact('fb_post_comment_count', 'string', this.parseCount(commentCount), context)); + Post.facts.push(NewFact('fb_post_view_count', 'string', this.parseCount(viewCount), context)); + + return Post; + } + + saveCommentVideo(): model.FactCollection[] { + + const container = document.querySelector(ELEMENT_SELECTORS.commentDetailVideo.container); + const sections = container?.querySelectorAll(ELEMENT_SELECTORS.commentDetailVideo.section); + + const comments = model.NewFactCollection("comment"); + const extractComment = async (section: HTMLElement): Promise => { + // Dapatkan href dengan fallback ke string kosong jika undefined + const commentUrlEl = section + .querySelector(ELEMENT_SELECTORS.commentDetailVideo.commentUrl) + ?.getAttribute("href") + ?.split("&__tn_")[0] ?? ""; + + // Karena commentUrlEl sekarang string (meskipun kosong), profileUrl pasti bisa diproses + const profileUrl = commentUrlEl + .split("?comment_id=")[0] + .split("&comment_id=")[0]; + + const displayName = section + .querySelector(ELEMENT_SELECTORS.commentDetailVideo.displayName) + ?.textContent + ?.trim() ?? ""; + + const content = section + .querySelector(ELEMENT_SELECTORS.commentDetailVideo.content) + ?.textContent + ?.trim() ?? ""; + + const likeCount = section + .querySelector(ELEMENT_SELECTORS.commentDetailVideo.likeCount) + ?.textContent + ?.trim() ?? "0"; + + const commentCount = section + .querySelector(ELEMENT_SELECTORS.commentDetailVideo.commentCount) + ?.textContent + ?.trim() ?? "0"; + + // Sekarang kita bisa memanggil NewFact tanpa error tipe + comments.facts.push( + NewFact("fb_comment_source_url", "url", commentUrlEl, window.location.href) + ); + comments.facts.push( + NewFact("fb_comment_profile_url", "url", profileUrl, window.location.href) + ); + comments.facts.push( + NewFact("fb_comment_display_name", "string", displayName, window.location.href) + ); + comments.facts.push( + NewFact("fb_comment_content", "string", content, window.location.href) + ); + comments.facts.push( + NewFact("fb_comment_like_count", "string", this.parseCount(likeCount), window.location.href) + ); + comments.facts.push( + NewFact("fb_comment_comment_count", "string", this.parseCount(commentCount), window.location.href) + ); + }; + sections?.forEach((section) => { + // Skip if section is already saved + if (this.isSaved(section)) return; + extractComment(section); + }) + + return [comments]; + } + saveCommentPosts(scope?: HTMLElement): model.FactCollection[] { + if (!scope) return []; + const container = scope.querySelector(ELEMENT_SELECTORS.commentDetailPost.container); + if (!container) return []; + + const sections = container.querySelectorAll(ELEMENT_SELECTORS.commentDetailPost.section); + const facts = model.NewFactCollection("comment"); + + sections.forEach((section) => { + // Skip if section is already saved + if (this.isSaved(section)) return; + + const commentUrlEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.commentURL); + const cleanUrl = commentUrlEl?.href ? commentUrlEl.href.split("&__cft__[0]")[0] : undefined; + const contextUrl = cleanUrl || this.context || window.location.href; + if (cleanUrl) { + facts.facts.push(NewFact("fb_comment_source_url", "url", cleanUrl, window.location.href)); + this.context = cleanUrl; + } + + const authorEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.profileURL); + if (authorEl) { + const name = authorEl.textContent?.trim() || ""; + const url = authorEl.href?.split("?")[0] || ""; + if (name) facts.facts.push(NewFact("fb_comment_display_name", "name", name, contextUrl)); + if (url) facts.facts.push(NewFact("fb_comment_profile_url", "url", url, contextUrl)); + } + + const bodyEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.content); + if (bodyEl) { + const body = turndownService.turndown(bodyEl.innerHTML || ""); + if (body) facts.facts.push(NewFact("fb_comment_content_md", "md", body, contextUrl)); + } + + const likeEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.likeCount); + if (likeEl) { + const likeCount = this.parseCount(likeEl.textContent || ""); + facts.facts.push(NewFact("fb_comment_like_count", "integer", likeCount, contextUrl)); + } + + const replyEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.replyCount); + if (replyEl) { + const replyCount = this.parseCount((replyEl.textContent || "").trim()); + facts.facts.push(NewFact("fb_comment_comment_count", "integer", replyCount, contextUrl)); + } + + const dateEl = section.querySelector(ELEMENT_SELECTORS.commentDetailPost.commentURL); + if (dateEl?.textContent) { + facts.facts.push(NewFact("fb_comment_duration_string", "string", dateEl.textContent.trim(), contextUrl)); + } + + // Mark section as saved after processing + this.markSaved(section); + }); + + + return facts.facts.length ? [facts] : []; + } + + async saveCommentReels(scope?: HTMLElement): Promise { + if (!scope) return []; + + const seeMoreBTNs = scope.querySelectorAll(ELEMENT_SELECTORS.commentDetailReels.seeMoreBtn); + seeMoreBTNs.forEach((btn) => btn.click()); + await new Promise((r) => setTimeout(r, 800)); + + const sections = scope.querySelectorAll(ELEMENT_SELECTORS.commentDetailReels.section); + if (!sections.length) return []; + + const facts = model.NewFactCollection("comment"); + + sections.forEach((section) => { + // Skip if section is already saved + if (this.isSaved(section)) return; + + const dateEl = section.querySelector(ELEMENT_SELECTORS.commentDetailReels.displayDate); + const clean = dateEl?.href ? dateEl.href.replace("&__tn__=R", "") : undefined; + const contextUrl = clean || this.context || window.location.href; + if (clean) { + facts.facts.push(NewFact("fb_comment_source_url", "url", clean, window.location.href)); + this.context = clean; + } + + + const authorEl = section.querySelector(ELEMENT_SELECTORS.commentDetailReels.displayName); + if (authorEl) { + const name = authorEl.textContent?.trim() || ""; + const url = authorEl.href?.split("?")[0] || ""; + if (name) facts.facts.push(NewFact("fb_comment_display_name", "name", name, contextUrl)); + if (url) facts.facts.push(NewFact("fb_comment_profile_url", "url", url, contextUrl)); + } + + const bodyEl = section.querySelector(ELEMENT_SELECTORS.commentDetailReels.content); + if (bodyEl) { + const body = turndownService.turndown(bodyEl.innerHTML || ""); + if (body) facts.facts.push(NewFact("fb_comment_content_md", "md", body, contextUrl)); + } + + const likeEl = section.querySelector(ELEMENT_SELECTORS.commentDetailReels.likeCount); + if (likeEl) { + const likeCount = this.parseCount((likeEl.textContent || "").trim()); + facts.facts.push(NewFact("fb_comment_like_count", "integer", likeCount, contextUrl)); + } + + const replyEl = section.querySelector(ELEMENT_SELECTORS.commentDetailReels.replyCount); + if (replyEl) { + const replyCount = this.parseCount((replyEl.textContent || "").trim()); + facts.facts.push(NewFact("fb_comment_comment_count", "integer", replyCount, contextUrl)); + } + + if (dateEl?.textContent) { + facts.facts.push(NewFact("fb_comment_duration_string", "string", dateEl.textContent.trim(), contextUrl)); + } + + // Mark section as saved after processing + this.markSaved(section); + }); + + + + return facts.facts.length ? [facts] : []; + } + + + saveProfile(): model.FactCollection | null { + const facts = model.NewFactCollection("profile"); + const displayName = document.querySelector(ELEMENT_SELECTORS.profile.displayName); + if (displayName && !this.isSaved(displayName)) { + facts.facts.push(NewFact("fb_profile_source_url", "url", window.location.href, this.context)); + const name = displayName.lastChild?.textContent || ""; + if (name) facts.facts.push(NewFact("fb_profile_display_name", "name", name, this.context)); + this.markSaved(displayName); + } + + const profilePicture = document.querySelector(ELEMENT_SELECTORS.profile.profilePicture); + if (profilePicture && !this.isSaved(profilePicture)) { + const pic = profilePicture.getAttribute("xlink:href") || ""; + if (pic) facts.facts.push(NewFact("fb_profile_profileimage_url", "url", pic, this.context)); + this.markSaved(profilePicture); + } + + const bioEl = document.querySelector(ELEMENT_SELECTORS.profile.bio); + if (bioEl) { + const bioContent = turndownService.turndown(bioEl.innerHTML) + facts.facts.push(NewFact("fb_profile_bio_md", "md", bioContent, window.location.href)) + } + + const followerCount = document.querySelector(ELEMENT_SELECTORS.profile.followerCount); + if (followerCount && !this.isSaved(followerCount)) { + const count = this.parseCount(followerCount.textContent); + facts.facts.push(NewFact("fb_profile_follower_count", "integer", count, this.context)); + this.markSaved(followerCount); + } + + const followingCount = document.querySelector(ELEMENT_SELECTORS.profile.followingCount); + if (followingCount && !this.isSaved(followingCount)) { + const count = this.parseCount(followingCount.textContent); + facts.facts.push(NewFact("fb_profile_following_count", "integer", count, this.context)); + this.markSaved(followingCount); + } + + + return facts.facts.length ? facts : null; + } + + async saveAllPost(): Promise { + let sections: NodeListOf | undefined; + + if (window.location.href.includes("/search/")) { + sections = document.querySelectorAll(ELEMENT_SELECTORS.post.searchPageContainer); + } else { + const container = document.querySelector(ELEMENT_SELECTORS.post.container); + sections = container?.querySelectorAll(":scope > div"); + } + + if (!sections?.length) return null; + + // First, expand all content before processing + await this.expandAllContent(sections); + + const facts = window.location.href.includes("/search/") ? model.NewFactCollection("search") : model.NewFactCollection("post"); + + const tasks = sections + ? Array.from(sections).map(async (postElement) => { + if (this.isSaved(postElement)) return; + + const isReels = + postElement.querySelector('a[attributionsrc]')?.getAttribute("aria-label")?.includes("Reels") ?? false; + + const data = isReels + ? await this.extractReelsFromProfile(postElement) + : await this.extractPostFromProfile(postElement); + + if (data && data.facts.length) { + facts.facts.push(...data.facts); + // mark CONTAINER post + this.markSaved(postElement); + } + }) + : []; + + await Promise.allSettled(tasks); + + return facts.facts.length ? facts : null; + } + + private async expandAllContent(sections: NodeListOf): Promise { + const expandTasks: Promise[] = []; + + sections.forEach((postElement) => { + if (this.isSaved(postElement)) return; + + const isReels = + postElement.querySelector('a[attributionsrc]')?.getAttribute("aria-label")?.includes("Reels") ?? false; + + const seeMoreSelector = isReels + ? ELEMENT_SELECTORS.reelFromProfile.seeMoreBtn + : ELEMENT_SELECTORS.postFromProfile.seeMoreBtn; + + // Find all expandable content in this post + const seeMoreButtons = postElement.querySelectorAll(seeMoreSelector); + + seeMoreButtons.forEach((btn) => { + expandTasks.push(this.expandSingleContent(btn, postElement, seeMoreSelector)); + }); + }); + + // Expand all content in parallel but with some delay between batches + const batchSize = 3; // Process 3 posts at a time to avoid overwhelming the page + for (let i = 0; i < expandTasks.length; i += batchSize) { + const batch = expandTasks.slice(i, i + batchSize); + await Promise.allSettled(batch); + // Small delay between batches + if (i + batchSize < expandTasks.length) { + await new Promise(resolve => setTimeout(resolve, 300)); + } + } + } + + private async expandSingleContent( + button: HTMLElement, + postElement: HTMLElement, + seeMoreSelector: string + ): Promise { + try { + // Scroll to button for better interaction + button.scrollIntoView({ block: "nearest", inline: "nearest" }); + + // Click the button + button.click(); + + // Wait for content to expand + await this.waitForContentExpansion(postElement, seeMoreSelector, 2000); + + // Additional small delay for DOM to stabilize + await new Promise(resolve => setTimeout(resolve, 100)); + + } catch (error) { + console.warn("Failed to expand content:", error); + } + } + + private async waitForContentExpansion( + container: HTMLElement, + seeMoreSelector: string, + timeoutMs: number + ): Promise { + const startTime = Date.now(); + + return new Promise((resolve) => { + const checkExpansion = () => { + // Check if button is gone (content expanded) + const button = container.querySelector(seeMoreSelector); + const elapsed = Date.now() - startTime; + + if (!button || elapsed >= timeoutMs) { + resolve(); + return; + } + + // Continue checking + setTimeout(checkExpansion, 50); + }; + + checkExpansion(); + }); + } + + async saveDetailReels(): Promise { + const jsonScript = document.querySelector('script[type="application/ld+json"]'); + + if (!jsonScript) { + const containers = document.querySelectorAll(ELEMENT_SELECTORS.detailReels.container); + const lastMain = containers[containers.length - 1]; + if (!lastMain || this.isSaved(lastMain)) return null; + + const data = await this.extractReelsFromDom(lastMain); + if (!data.facts.length) return null; + + // mark CONTAINER reels detail + this.markSaved(lastMain); + return data; + } + return null; + } + + async extractReelsFromDom(reelsElement: HTMLElement): Promise { + const postData = model.NewFactCollection("post"); + this.context = window.location.href; + + postData.facts.push(NewFact("fb_post_source_url", "url", window.location.href, this.context)); + + const displayNameElement = reelsElement.querySelector(ELEMENT_SELECTORS.detailReels.displayName); + if (displayNameElement) { + const displayName = displayNameElement.textContent?.replace(/\s+/g, " ").trim() || ""; + if (displayName) postData.facts.push(NewFact("fb_post_display_name", "name", displayName, this.context)); + } + + const captionWrap = reelsElement.querySelector(ELEMENT_SELECTORS.detailReels.caption)?.parentElement; + if (captionWrap) { + await this.expandCaptionIfNeeded(captionWrap, ELEMENT_SELECTORS.detailReels.seeMoreBtn, { + maxClicks: 3, + timeoutMs: 2000, + }); + const clone = captionWrap.cloneNode(true) as HTMLElement; + clone.querySelectorAll("button, [role='button'], object, a").forEach((el) => el.remove()); + const caption = turndownService.turndown(clone); + if (caption) postData.facts.push(NewFact("fb_post_content_md", "md", caption, this.context)); + } + + const reactionContainer = reelsElement.querySelector(ELEMENT_SELECTORS.detailReels.reactionContainer); + if (reactionContainer) { + const likeCount = reactionContainer.children[1]?.textContent?.trim() || ""; + const commentCount = reactionContainer.children[2]?.textContent?.trim() || ""; + const shareCount = reactionContainer.children[3]?.textContent?.trim() || ""; + postData.facts.push(NewFact("fb_post_like_count", "integer", this.parseCount(likeCount), this.context)); + postData.facts.push(NewFact("fb_post_comment_count", "integer", this.parseCount(commentCount), this.context)); + postData.facts.push(NewFact("fb_post_share_count", "integer", this.parseCount(shareCount), this.context)); + } + + // Add savedat_datetime + // postData.facts.push(NewFact("savedat_datetime", "datetime", new Date().toISOString(), this.context)); + + return postData; + } + + saveDetailPost(): model.FactCollection | null { + const container = + document.querySelector(ELEMENT_SELECTORS.detailPost.container); + if (!container || this.isSaved(container)) return null; + + const out = model.NewFactCollection("post"); + + // URL post + out.facts.push(NewFact("fb_post_source_url", "url", window.location.href, this.context)); + + // Display name + const displayNameEl = container.querySelector( + ELEMENT_SELECTORS.detailPost.displayName + ); + if (displayNameEl) { + const displayName = displayNameEl.textContent?.replace(/\s+/g, " ").trim() || ""; + if (displayName) out.facts.push(NewFact("fb_post_display_name", "name", displayName, this.context)); + } + + // Foto + const photoImgs = Array.from( + container.querySelectorAll(ELEMENT_SELECTORS.detailPost.photoUrls) + ) + .map((img) => img.src) + .filter(Boolean); + + // Caption -> markdown + let captionMd = ""; + const captionEl = container.querySelector(ELEMENT_SELECTORS.detailPost.caption); + if (captionEl) { + captionMd = (turndownService.turndown(captionEl.innerHTML || "") || "").trim(); + } + + // content_md: "[photo](u1) [photo-2](u2) ...\n\n" + const photoLine = photoImgs + .map((u, i) => (i === 0 ? `[photo](${u})` : `[photo-${i + 1}](${u})`)) + .join(" "); + const contentMd = [photoLine, captionMd].filter(Boolean).join("\n\n").trim(); + if (contentMd) out.facts.push(NewFact("fb_post_content_md", "md", contentMd, this.context)); + + // // (opsional) field foto terpisah jika masih dipakai downstream + // if (photoImgs.length === 1) { + // out.facts.push(NewFact("photoUrl", "url", photoImgs[0], this.context)); + // } else if (photoImgs.length > 1) { + // out.facts.push(NewFact("photoUrls", "string", JSON.stringify(photoImgs), this.context)); + // out.facts.push(NewFact("photoUrl", "url", photoImgs[0], this.context)); + // } + + // Reactions + const reactBox = container.querySelector( + ELEMENT_SELECTORS.detailPost.reactionContainer + ); + if (reactBox) { + const likeCount = reactBox.querySelector(ELEMENT_SELECTORS.detailPost.likeCount); + if (likeCount) { + const count = this.parseCount(likeCount.textContent?.trim()); + out.facts.push(NewFact("fb_post_like_count", "integer", count, this.context)); + } + + const commentCount = reactBox.querySelector( + ELEMENT_SELECTORS.detailPost.commentCount + ); + if (commentCount) { + const count = this.parseCount(commentCount.textContent?.trim()); + out.facts.push(NewFact("fb_post_comment_count", "integer", count, this.context)); + } + + const shareCount = reactBox.querySelector( + ELEMENT_SELECTORS.detailPost.shareCount + ); + if (shareCount) { + const count = this.parseCount(shareCount.textContent?.trim()); + out.facts.push(NewFact("fb_post_share_count", "integer", count, this.context)); + } + } + + + + if (!out.facts.length) return null; + this.markSaved(container); + return out; + } + + + async extractPostFromProfile(postElement: HTMLElement): Promise { + + const captionEl = postElement.querySelector( + ELEMENT_SELECTORS.postFromProfile.caption + ); + if (!postElement || this.isSaved(postElement) || !captionEl) return null; + + const out = model.NewFactCollection("post"); + + + // 1) URL post + const postUrlEl = postElement.querySelector( + ELEMENT_SELECTORS.postFromProfile.postUrl + ); + // Href bisa relatif; normalisasi jadi absolute FB URL + const rawHref = postUrlEl?.getAttribute("href") || ""; + const fullPostUrl = rawHref + ? (rawHref.startsWith("http") + ? rawHref.split("?")[0] + : `https://www.facebook.com${rawHref.split("?")[0]}`) + : ""; + // this.context = window.location.href; + if (fullPostUrl) { + this.context = fullPostUrl + out.facts.push(NewFact("fb_post_source_url", "url", fullPostUrl, this.context)); + } + + // 2) Profile URL & Display Name + const profileUrlEl = postElement.querySelector( + ELEMENT_SELECTORS.postFromProfile.profileUrl + ); + const profileNameEl = postElement.querySelector( + ELEMENT_SELECTORS.postFromProfile.profileName + ); + + const profileUrl = profileUrlEl?.getAttribute("href")?.split("?")[0] || ""; + const displayName = profileNameEl?.textContent?.replace(/\s+/g, " ").trim() || ""; + + if (profileUrl) out.facts.push(NewFact("fb_post_profile_url", "url", profileUrl.startsWith("https://www.facebook.com/") ? profileUrl : `https://www.facebook.com${profileUrl}`, this.context)); + if (displayName) out.facts.push(NewFact("fb_post_display_name", "name", displayName, this.context)); + const mediaExternal = postElement.querySelectorAll('[data-ad-rendering-role="image"]'); + + const photos = postElement.querySelectorAll(ELEMENT_SELECTORS.postFromProfile.photoUrl); + + const videos = postElement.querySelectorAll('a[attributionsrc][href*="/videos/"]'); + + // Extract URLs from different media types + const mediaExternalUrls = Array.from(mediaExternal) + .map((img) => turndownService.turndown(img.innerHTML) || "") + .filter(Boolean); + + const photoUrls = Array.from(photos) + .map((img) => { + const src = (img as HTMLImageElement).src || ""; + return src.split("?__cft__")[0]; // Remove __cft__ parameter + }) + .filter(Boolean); + + const videoUrls = Array.from(videos) + .map((video) => { + const href = video.getAttribute("href") || ""; + return href.split("?__cft__")[0]; // Remove __cft__ parameter + }) + .filter(Boolean); + + // Combine all media URLs + const allMediaUrls = [...mediaExternalUrls, ...photoUrls, ...videoUrls]; + + const captionMd = captionEl + ? (turndownService.turndown(captionEl.innerHTML || "") || "").trim() + : ""; + + // 4) Create content markdown with all media types + if (allMediaUrls.length || captionMd) { + const mediaLines: string[] = []; + + // Add external media + mediaExternalUrls.forEach((url, i) => { + mediaLines.push(i === 0 && mediaExternalUrls.length === 1 ? `${url}` : `[](${url})`); + }); + + // Add regular photos + photoUrls.forEach((url, i) => { + const index = mediaExternalUrls.length + i + 1; + mediaLines.push(index === 1 && allMediaUrls.length === 1 ? `[photo](${url})` : `[photo](${url})`); + }); + + // Add videos + videoUrls.forEach((url, i) => { + // const index = i + 1; + mediaLines.push(videoUrls.length === 1 ? `[video](${url})` : `[video](${url})`); + }); + + const mediaLine = mediaLines.join(" "); + const contentMd = [mediaLine, captionMd].filter(Boolean).join("\n\n").trim(); + if (contentMd) out.facts.push(NewFact("fb_post_content_md", "md", contentMd, this.context)); + } + + // // (opsional) simpan juga url foto terpisah + // if (photoImgs.length === 1) { + // out.facts.push(NewFact("photoUrl", "url", photoImgs[0], this.context)); + // } else if (photoImgs.length > 1) { + // out.facts.push(NewFact("photoUrls", "string", JSON.stringify(photoImgs), this.context)); + // out.facts.push(NewFact("photoUrl", "url", photoImgs[0], this.context)); + // } + + // 5) Reactions (like, comment, share) + const reactBox = postElement.querySelector( + ELEMENT_SELECTORS.postFromProfile.containerInteractions + ); + if (reactBox) { + const likeEl = reactBox.querySelector(ELEMENT_SELECTORS.postFromProfile.like); + const commentEl = reactBox.querySelector(ELEMENT_SELECTORS.postFromProfile.comment); + const shareEl = reactBox.querySelector(ELEMENT_SELECTORS.postFromProfile.share); + + const likeCount = likeEl?.textContent?.trim() || ""; + const commentCount = commentEl?.textContent?.trim() || ""; + const shareCount = shareEl?.textContent?.trim() || ""; + + out.facts.push(NewFact("fb_post_like_count", "integer", this.parseCount(likeCount), this.context)); + out.facts.push(NewFact("fb_post_comment_count", "integer", this.parseCount(commentCount), this.context)); + out.facts.push(NewFact("fb_post_share_count", "integer", this.parseCount(shareCount), this.context)); + } + + // Add savedat_datetime + // out.facts.push(NewFact("savedat_datetime", "datetime", new Date().toISOString(), this.context)); + + // Tidak ada fakta yang terkumpul? jangan tandai & return null + if (!out.facts.length) return null; + + // Tandai container post sebagai saved (bukan ) + this.markSaved(postElement); + return out; + } + + + async extractReelsFromProfile(postElement: HTMLElement): Promise { + // Check if this element was already processed + if (this.isSaved(postElement)) { + return model.NewFactCollection("post"); // Return empty collection for already processed elements + } + + const postData = model.NewFactCollection("post"); + + const reelUrlEl = postElement.querySelector(ELEMENT_SELECTORS.reelFromProfile.postUrl); + if (reelUrlEl) { + const href = reelUrlEl.getAttribute("href") || ""; + const fullUrl = href.startsWith("http") ? href.split("?")[0] : `https://www.facebook.com${href.split("?")[0]}`; + if (fullUrl) { + this.context = fullUrl + postData.facts.push(NewFact("fb_post_source_url", "url", fullUrl, this.context)) + }; + } + + const profileUrlEl = postElement.querySelector(ELEMENT_SELECTORS.reelFromProfile.profileUrl); + if (profileUrlEl) { + const href = profileUrlEl.getAttribute("href") || ""; + const cleanUrl = href.split("?")[0]; + if (cleanUrl) postData.facts.push(NewFact("fb_post_profile_url", "url", cleanUrl.startsWith("https://www.facebook.com") ? cleanUrl : `https://www.facebook.com${cleanUrl}`, this.context)); + } + + const displayNameEl = postElement.querySelector(ELEMENT_SELECTORS.reelFromProfile.profileName); + if (displayNameEl) { + const displayName = displayNameEl.textContent?.replace(/\s+/g, " ").trim() || ""; + if (displayName) postData.facts.push(NewFact("fb_post_display_name", "name", displayName, this.context)); + } + + const captionElement = postElement.querySelector(ELEMENT_SELECTORS.reelFromProfile.caption); + if (captionElement) { + const clone = captionElement.cloneNode(true) as HTMLElement; + clone.querySelectorAll('div[role="button"], button, object, a').forEach((el) => el.remove()); + const caption = turndownService.turndown(clone.innerHTML.trim()); + if (caption) postData.facts.push(NewFact("fb_post_content_md", "md", caption, this.context)); + } + + const containerInteractions = postElement.querySelector( + ELEMENT_SELECTORS.reelFromProfile.containerInteractions + ); + if (containerInteractions) { + // Filter elements that contain '__fb-' in their innerHTML and extract text content + const reactionList = [...containerInteractions.querySelectorAll("div > div > div > div > div")] + .filter((el) => el.innerHTML.includes("__fb-")) + .map((el) => el.textContent?.trim() || ""); + + console.log("Reels reaction list:", reactionList); + + // Get counts from the filtered reaction list + const likeCount = reactionList[1] || "0"; + const commentCount = reactionList[2] || "0"; + const shareCount = reactionList[3] || "0"; + + console.log("Reels counts:", { likeCount, commentCount, shareCount }); + + postData.facts.push(NewFact("fb_post_like_count", "integer", this.parseCount(likeCount), this.context)); + postData.facts.push(NewFact("fb_post_comment_count", "integer", this.parseCount(commentCount), this.context)); + postData.facts.push(NewFact("fb_post_share_count", "integer", this.parseCount(shareCount), this.context)); + } + + const dateEl = postElement.querySelector(ELEMENT_SELECTORS.reelFromProfile.date); + if (dateEl) { + const dateText = dateEl.textContent?.trim() || ""; + if (dateText) { + postData.facts.push(NewFact("fb_post_duration_string", "string", dateText, this.context)); + } + } + + // Add savedat_datetime + // postData.facts.push(NewFact("savedat_datetime", "datetime", new Date().toISOString(), this.context)); + + // Mark the container as saved only if we actually extracted data + if (postData.facts.length > 0) { + this.markSaved(postElement); + } + + return postData; + } + + private async expandCaptionIfNeeded( + captionEl: HTMLElement, + seeMoreSelector: string, + opts: { maxClicks?: number; timeoutMs?: number } = {} + ): Promise { + const { maxClicks = 3, timeoutMs = 2000 } = opts; + + captionEl.scrollIntoView({ block: "nearest", inline: "nearest" }); + const before = this.measureCaption(captionEl); + + for (let i = 0; i < maxClicks; i++) { + const btn = captionEl.querySelector(seeMoreSelector); + if (!btn) break; + + btn.click(); + await this.waitForExpansion(captionEl, seeMoreSelector, timeoutMs); + await this.nextPaint(); + await this.nextPaint(); + + const after = this.measureCaption(captionEl); + const expanded = + after.height > before.height || after.textLen > before.textLen || !captionEl.querySelector(seeMoreSelector); + if (expanded) break; + } + } + + private waitForExpansion(captionEl: HTMLElement, seeMoreSelector: string, timeoutMs: number): Promise { + const start = this.measureCaption(captionEl); + + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (!done) { + done = true; + cleanup(); + resolve(); + } + }; + + const ro = new ResizeObserver(() => finish()); + ro.observe(captionEl); + + const mo = new MutationObserver(() => finish()); + mo.observe(captionEl, { childList: true, subtree: true, characterData: true }); + + let rafId = 0; + const poll = () => { + if (done) return; + const cur = this.measureCaption(captionEl); + const grew = cur.height > start.height || cur.textLen > start.textLen; + const buttonGone = !captionEl.querySelector(seeMoreSelector); + if (grew || buttonGone) return finish(); + rafId = requestAnimationFrame(poll); + }; + rafId = requestAnimationFrame(poll); + + const t = setTimeout(finish, timeoutMs); + + function cleanup() { + try { ro.disconnect(); } catch { } + try { mo.disconnect(); } catch { } + try { cancelAnimationFrame(rafId); } catch { } + clearTimeout(t); + } + }); + } + + private nextPaint(): Promise { + return new Promise((res) => requestAnimationFrame(() => res())); + } + + private measureCaption(el: HTMLElement) { + const rect = el.getBoundingClientRect(); + return { height: Math.round(rect.height), textLen: (el.textContent || "").length }; + } + + addClassTagged(element: Element): void { + if (!element || element === document.body) return; + element.setAttribute("class", (element.getAttribute("class") || "") + " tagged"); + } + + parseCount(str = ""): string { + if (typeof str !== "string") return "0"; + + const replyMatch = str.match(/Lihat semua (\d+)\s*balasan?|(\d+)\s*replies?/i); + if (replyMatch) return replyMatch[1] || replyMatch[2]; + + const raw = str.trim().match(/^([\d,.]+)\s*([a-zA-Z]*)/); + if (!raw) return "0"; + + const numPart = raw[1].replace(/\./g, "").replace(/,/g, "."); + const unitPart = raw[2].toLowerCase(); + + const num = parseFloat(numPart); + if (isNaN(num)) return "0"; + + const multipliers: Record = { + k: 1_000, + rb: 1_000, + m: 1_000_000, + jt: 1_000_000, + b: 1_000_000_000, + }; + + const total = Math.round(num * (multipliers[unitPart] || 1)); + return String(total); + } +} diff --git a/src/modules/instagram.ts b/src/modules/instagram.ts new file mode 100644 index 0000000..cacf3d7 --- /dev/null +++ b/src/modules/instagram.ts @@ -0,0 +1,763 @@ +import * as model from '../model'; +import { turndownService } from '../utils/turndown'; +import { parseCount } from '../helper/parseCount'; + +const SELECTORS = { + profile: { + container: 'main header', + avatar: 'img[alt*="profile picture" i], img[alt*="profile" i], header img[alt]', + displayName: 'header section:nth-child(4) span[dir="auto"]', + verifiedBadge: 'svg[aria-label="Verified"], img[alt="Verified"]', + bio: 'main header section:nth-child(4) > div > span', + followers: 'a[href*="/followers/"] span', + following: 'a[href*="/following/"] span', + }, + post: { + container: 'article', + username: 'a[href*="/"][href$="/"]', + caption: 'h1[dir="auto"]', + likeCount: 'a[href*="/liked_by/"] span[class*="html-span"]', + // media: 'video[src], img[src*="instagram"]', + mediaContainer: 'div[role="presentation"]', + videoUrl: 'article > div > div:first-child video[src], article > div > div:first-child video[src] source', + imageUrl: 'article > div > div:first-child img[src]', + + timestamp: 'time[datetime]', + profileImage: 'img[alt*="profile"]', + profileUrl: 'a[href*="/"][href$="/"]' + }, + comments: { + container: 'article > div > div:nth-child(2) > div > div > div:nth-child(2) > div > ul > div:nth-child(3) > div > div, main[role="main"] hr + div > div > div:nth-child(2)', + sections: ':scope > div', + profile_url: 'a[href*="/"]', + user_name: 'a[href*="/"] span[dir="auto"]', + createdat_datetime: 'time[datetime]', + profileimage_url: 'img[alt*="profile"]', + comment_url: 'a[href*="/p/"]', + like_count: 'div[role="button"] > span[dir="auto"], span[dir="ltr"] button > span', + replie_count: 'div[role="button"] > div[class^="html-div"] > span[dir="auto"], li ul li button > span', + content_md: 'div[class^="html-div"] > div > div[class^="html-div"] > div[class^="html-div"] > div[class^="html-div"] > div > div[class^="html-div"] > div:nth-of-type(2)', + username: 'a[href*="/"][href$="/"]', + text: 'span[dir="auto"]', + timestamp: 'time[datetime]', + profileImage: 'img[alt*="profile"]' + } +}; + +export default class InstagramModule { + async saveData(): Promise { + const collections: model.FactCollection[] = []; + + try { + if (this.isProfilePage()) { + const profile = this.extractProfile(); + if (profile) collections.push(profile); + + const posts = this.extractPostsList(); + if (posts) collections.push(posts); + } else if (this.isPostPage()) { + const post = this.extractDetailPost(); + if (post) collections.push(post); + + const comments = this.extractComments(); + if (comments) collections.push(comments); + } + else if(window.location.href.includes('/explore/') || window.location.href.includes('/search/')){ + const posts = this.extractPostsList(); + if (posts?.facts && posts.facts.length > 0) collections.push(posts); + + } + } catch (error) { + console.error("Error saving Instagram data:", error); + } + + return collections; + } + + private isProfilePage(): boolean { + return !window.location.pathname.includes('/p/') && !window.location.pathname.includes('/reel/') && !window.location.href.includes('/search/'); + } + + private isPostPage(): boolean { + return window.location.pathname.includes('/p/') || window.location.pathname.includes('/reel/'); + } + + + + private extractProfile(): model.FactCollection | null { + if (this.isSaved(document.querySelector(SELECTORS.profile.container) as HTMLElement) + ) { + return null; + } + + const profile = model.NewFactCollection('profile'); + profile.url = this.getCleanURL(); + + if (profile.url) profile.facts.push(model.NewFact("ig_profile_source_url", "url", profile.url, window.location.href)); + + const displayName = document.querySelector(SELECTORS.profile.displayName)?.textContent?.trim(); + + if (displayName) profile.facts.push(model.NewFact("ig_profile_display_name", "name", displayName, window.location.href)); + // Username from URL + const username = this.getUsername(); + if (username) profile.facts.push(model.NewFact("ig_profile_user_name", "name", username, window.location.href)); + + // Avatar + const avatar = document.querySelector(SELECTORS.profile.avatar) as HTMLImageElement; + if (avatar?.src) profile.facts.push(model.NewFact("ig_profile_profileimage_url", "url", avatar.src, window.location.href)); + + // Bio + const bioEl = document.querySelector(SELECTORS.profile.bio) as HTMLElement; + const bioLinkEl = document.querySelector('main header section:nth-child(4) > div > a') as HTMLAnchorElement; + const bioTurndown = turndownService.turndown(bioEl?.innerHTML?.trim() || ''); + + + + if (bioEl) { + profile.facts.push(model.NewFact("ig_profile_bio_text", "text", bioTurndown + , window.location.href)); + } + // Stats from meta description + const stats = this.extractStatsFromMeta(); + Object.entries(stats).forEach(([key, value]) => { + if (value) profile.facts.push(model.NewFact(key, "integer", String(this.parseNumber(value)), window.location.href)); + }); + + if (!stats.followerCount) { + // Fallback to followers count from profile page + const followersEl = document.querySelector(SELECTORS.profile.followers) as HTMLElement; + if (followersEl) { + const followerCount = followersEl.textContent?.trim(); + const cleanedfollowerCount = parseCount(followerCount.split(' ')[0]); + + if (followerCount) { + profile.facts.push(model.NewFact("ig_profile_follower_count", "integer", cleanedfollowerCount, window.location.href)); + } + } + } + + if (!stats.followerCount) { + // Fallback to following count from profile page + const followingEl = document.querySelector(SELECTORS.profile.following) as HTMLElement; + if (followingEl) { + const followingCount = followingEl.textContent?.trim(); + const cleanedFollowingCount = parseCount(followingCount.split(' ')[0]); + + if (followingCount) { + profile.facts.push(model.NewFact("ig_profile_following_count", "integer", cleanedFollowingCount, window.location.href)); + } + } + } + + if (profile.facts.length > 0) { + this.addClassSaved(document.querySelector(SELECTORS.profile.container) as HTMLElement); + return profile; + } else { + return null; + } + + } + + private extractPostsList(): model.FactCollection | null { + const posts = window.location.href.includes('/search/') ? model.NewFactCollection('search') : model.NewFactCollection('post'); + posts.url = this.getCleanURL(); + + const postLinks = document.querySelectorAll('a[href*="/p/"]:not(.saved), a[href*="/reel/"]:not(.saved)'); + + postLinks.forEach(link => { + posts.facts.push(model.NewFact("ig_post_source_url", "url", link.href, link.href)); + + // const img = link.querySelector('img') as HTMLImageElement; + // if (img?.src) { + // posts.facts.push(model.NewFact("thumbnail_url", "url", img.src, link.href)); + // if (img.alt) posts.facts.push(model.NewFact("description", "string", img.alt, link.href)); + // } + + // Tandai sebagai tersimpan + this.addClassSaved(link); + }); + + return posts.facts.length > 0 ? posts : null; + } + private extractFromJSON(): model.FactCollection | null { + const scripts = document.querySelectorAll('script[type="application/json"]:not(.saved)'); + const postId = this.getPostId(); + + for (const script of scripts) { + const content = script.textContent; + if (!content || !content.includes('like_count') || !content.includes(postId)) continue; + + try { + const data = JSON.parse(content); + const postData = this.findPostInJSON(data); + if (!postData) continue; + + console.log('๐Ÿ“ฆ Found post data structure:', { + hasCarousel: !!postData.carousel_media, + mediaType: postData.media_type, + productType: postData.product_type, + carouselCount: postData.carousel_media?.length || 0, + hasUser: !!(postData.user || postData.owner), + username: (postData.user || postData.owner)?.username + }); + + const post = model.NewFactCollection('post'); + post.url = this.getCleanURL(); + + // Basic fields + post.facts.push(model.NewFact("ig_post_source_url", "url", post.url, window.location.href)); + + // Extract user information + const userInfo = postData.user || postData.owner; + if (userInfo) { + console.log('๐Ÿ‘ค Found user info:', { + username: userInfo.username, + hasProfilePic: !!userInfo.profile_pic_url, + hasHDProfilePic: !!userInfo.hd_profile_pic_url_info?.url + }); + + if (userInfo.username) { + post.facts.push(model.NewFact("ig_post_user_name", "name", userInfo.username, window.location.href)); + + // Generate profile URL from username + const profileUrl = `https://www.instagram.com/${userInfo.username}/`; + post.facts.push(model.NewFact("ig_post_profile_url", "url", profileUrl, window.location.href)); + console.log('โœ… Added username and profile URL'); + } + + // Extract profile image + if (userInfo.profile_pic_url) { + post.facts.push(model.NewFact("ig_post_profileimage_url", "url", userInfo.profile_pic_url, window.location.href)); + console.log('โœ… Added profile image from profile_pic_url'); + } else if (userInfo.hd_profile_pic_url_info?.url) { + post.facts.push(model.NewFact("ig_post_profileimage_url", "url", userInfo.hd_profile_pic_url_info.url)); + console.log('โœ… Added profile image from hd_profile_pic_url_info'); + } + } else { + console.log('โš ๏ธ No user info found in post data'); + } + + // Extract media content (images/videos) and caption + const mediaContent = this.extractMediaFromJSON(postData, data); + if (mediaContent.content) { + post.facts.push(model.NewFact("ig_post_content_md", "md", mediaContent.content, window.location.href)); + console.log('โœ… Extracted media content successfully'); + } + + if (postData.like_count > 0) { + post.facts.push(model.NewFact("ig_post_like_count", "integer", String(postData.like_count), window.location.href)); + } + + if (postData.comment_count >= 0) { + post.facts.push(model.NewFact("ig_post_comment_count", "integer", String(postData.comment_count), window.location.href)); + } + + // // Legacy single media URL + // if (postData.display_url) { + // post.facts.push(model.NewFact("ig_post_media_url", "url", postData.display_url, window.location.href)); + // } + + const date = document.querySelector('time[datetime]'); + if (date) { + const datetime = date.getAttribute('datetime'); + if (datetime) { + post.facts.push(model.NewFact("ig_post_createdat_datetime", "datetime", datetime, window.location.href)); + } + } + + this.addClassSaved(script as HTMLElement); + return post.facts.length > 0 ? post : null; + } catch (error) { + console.error('โŒ Error parsing JSON:', error); + continue; + } + } + return null; + } + private extractDetailPost(): model.FactCollection | null { + const post = model.NewFactCollection('post'); + post.url = this.getCleanURL(); + + // Try JSON first, then DOM + const jsonPost = this.extractFromJSON(); + if (jsonPost) { + + return jsonPost; + } + + // Basic info + post.facts.push(model.NewFact("ig_post_source_url", "url", window.location.href)); + + const container = document.querySelector(SELECTORS.post.container); + if (!container || container.classList.contains('saved')) return null; + + // Username + const usernameEl = this.findElement(SELECTORS.post.username, container); + if (usernameEl) { + const username = this.extractUsernameFromHref(usernameEl.getAttribute('href')); + if (username) post.facts.push(model.NewFact("ig_post_user_name", "name", username, window.location.href)); + } + + // Profile URL + const profileUrlEl = this.findElement(SELECTORS.post.profileUrl, container); + if (profileUrlEl) { + const profileUrl = profileUrlEl.getAttribute('href'); + const fullProfileUrl = profileUrl?.includes('instagram.com') ? profileUrl : `https://www.instagram.com${profileUrl}`; + if (profileUrl) post.facts.push(model.NewFact("ig_post_profile_url", "url", fullProfileUrl, window.location.href)); + } + + // Avatar + const profileImage = this.findElement(SELECTORS.post.profileImage, container) as HTMLImageElement; + if (profileImage?.src) { + + post.facts.push(model.NewFact("ig_post_profileimage_url", "url", profileImage.src, window.location.href)); + } + + // Content of post + const caption = this.findElement(SELECTORS.post.caption, container)?.textContent?.trim(); + + const mediaContainer = this.findElement(SELECTORS.post.mediaContainer, container); + + const imageUrl = mediaContainer?.querySelectorAll(SELECTORS.post.imageUrl) as NodeListOf; + + const imageUrls: string[] = [...imageUrl].map(img => img.src).filter(src => src); + + + const videoUrl = mediaContainer?.querySelectorAll(SELECTORS.post.videoUrl) as NodeListOf; + + const videoUrls: string[] = [...videoUrl].map(video => video.src).filter(src => src); + + const contentParts: string[] = []; + + // Add images + if (imageUrls.length > 0) { + const imageMarkdown = imageUrls.map(url => `![image](${url})`).join('\n'); + contentParts.push(imageMarkdown); + } + + // Add video + if (videoUrls.length > 0) { + const videoMarkdown = videoUrls.map(url => `![video](${url})`).join('\n'); + contentParts.push(videoMarkdown); + } + // Add caption + if (caption) { + contentParts.push(caption); + } + + // Join dengan double newline untuk proper spacing + const contentTurndown = contentParts.join('\n\n'); + + post.facts.push(model.NewFact("ig_content_md", "md", contentTurndown, window.location.href)); + // Likes + const likesEl = this.findElement(SELECTORS.post.likeCount, container); + if (likesEl?.textContent) { + const likes = this.parseNumber(likesEl.textContent); + if (likes > 0) post.facts.push(model.NewFact("ig_post_like_count", "integer", String(likes), window.location.href)); + } + // Timestamp + const timeEl = this.findElement(SELECTORS.post.timestamp, container); + if (timeEl) { + const datetime = timeEl.getAttribute('datetime'); + if (datetime) post.facts.push(model.NewFact("ig_post_createdat_datetime", "datetime", datetime, window.location.href)); + } + + // Tandai container sebagai tersimpan + if (post.facts.length > 0) { + this.addClassSaved(container as HTMLElement); + } + + return post.facts.length > 0 ? post : null; + } + + // Extract post data from JSON embedded in the page (example: ../../htmlexample/instagram/detailPost.json) + + + private findPostInJSON(obj: any): any { + if (!obj || typeof obj !== 'object') return null; + + // Check if current object is a valid post + if (obj.like_count !== undefined && (obj.user || obj.owner)) { + return obj; + } + + // Check for carousel container (product_type: "carousel_container") + if (obj.product_type === 'carousel_container' && obj.carousel_media) { + return obj; + } + + // Search in common paths + const paths = [ + 'graphql.shortcode_media', + 'media', + '__bbox.result', + 'xdt_api__v1__media__shortcode__web_info.items.0', // New Instagram API format + 'require.0.3.0.__bbox.require.0.3.1.__bbox.result.data.xdt_api__v1__media__shortcode__web_info.items.0' + ]; + + for (const path of paths) { + const result = this.getNestedValue(obj, path); + if (result && (result.like_count !== undefined || result.carousel_media)) { + return result; + } + } + + // Special handling for nested carousel structure + if (obj.items && Array.isArray(obj.items) && obj.items.length > 0) { + const firstItem = obj.items[0]; + if (firstItem.carousel_media || firstItem.like_count !== undefined) { + return firstItem; + } + } + + // Recursive search with depth limit to avoid infinite loops + return this.findPostInJSONRecursive(obj, 0, 5); + } + + private findPostInJSONRecursive(obj: any, depth: number, maxDepth: number): any { + if (depth >= maxDepth || !obj || typeof obj !== 'object') return null; + + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const value = obj[key]; + + // Check current value + if (value && typeof value === 'object') { + if (value.like_count !== undefined && (value.user || value.owner)) { + return value; + } + if (value.product_type === 'carousel_container' && value.carousel_media) { + return value; + } + + // Recursive search + const result = this.findPostInJSONRecursive(value, depth + 1, maxDepth); + if (result) return result; + } + } + } + + return null; + } + + private extractMediaFromJSON(postData: any, fullData: any): { content: string } { + const contentParts: string[] = []; + const imageUrls: string[] = []; + const videoUrls: string[] = []; + + console.log('๐Ÿ” Extracting media from post data...'); + + // Check if it's a carousel post + if (postData.carousel_media && Array.isArray(postData.carousel_media)) { + console.log(`๐Ÿ“ธ Found carousel with ${postData.carousel_media.length} items`); + + // Extract media from carousel + for (const mediaItem of postData.carousel_media) { + if (mediaItem.media_type === 2 && mediaItem.video_versions) { + // Video media + const videoUrl = this.getBestVideoURL(mediaItem.video_versions); + if (videoUrl) { + videoUrls.push(videoUrl); + console.log('๐ŸŽฅ Added video URL from carousel'); + } + } else if (mediaItem.media_type === 1 && mediaItem.image_versions2?.candidates) { + // Image media + const imageUrl = this.getBestImageURL(mediaItem.image_versions2.candidates); + if (imageUrl) { + imageUrls.push(imageUrl); + console.log('๐Ÿ“ท Added image URL from carousel'); + } + } + } + } else { + console.log('๐Ÿ“ฑ Processing single media post'); + + // Single media post + if (postData.media_type === 2 && postData.video_versions) { + // Single video + const videoUrl = this.getBestVideoURL(postData.video_versions); + if (videoUrl) { + videoUrls.push(videoUrl); + // console.log('๐ŸŽฅ Added single video URL'); + } + } else if (postData.media_type === 1 && postData.image_versions2?.candidates) { + // Single image + const imageUrl = this.getBestImageURL(postData.image_versions2.candidates); + if (imageUrl) { + imageUrls.push(imageUrl); + // console.log('๐Ÿ“ท Added single image URL'); + } + } + } + + // Check for video_dash_prefetch_representations (from multiplepost.json structure) + const extensions = this.findExtensionsInJSON(fullData); + if (extensions?.all_video_dash_prefetch_representations) { + console.log(`๐ŸŽฌ Found ${extensions.all_video_dash_prefetch_representations.length} video dash representations`); + + for (const videoData of extensions.all_video_dash_prefetch_representations) { + if (videoData.representations) { + for (const rep of videoData.representations) { + if (rep.mime_type === 'video/mp4' && rep.base_url) { + videoUrls.push(rep.base_url); + // console.log('๐ŸŽฅ Added video URL from dash representation'); + break; // Take first video URL found per video + } + } + } + } + } + + console.log(`๐Ÿ“Š Media extraction result: ${imageUrls.length} images, ${videoUrls.length} videos`); + + // Build content in Markdown format + if (imageUrls.length > 0) { + const imageMarkdown = imageUrls.map(url => `![image](${url})`).join('\n'); + contentParts.push(imageMarkdown); + } + + if (videoUrls.length > 0) { + const videoMarkdown = videoUrls.map(url => `![video](${url})`).join('\n'); + contentParts.push(videoMarkdown); + } + + // Add caption if exists + if (postData.caption?.text) { + contentParts.push(postData.caption.text); + console.log('๐Ÿ“ Added caption text'); + } + + return { + content: contentParts.join('\n\n') + }; + } + + private findExtensionsInJSON(obj: any): any { + if (!obj || typeof obj !== 'object') return null; + + // Direct extensions property + if (obj.extensions) return obj.extensions; + + // Search recursively with limited depth + const searchExtensions = (data: any, depth: number = 0): any => { + if (depth > 3 || !data || typeof data !== 'object') return null; + + for (const key in data) { + if (data.hasOwnProperty(key)) { + if (key === 'extensions' && data[key]) return data[key]; + + if (typeof data[key] === 'object') { + const nested = searchExtensions(data[key], depth + 1); + if (nested) return nested; + } + } + } + return null; + }; + + return searchExtensions(obj); + } + + private getBestVideoURL(videoVersions: any[]): string | null { + if (!videoVersions || videoVersions.length === 0) return null; + + // Find highest quality video (type 101 is usually best quality) + const bestVideo = videoVersions.find(v => v.type === 101) || videoVersions[0]; + return bestVideo?.url || null; + } + + private getBestImageURL(candidates: any[]): string | null { + if (!candidates || candidates.length === 0) return null; + + // Find highest resolution image + const validCandidates = candidates.filter(c => c.url && c.width && c.height); + if (validCandidates.length === 0) return null; + + const bestImage = validCandidates.reduce((best, current) => { + const currentRes = current.width * current.height; + const bestRes = best.width * best.height; + return currentRes > bestRes ? current : best; + }, validCandidates[0]); + + return bestImage?.url || null; + } + + /** + * Extracts **all** top-level comments from an Instagram post + * using the exact DOM path proven in the JSDOM demo. + */ + private extractComments(): model.FactCollection | null { + const container = document.querySelector( + SELECTORS.comments.container + ) as HTMLElement | null; + + if (!container) return null; + + /* ๐Ÿ‘‰ grab every top-level comment block */ + const sections = container.querySelectorAll(':scope > div'); + if (sections.length === 0) return null; + + const facts = model.NewFactCollection('comment'); + + sections.forEach(section => { + if (section.classList.contains('saved')) return; + + const profile_url = section.querySelector(SELECTORS.comments.profile_url); + const user_name = section.querySelector(SELECTORS.comments.user_name); + const createdat_datetime = section.querySelector(SELECTORS.comments.createdat_datetime); + const profileimage_url = section.querySelector(SELECTORS.comments.profileimage_url); + const comment_url = section.querySelector(SELECTORS.comments.comment_url); + const like_count = section.querySelector(SELECTORS.comments.like_count); + const replie_count = section.querySelector(SELECTORS.comments.replie_count); + const content_md = section.querySelector(SELECTORS.comments.content_md); + + const commentUrl = comment_url?.getAttribute('href'); + + const fullCommentUrl = commentUrl ? new URL(commentUrl, window.location.href).toString() : null; + + if (fullCommentUrl) { + if (commentUrl) facts.facts.push(model.NewFact("ig_comment_source_url", "url", fullCommentUrl, window.location.href)); + } + + if (profile_url) { + const profileUrl = profile_url.getAttribute('href') + const fullProfileUrl = profileUrl?.includes('instagram.com') ? profileUrl : `https://www.instagram.com${profileUrl}`; + if (profileUrl) facts.facts.push(model.NewFact("ig_comment_profile_url", "name", fullProfileUrl, fullCommentUrl ?? window.location.href)); + + const username = this.extractUsernameFromHref(profile_url.getAttribute('href')); + if (username) facts.facts.push(model.NewFact("ig_comment_user_name", "name", username, fullCommentUrl ?? window.location.href)); + } + + + + if (content_md) { + const contentTurndown = turndownService.turndown(content_md.innerHTML); + if (contentTurndown) facts.facts.push(model.NewFact("ig_comment_content_md", "md", contentTurndown, fullCommentUrl ?? window.location.href)); + } + + if (profileimage_url) { + const imageUrl = (profileimage_url as HTMLImageElement).src; + if (imageUrl) facts.facts.push(model.NewFact("ig_comment_profileimage_url", "url", imageUrl, fullCommentUrl ?? window.location.href)); + } + + if (like_count) { + const likes = like_count.textContent?.trim() || '0'; + if (likes) { + const likeMatch = likes.replaceAll(',', '').match(/\d+/g); + const likeCountStr = likeMatch ? likeMatch.join('') : '0'; + facts.facts.push(model.NewFact("ig_comment_like_count", "integer", likeCountStr, fullCommentUrl ?? window.location.href)); + } + } + + if (replie_count) { + const replies = replie_count.textContent?.trim() || '0'; + if (replies) { + const replyMatch = replies.replaceAll(',', '').match(/\d+/g); + const replyCountStr = replyMatch ? replyMatch.join('') : '0'; + facts.facts.push(model.NewFact("ig_comment_comment_count", "integer", replyCountStr, fullCommentUrl ?? window.location.href)); + } + } + + if (createdat_datetime) { + const datetime = createdat_datetime.getAttribute('datetime'); + if (datetime) facts.facts.push(model.NewFact("ig_comment_createdat_datetime", "datetime", datetime, fullCommentUrl ?? window.location.href)); + } + + this.addClassSaved(section as HTMLElement); // Mark section as saved + }); + + // container.classList.add('saved'); + return facts.facts.length > 0 ? facts : null; + } + + + private getCleanURL(): string { + const url = new URL(window.location.href); + // Remove img_index parameter + url.searchParams.delete('img_index'); + return url.toString(); + } + + private getUsername(): string | null { + const segments = window.location.pathname.split('/').filter(Boolean); + return segments[0] && segments[0].length < 50 ? segments[0] : null; + } + + private extractStatsFromMeta(): Record { + const ogDesc = document.querySelector('meta[property="og:description"]'); + const content = ogDesc?.getAttribute('content') || ''; + + const stats: Record = {}; + + const patterns = { + ig_profile_post_count: /(\d+[,\d]*)\s*Posts/i, + ig_profile_follower_count: /(\d+[,\d]*)\s*Followers/i, + ig_profile_following_count: /(\d+[,\d]*)\s*Following/i + }; + + Object.entries(patterns).forEach(([key, pattern]) => { + const match = content.match(pattern); + if (match) stats[key] = match[1]; + }); + + return stats; + } + + private extractUsernameFromHref(href: string | null): string | null { + if (!href) return null; + const match = href.match(/\/([^\/]+)\/$/); + return match && match[1] !== 'p' && match[1] !== 'reel' ? match[1] : null; + } + + private findElement(selector: string, container?: Element | null): Element | null { + return container ? container.querySelector(selector) : document.querySelector(selector); + } + + private parseNumber(text: string): number { + const clean = text.toLowerCase().replace(/[^0-9km.,]/g, ''); + if (clean.includes('k')) return Math.round(parseFloat(clean.replace('k', '')) * 1000); + if (clean.includes('m')) return Math.round(parseFloat(clean.replace('m', '')) * 1000000); + return parseInt(clean.replace(/[,.]/g, '')) || 0; + } + + private getNestedValue(obj: any, path: string): any { + return path.split('.').reduce((current, key) => current?.[key], obj); + } + + private isTimeElement(text: string): boolean { + return /^\d+[dwmh]$/.test(text); + } + + + + private getPostId(): string { + const match = window.location.pathname.match(/\/p\/([^/]+)/); + return match ? match[1] : ''; + } + + tagElement(): void { + const elements = document.querySelectorAll('article, img[alt*="profile"], a[href*="/p/"], a[href*="/reel/"]'); + elements.forEach(el => el.classList.add('tagged')); + console.log(window.location.hostname); + + + } + + addClassTagged(element: HTMLElement): void { + element.classList.add('tagged'); + } + + addClassSaved(element: HTMLElement): void { + element.classList.add('saved'); + } + + isTagged(element: HTMLElement): boolean { + return element.classList.contains('tagged'); + } + + isSaved(element: HTMLElement): boolean { + return element.classList.contains('saved'); + } + + +} diff --git a/src/modules/linkedin.ts b/src/modules/linkedin.ts new file mode 100644 index 0000000..afb2031 --- /dev/null +++ b/src/modules/linkedin.ts @@ -0,0 +1,629 @@ +import * as model from "../model"; +import { NewFact } from "../model"; +import { turndownService } from "../utils/turndown"; + +const ELEMENT_SELECTORS = { + profile: { + container: "#recent-activity-top-card", + displayName: "h1[class*='org-top-card-summary__title'], #recent-activity-top-card h3", + description: "#recent-activity-top-card h4", + image: "[class*='logo-container'] img, img.pv-recent-activity-top-card__member-photo", + followerCount: "li[class='text-body-small t-black--light inline-block'] > span, div.pv-recent-activity-top-card__extra-info > div > div:nth-child(2)", + }, + post: { + container: "[data-view-name='feed-full-update']", + showMore: "button[class*='text__see-more'], button[role='button'][class*='see-more']", + url: "div.feed-shared-update-v2", + displayName: "span[class='update-components-actor__title'] span[dir='ltr'] > span > span, span[class='update-components-actor__title'] span[dir='ltr'] > span:first-of-type", + username: "span.feed-shared-actor__sub-description a", + profileImage: "div[class*='ivm-view-attr__img-wrapper'] img", + content: [ + "div.feed-shared-inline-show-more-text", + "[class*='update-components-image__container-wrapper'] img", + "[class*='video-js media-player__player'] video", + "div.ivm-view-attr__blur-background img", + ], + like: ["button[aria-label*='reactions'] span", "button.social-details-social-counts__count-value span"], + comment: "li[class*='social-details-social-counts__comments'] button", + share: "button[class*='social-details-social-counts__item--truncate-text'] > span", + date: '.update-components-actor__sub-description span[aria-hidden="true"]' + }, + comment: { + container: "article.comments-comment-entity", + displayName: "span.comments-comment-meta__description-title", + urlProfile: "article.comments-comment-entity a", + profileImage: "div[class*='ivm-view-attr__img-wrapper'] img", + content: [ + "div.update-components-text", + "[class*='update-components-image__container-wrapper'] img", + "[class*='video-js media-player__player'] video", + ], + like: "button.comments-comment-social-bar__reactions-count--cr > span", + commentCount: "span.comments-comment-social-bar__replies-count--cr", + date: "time.comments-comment-meta__data" + } +} as const; + +export default class LinkedInModule { + context: string = ""; + followerCount: string = ""; + private currentUrl: string = ""; + + constructor() { + this.currentUrl = window.location.href; + this.updateFollowerCount(); + + // Reset follower count when URL changes (different profile) + window.addEventListener('popstate', () => this.handleUrlChange()); + + // Monitor URL changes for SPA navigation + setInterval(() => { + if (window.location.href !== this.currentUrl) { + this.handleUrlChange(); + } + }, 1000); + + // Continue monitoring follower count + setInterval(() => this.updateFollowerCount(), 2000); + } + + private handleUrlChange(): void { + const newUrl = window.location.href; + if (newUrl !== this.currentUrl) { + console.log('LinkedIn URL changed from', this.currentUrl, 'to', newUrl); + this.currentUrl = newUrl; + + // Reset follower count for new profile + this.followerCount = ""; + + // Start monitoring follower count for new page + this.updateFollowerCount(); + } + } + + + private updateFollowerCount() { + const followerElement = document.querySelector(ELEMENT_SELECTORS.profile.followerCount); + + + if (followerElement && followerElement.textContent && this.followerCount == "") { + const newCount = followerElement.textContent.trim(); + if (newCount !== this.followerCount) { + this.followerCount = newCount; + console.log("Follower count updated:", this.followerCount); + } + } + } + + tagElement(): void { + Object.keys(ELEMENT_SELECTORS).forEach((type) => { + const selectorObj = ELEMENT_SELECTORS[type as keyof typeof ELEMENT_SELECTORS]; + Object.entries(selectorObj).forEach(([key, value]) => { + if (key === "showMore" && typeof value === 'string') { + const showMoreBtns = document.querySelectorAll(value); + showMoreBtns.forEach(btn => { + try { + // Check if button is visible and clickable before clicking + if (btn && btn.offsetParent !== null && !(btn as HTMLButtonElement).disabled) { + btn.click(); + console.log("Clicked show more button in tagElement"); + } + } catch (e) { + console.error("Error clicking show more button in tagElement:", e); + } + }); + return; + } + + if (Array.isArray(value)) { + value.forEach(sel => { + document.querySelectorAll(sel).forEach(element => this.addClassTagged(element)); + }); + } else { + document.querySelectorAll(value).forEach(element => this.addClassTagged(element)); + } + }); + }); + } + + async saveData(): Promise { + const factCollections: model.FactCollection[] = []; + + try { + if (this.isProfilePage()) { + await this.handleProfilePage(factCollections); + } else if (this.isPostPage()) { + await this.handlePostPage(factCollections); + }else if(this.isSearchPage()){ + await this.handleProfilePage(factCollections); + } + } catch (error) { + console.error("Error in saveData:", error); + } + + return factCollections; + } + + private isProfilePage(): boolean { + return window.location.href.includes('/company/') || window.location.href.includes('/in/'); + } + + private isPostPage(): boolean { + return window.location.href.includes('/posts/') || window.location.href.includes('/feed/update/'); + } + + private isSearchPage(): boolean { + return window.location.href.includes('/search/results/'); + } + + private async handleProfilePage(factCollections: model.FactCollection[]): Promise { + console.log("Extracting profile data..."); + const profile = this.saveProfile(); + if (profile.facts.length > 0) { + + factCollections.push(profile); + this.markElementSaved(ELEMENT_SELECTORS.profile.container); + console.log("Profile data extracted successfully"); + } + + console.log("Extracting posts data..."); + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + console.log(`${posts.facts.length} post facts extracted successfully`); + } else { + console.log("No posts found or extraction failed"); + } + } + + private async handlePostPage(factCollections: model.FactCollection[]): Promise { + console.log("Extracting post data..."); + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + console.log(`${posts.facts.length} post facts extracted successfully`); + } + + console.log("Extracting comments data..."); + const comments = this.saveComment(); + if (comments.facts.length > 0) { + factCollections.push(comments); + this.markElementSaved(ELEMENT_SELECTORS.comment.container); + console.log(`${comments.facts.length} comment facts extracted successfully`); + } + } + + private markElementSaved(selector: string): void { + const element = document.querySelector(selector) || document.body; + this.addClassSaved(element); + } + + saveProfile(): model.FactCollection { + const profileContainer = document.querySelector( + ELEMENT_SELECTORS.profile.container + ); + + /* 1. Skip if already saved */ + if (profileContainer && this.isSaved(profileContainer)) { + const profileData = model.NewFactCollection('profile'); + return profileData; + } + + /* 2. Build the fact collection */ + const cleanedUrl = window.location.href.replaceAll('/recent-activity/all/', ''); + const profile: model.FactCollection = { + url: window.location.href, + facts: [], + savedAt: new Date().toISOString(), + type: 'profile', + }; + + const elements = ELEMENT_SELECTORS.profile; + const profileUrl = model.NewFact('li_profile_source_url', 'url', cleanedUrl, cleanedUrl); + + // Get display name directly + const displayNameElement = document.querySelector(elements.displayName); + const displayName = displayNameElement?.textContent?.trim() || ''; + const displayNameFact = NewFact('li_profile_display_name', 'name', displayName, cleanedUrl); + + // Get description directly + const descriptionElement = document.querySelector(elements.description); + const description = descriptionElement?.textContent?.trim() || ''; + const descriptionFact = NewFact('li_profile_bio_text', 'text', description, cleanedUrl); + + if ( + displayNameFact?.value && + descriptionFact?.value + ) { + // Get profile image directly + const imgElement = document.querySelector(elements.image); + const profileImageSrc = imgElement?.getAttribute('src') || ''; + const profileImageFact = NewFact('li_profile_profileimage_url', 'url', profileImageSrc, cleanedUrl); + + // Get follower count directly + let followerCountFact; + if (!this.followerCount || this.followerCount.trim() === '') { + followerCountFact = NewFact('li_profile_follower_count', 'number', '0', cleanedUrl); + } else { + const cleanedText = this.followerCount.trim().replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); + const followerCount = Number(numericValue) || 0; + followerCountFact = NewFact('li_profile_follower_count', 'number', followerCount.toString(), cleanedUrl); + } + + const profileFacts: model.Fact[] = [ + profileUrl, + displayNameFact, + descriptionFact, + profileImageFact, + followerCountFact, + ]; + profile.facts.push(...profileFacts); + + /* 3. Mark container as saved only after success */ + if (profileContainer) { + this.addClassSaved(profileContainer); + } + console.log('Profile data extracted successfully'); + } else { + console.log('Profile extraction skipped โ€“ missing required fields'); + } + + return profile; + } + + async savePost(): Promise { + const posts: model.FactCollection = window.location.href.includes('/search/') ? model.NewFactCollection('search') : model.NewFactCollection('post'); + const selectors = ELEMENT_SELECTORS.post; + const postElements = document.querySelectorAll(`${selectors.container}:not(.saved)`); + + // First, click all show more buttons and wait for content to load + await this.expandAllPostContent(postElements, selectors.showMore); + + postElements.forEach((element) => { + const postFacts = this.extractPostFromElement(element, selectors); + posts.facts.push(...postFacts); + this.addClassSaved(element); + }); + + console.log(`Total post facts extracted: ${posts.facts.length}`); + return posts; + } + + private async expandAllPostContent(postElements: NodeListOf, showMoreSelector: string): Promise { + const clickPromises: Promise[] = []; + + postElements.forEach((element) => { + const btnShowMore = element.querySelectorAll(showMoreSelector); + + btnShowMore.forEach(btn => { + // Check if button is visible and clickable + if (btn && btn.offsetParent !== null && !(btn as HTMLButtonElement).disabled) { + clickPromises.push(this.clickShowMoreButton(btn)); + } + }); + }); + + // Wait for all buttons to be clicked + await Promise.all(clickPromises); + + // Additional wait to allow content to fully load after clicking + if (clickPromises.length > 0) { + await this.delay(500); // Wait 500ms for content to load + console.log(`Clicked ${clickPromises.length} show more buttons and waited for content to load`); + } + } + + private async clickShowMoreButton(button: HTMLElement): Promise { + return new Promise((resolve) => { + try { + button.click(); + // Small delay after each click to prevent overwhelming the page + setTimeout(resolve, 100); + } catch (e) { + console.error("Error clicking show more button:", e); + resolve(); + } + }); + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + saveComment(): model.FactCollection { + const comments: model.FactCollection = model.NewFactCollection('comment'); + const selectors = ELEMENT_SELECTORS.comment; + const commentElements = document.querySelectorAll(`${selectors.container}:not(.saved)`); + + commentElements.forEach(element => this.addClassTagged(element)); + + commentElements.forEach((element) => { + const commentFacts = this.extractCommentFromElement(element, selectors); + comments.facts.push(...commentFacts); + this.addClassSaved(element); + }); + + console.log(`Total comment facts extracted: ${comments.facts.length}`); + return comments; + } + + addClassTagged(element: HTMLElement): void { + element.classList.add('tagged'); + } + + addClassSaved(element: HTMLElement): void { + element.classList.add('saved'); + } + + private extractPostFromElement(element: HTMLElement, selectors: typeof ELEMENT_SELECTORS.post): model.Fact[] { + const facts: model.Fact[] = []; + const urnEl = element.querySelector(selectors.url); + + + const urn = urnEl?.getAttribute('data-urn') || ''; + const contextUrl = `https://www.linkedin.com/feed/update/${urn}`; + + if (urn) { + facts.push(NewFact('li_post_source_url', 'url', contextUrl, contextUrl)); + } + + const displayNameEl = element.querySelector(selectors.displayName); + if (displayNameEl?.textContent?.trim()) { + facts.push(NewFact('li_post_display_name', 'name', displayNameEl.textContent.trim(), contextUrl)); + } + + const usernameEl = element.querySelector(selectors.username); + if (usernameEl?.textContent?.trim()) { + facts.push(NewFact('li_post_user_name', 'name', usernameEl.textContent.trim(), contextUrl)); + } + + const profileImageEl = element.querySelector(selectors.profileImage); + if (profileImageEl?.getAttribute('src')) { + const profileImageUrl = profileImageEl.getAttribute('src') || ''; + facts.push(NewFact('li_post_profileimage_url', 'url', profileImageUrl, contextUrl)); + } + + const content = this.extractContent(element, [...selectors.content]); + if (content) { + facts.push(NewFact('li_post_content_md', 'md', content, contextUrl)); + } + + const likeCount = this.extractLikeCount(element, [...selectors.like]); + if (likeCount) { + facts.push(NewFact('li_post_like_count', 'number', likeCount, contextUrl)); + } + + const commentCount = this.extractCommentCount(element, selectors.comment); + if (commentCount) { + facts.push(NewFact('li_post_comment_count', 'number', commentCount, contextUrl)); + } + + const shareCount = this.extractShareCount(element, selectors.share); + if (shareCount) { + facts.push(NewFact('li_post_share_count', 'number', shareCount, contextUrl)); + } + + const date = this.extractDate(selectors.date) + // example value: "3hr" + if (date) { + facts.push(NewFact('li_post_duration_string', 'string', date, contextUrl)); + } + + return facts; + } + + private extractCommentFromElement(element: HTMLElement, selectors: typeof ELEMENT_SELECTORS.comment): model.Fact[] { + const profileUrlEl = element.querySelector(selectors.urlProfile); + const profileUrl = profileUrlEl?.getAttribute('href') + const cleanedUrl = profileUrl?.replace('https://www.linkedin.com/in/', '').replace("/", "") + + const contextUrl = `${window.location.href.replace(/\/$/, '')}#comment_by=${cleanedUrl}`; + const facts: model.Fact[] = []; + + const displayNameEl = element.querySelector(selectors.displayName); + if (displayNameEl?.textContent?.trim()) { + facts.push(NewFact('li_comment_display_name', 'name', displayNameEl.textContent.trim(), contextUrl)); + } + + const profileImageEl = element.querySelector(selectors.profileImage); + if (profileImageEl?.getAttribute('src')) { + const profileImageUrl = profileImageEl.getAttribute('src') || ''; + facts.push(NewFact('li_comment_profileimage_url', 'url', profileImageUrl, contextUrl)); + } + + const content = this.extractContent(element, [...selectors.content]); + if (content) { + facts.push(NewFact('li_comment_content_md', 'md', content, contextUrl)); + } + + const likeEl = element.querySelector(selectors.like); + if (likeEl?.textContent?.trim()) { + // Hapus semua karakter non-digit kecuali titik dan koma, lalu hapus pemisah ribuan + const cleanedText = likeEl.textContent.trim().replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); // Hapus titik dan koma + const likeCount = Number(numericValue) || 0; + facts.push(NewFact('li_comment_like_count', 'number', likeCount.toString(), contextUrl)); + } else { + facts.push(NewFact('li_comment_like_count', 'number', "0", contextUrl)); + } + + const commentCountEl = element.querySelector(selectors.commentCount); + if (commentCountEl?.textContent?.trim()) { + const commentText = commentCountEl.textContent.trim().split(' ')[0]; + // Hapus semua karakter non-digit kecuali titik dan koma, lalu hapus pemisah ribuan + const cleanedText = commentText.replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); // Hapus titik dan koma + const commentCount = Number(numericValue) || 0; + facts.push(NewFact('li_comment_comment_count', 'number', commentCount.toString(), contextUrl)); + } else { + facts.push(NewFact('li_comment_comment_count', 'number', "0", contextUrl)); + } + + const dateEl = element.querySelector(selectors.date); + if (dateEl && dateEl.textContent) { + const dateText = dateEl.textContent.trim(); + facts.push(NewFact('li_comment_duration_string', 'string', dateText, contextUrl)); + } + + return facts; + } + + private extractDate(selector: string): string { + const dateEl = document.querySelector(selector); + if (dateEl && dateEl.textContent) { + const dateText = dateEl.textContent.trim(); + return dateText; + } + return "" + } + + private extractContent(element: HTMLElement, contentSelectors: string[]): string { + let textContent = ''; + const mediaUrls: string[] = []; + + for (const contentSelector of contentSelectors) { + const contentEls = element.querySelectorAll(contentSelector); + + contentEls.forEach(el => { + if (!el.querySelector('img') && !el.querySelector('video') && + el.tagName !== 'IMG' && el.tagName !== 'VIDEO') { + if (el.textContent && el.textContent.trim() !== '') { + textContent += el.outerHTML; + } + } + + this.extractVideoUrls(el, mediaUrls); + this.extractImageUrls(el, mediaUrls); + }); + } + + const markdownText = textContent ? turndownService.turndown(textContent) : ''; + const uniqueMediaUrls = this.cleanMediaUrls(mediaUrls); + + let mediaContent = ''; + if (uniqueMediaUrls.length > 0) { + mediaContent = uniqueMediaUrls.map((url: string) => `[photo](${url})`).join('\n'); + } + + return mediaContent + markdownText; + } + + private extractLikeCount(element: HTMLElement, likeSelectors: string[]): string | null { + for (const likeSelector of likeSelectors) { + const likeEl = element.querySelector(likeSelector); + if (likeEl?.textContent?.trim()) { + // Hapus semua karakter non-digit kecuali titik dan koma, lalu hapus pemisah ribuan + const cleanedText = likeEl.textContent.trim().replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); // Hapus titik dan koma + const likeCount = Number(numericValue) || 0; + return likeCount.toString(); + } + } + return null; + } + + private extractCommentCount(element: HTMLElement, commentSelector: string): string | null { + const commentEl = element.querySelector(commentSelector); + if (commentEl?.getAttribute('aria-label')) { + const ariaLabel = commentEl.getAttribute('aria-label')?.split(' ')[0].trim() || '0'; + // Hapus semua karakter non-digit kecuali titik dan koma, lalu hapus pemisah ribuan + const cleanedText = ariaLabel.replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); // Hapus titik dan koma + const commentCount = Number(numericValue) || 0; + return commentCount.toString(); + } + return null; + } + + private extractShareCount(element: HTMLElement, shareSelector: string): string | null { + const shareEl = element.querySelector(shareSelector); + if (shareEl?.textContent?.trim()) { + const shareText = shareEl.textContent.trim().split(' ')[0] || '0'; + // Hapus semua karakter non-digit kecuali titik dan koma, lalu hapus pemisah ribuan + const cleanedText = shareText.replace(/[^\d.,]/g, ''); + const numericValue = cleanedText.replace(/[.,]/g, ''); // Hapus titik dan koma + const shareCount = Number(numericValue) || 0; + return shareCount.toString(); + } + return null; + } + + private extractVideoUrls(element: Element, mediaUrls: string[]): void { + if (element.tagName === 'VIDEO') { + const posterUrl = element.getAttribute('poster'); + if (posterUrl && !mediaUrls.includes(posterUrl)) { + mediaUrls.push(posterUrl); + } + return; + } + + const videoElements = element.querySelectorAll('video'); + videoElements.forEach(videoEl => { + const posterUrl = videoEl.getAttribute('poster'); + if (posterUrl && !mediaUrls.includes(posterUrl)) { + mediaUrls.push(posterUrl); + } + }); + } + + private extractImageUrls(element: Element, mediaUrls: string[]): void { + if (element.tagName === 'IMG') { + const imgSrc = element.getAttribute('src'); + if (imgSrc && !mediaUrls.includes(imgSrc)) { + mediaUrls.push(imgSrc); + } + return; + } + + const imgElements = element.querySelectorAll('img'); + imgElements.forEach(imgEl => { + const imgSrc = imgEl.getAttribute('src'); + if (imgSrc && !mediaUrls.includes(imgSrc)) { + mediaUrls.push(imgSrc); + } + }); + } + + private cleanMediaUrl(url: string): string { + try { + return url.split('?')[0]; + } catch (e) { + return url; + } + } + + private cleanMediaUrls(urls: string[]): string[] { + const uniqueUrls: string[] = []; + + for (const url of urls) { + if (!uniqueUrls.includes(url)) { + uniqueUrls.push(url); + } + } + + const cleanedUrls: string[] = []; + const cleanedBaseUrls: string[] = []; + + for (const url of uniqueUrls) { + const baseUrl = this.cleanMediaUrl(url); + + if (!cleanedBaseUrls.includes(baseUrl)) { + cleanedBaseUrls.push(baseUrl); + cleanedUrls.push(url); + } + } + + return cleanedUrls; + } + + isTagged(element: HTMLElement): boolean { + return element.classList.contains('tagged'); + } + + isSaved(element: HTMLElement): boolean { + return element.classList.contains('saved'); + } +} diff --git a/src/modules/tiktok.ts b/src/modules/tiktok.ts new file mode 100644 index 0000000..289c14d --- /dev/null +++ b/src/modules/tiktok.ts @@ -0,0 +1,705 @@ +import * as model from "../model"; +import { NewFact } from "../model"; +import { turndownService } from "../utils/turndown"; +import { parseCount } from "../helper/parseCount"; + +const ELEMENT_SELECTORS = { + profile: { + ld: 'script[id="__UNIVERSAL_DATA_FOR_REHYDRATION__"]', + }, + post: { + container: "div[data-e2e$='item-list'] div[data-e2e$='item']", + url: "div[data-e2e$='post-item'] a[href*='/video/'], div[data-e2e$='post-item'] a[href*='/photo/'], div[data-e2e$='search_top-item'] a[href*='/@']", + profileImage: '[data-e2e="browse-user-avatar"] img[src]', + view: '[data-e2e="video-views"]', + content: ["[data-e2e='browse-video'] > div", "[data-e2e='browse-video-desc']", "picture > img"], + username: '[data-e2e="browse-username"]', + displayName: '[data-e2e="browser-nickname"] span:first-child', + date: '[data-e2e="browser-nickname"] span:last-child', + like: '[data-e2e="browse-like-count"], [data-e2e="like-count"]', + share: '[data-e2e="share-count"]', + comment: '[data-e2e="browse-comment-count"], [data-e2e="comment-count"]', + }, + postVideo: { + container: "div[class*='DivBrowserModeContainer']", + profileImage: '[data-e2e="browse-user-avatar"] img[src]', + content: "[data-e2e='browse-video-desc']", + videoUrl: "[data-e2e='browse-video'] > div > video[src", + view: `a[href*="${window.location.href}"] [data-e2e="video-views"]`, + username: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:first-child', + displayName: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:nth-child(3) > span:first-child', + date: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:nth-child(3) > span:last-child', + likeCount: '[data-e2e="browse-like-count"], [data-e2e="like-count"]', + commentCount: '[data-e2e="browse-comment-count"], [data-e2e="comment-count"]', + shareCount: '[data-e2e="share-count"]', + bookmarkCount: '[data-e2e="undefined-count"]', + }, + postPhoto: { + container: "[class*='DivBrowserModeContainer'], [class*='DivPlayerContainer']", + content: "[data-e2e='browse-video-desc']", + photoUrl: "[class*='DivPhotoVideoContainer']", + profileImage: '[data-e2e="browse-user-avatar"] img[src]', + username: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:first-child, [data-e2e="browse-username"]', + displayName: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:nth-child(3) > span:first-child, [data-e2e="browser-nickname"] span:first-child', + date: 'div[class*="DivDescriptionContentWrapper"] a:nth-child(2) > span:nth-child(3) > span:last-child, [data-e2e="browser-nickname"] span:last-child', + like: '[data-e2e="browse-like-count"], [data-e2e="like-count"]', + view: `a[href*="${window.location.href}"] [data-e2e="video-views"]`, + share: '[data-e2e="share-count"]', + bookmarkCount: '[data-e2e="undefined-count"]', + comment: '[data-e2e="browse-comment-count"], [data-e2e="comment-count"]', + }, + comment: { + container: 'div[class*="DivCommentItemContainer"], div[class*="DivCommentObjectWrapper"]', + url: 'div[class*="DivContentContainer"] > a', + content: '[data-e2e="comment-level-1"] span, [data-e2e="comment-level-1"] p', + username: '[data-e2e="comment-avatar-1"], [data-e2e="comment-username-1"] > div > a ', + displayName: '[data-e2e="comment-username-1"]', + date: '[data-e2e="comment-time-1"], [class*="DivCommentSubContentWrapper"] > span:first-child', + commentCount: '[class*="DivReplyActionContainer"] > p, [class*="DivViewRepliesContainer"] > span', + like: '[data-e2e="comment-like-count"], [aria-label*="likes"] span', + } +} as const; + +export default class TiktokModule { + context: string = ""; + + tagElement(): void { + Object.keys(ELEMENT_SELECTORS).forEach((type) => { + const selectorObj = ELEMENT_SELECTORS[type as keyof typeof ELEMENT_SELECTORS]; + Object.entries(selectorObj).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(sel => { + document.querySelectorAll(sel).forEach(element => this.addClassTagged(element)); + }); + } else { + document.querySelectorAll(value).forEach(element => this.addClassTagged(element)); + } + }); + }); + } + + async saveData(): Promise { + const factCollections: model.FactCollection[] = []; + + try { + if (this.isSearchPage()) { + console.log("Detected search page"); + await this.handleSearchPage(factCollections); + } else if (this.isProfilePage()) { + + await this.handleProfilePage(factCollections); + } else if (this.isVideoPage()) { + await this.handleVideoPage(factCollections); + } else if (this.isPhotoPage()) { + await this.handlePhotoPage(factCollections); + } + } catch (error) { + console.error("Error in saveData:", error); + } + + return factCollections; + } + + private async handleSearchPage(factCollections: model.FactCollection[]): Promise { + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + + return; + } + + private isProfilePage(): boolean { + return !window.location.href.includes('/video/') && !window.location.href.includes('/photo/'); + } + + private isSearchPage(): boolean { + return window.location.href.includes('/search'); + } + + private isVideoPage(): boolean { + return window.location.href.includes('/video/'); + } + + private isPhotoPage(): boolean { + return window.location.href.includes('/photo/'); + } + + private async handleProfilePage(factCollections: model.FactCollection[]): Promise { + const profile = this.saveProfile(); + if (profile.facts.length > 0) { + factCollections.push(profile); + } + + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + } + + private async handleVideoPage(factCollections: model.FactCollection[]): Promise { + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + + const comments = this.saveComment(); + if (comments.facts.length > 0) { + factCollections.push(comments); + this.markElementSaved(ELEMENT_SELECTORS.comment.container); + } + } + + private async handlePhotoPage(factCollections: model.FactCollection[]): Promise { + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + + const comments = this.saveComment(); + if (comments.facts.length > 0) { + factCollections.push(comments); + this.markElementSaved(ELEMENT_SELECTORS.comment.container); + } + } + + private markElementSaved(selector: string): void { + const element = document.querySelector(selector) || document.body; + this.addClassSaved(element); + } + + saveProfile(): model.FactCollection { + const profile: model.FactCollection = model.NewFactCollection('profile'); + + profile.facts.push(NewFact('tt_profile_source_url', 'url', window.location.href, window.location.href)); + const scriptTag = document.querySelector(`${ELEMENT_SELECTORS.profile.ld}:not(.saved)`); + if (!scriptTag || !scriptTag.textContent) { + return profile; + } + + try { + const data = JSON.parse(scriptTag.textContent); + const userInfo = data?.__DEFAULT_SCOPE__?.['webapp.user-detail']?.userInfo; + if (userInfo && userInfo.user) { + // Direct NewFact calls for profile data + const username = userInfo.user?.uniqueId || ''; + if (username) { + profile.facts.push(NewFact('tt_profile_user_name', 'name', username, window.location.href)); + } + + const displayName = userInfo.user?.nickname || ''; + if (displayName) { + profile.facts.push(NewFact('tt_profile_display_name', 'name', displayName, window.location.href)); + } + + const description = userInfo.user?.signature || ''; + if (description) { + profile.facts.push(NewFact('tt_profile_bio_text', 'text', description, window.location.href)); + } + + const image = userInfo.user?.avatarLarger || userInfo.user?.avatarThumb || ''; + if (image) { + profile.facts.push(NewFact('tt_profile_profileimage_url', 'url', image, window.location.href)); + } + + const followerCount = userInfo.stats?.followerCount?.toString() || '0'; + profile.facts.push(NewFact('tt_profile_follower_count', 'number', followerCount, window.location.href)); + + const followingCount = userInfo.stats?.followingCount?.toString() || '0'; + profile.facts.push(NewFact('tt_profile_following_count', 'number', followingCount, window.location.href)); + + const postCount = userInfo.stats?.videoCount?.toString() || '0'; + profile.facts.push(NewFact('tt_profile_post_count', 'number', postCount, window.location.href)); + + const likeCount = userInfo.stats?.heartCount?.toString() || '0'; + profile.facts.push(NewFact('tt_profile_like_count', 'number', likeCount, window.location.href)); + + this.addClassSaved(scriptTag); + } + } catch (error) { + console.error("Error parsing TikTok profile:", error); + } + + return profile; + } + + async savePost(): Promise { + const posts: model.FactCollection = window.location.href.includes('/search/') ? model.NewFactCollection('search') : model.NewFactCollection('post'); + + if (this.isVideoPage()) { + await this.extractVideoPost(posts); + } else if (this.isPhotoPage()) { + await this.extractPhotoPost(posts); + } else { + await this.extractAllPosts(posts); + } + + return posts; + } + + private async extractVideoPost(posts: model.FactCollection): Promise { + const scriptTag = document.querySelector("script#__UNIVERSAL_DATA_FOR_REHYDRATION__:not(.saved)"); + let dataExtracted = false; + + if (scriptTag && scriptTag.textContent) { + try { + const data = JSON.parse(scriptTag.textContent); + const stats = data?.['__DEFAULT_SCOPE__']['webapp.video-detail']["itemInfo"]["itemStruct"] || + data?.['__DEFAULT_SCOPE__']['webapp.video-detail']["itemInfo"]["itemStruct"]["stats"]; + + const url = window.location.href; + const urlInJson = data?.['__DEFAULT_SCOPE__']['seo.abtest']['canonical']; + + if (stats && url === urlInJson) { + this.createPostFacts(stats, posts, url); + this.addClassSaved(document.body); + dataExtracted = true; + scriptTag.classList.add('saved'); + } else { + console.warn("No stats found in video detail data or URL mismatch"); + } + } catch (error) { + console.error("Error parsing TikTok video post:", error); + } + } + + if (!dataExtracted) { + this.extractVideoPostFromDOM(posts); + } + } + + private extractVideoPostFromDOM(posts: model.FactCollection): void { + const url = window.location.href; + const container = document.querySelector(`${ELEMENT_SELECTORS.postVideo.container}:not(.saved)`); + const selectors = ELEMENT_SELECTORS.postVideo; + + if (!container) { + console.warn("โŒ No container found for video post extraction or already saved"); + return; + } + + posts.facts.push(NewFact('tt_post_source_url', 'url', url, url)); + + const displayName = this.getDisplayName(container, selectors.displayName) || + this.getDisplayName(document, selectors.displayName); + if (displayName) { + posts.facts.push(NewFact('tt_post_display_name', 'name', displayName, url)); + } + + const username = this.getUsername(container, selectors.username) || + this.getUsername(document, selectors.username); + if (username) { + posts.facts.push(NewFact('tt_post_user_name', 'name', username, url)); + + posts.facts.push(NewFact('tt_post_profile_url', 'url', `https://www.tiktok.com/@${this.extractUsernameFromUrl(username)}`, url)); + } + + + + const profileImageElement = document.querySelector(selectors.profileImage); + if (profileImageElement?.src) { + posts.facts.push(NewFact('tt_post_profileimage_url', 'url', profileImageElement.src, url)); + } + + const content = this.getContent(container, selectors.content) || + this.getContent(document, selectors.content); + if (content) { + posts.facts.push(NewFact('tt_post_content_md', 'md', content, url)); + } + + const viewElement = document.querySelector(`a[href="${window.location.href}"] [data-e2e="video-views"]`) || + document.querySelector(selectors.view); + if (viewElement?.textContent) { + const viewCount = parseCount(viewElement.textContent.trim()); + posts.facts.push(NewFact('tt_post_view_count', 'number', viewCount, url)); + } + + const likeCount = this.getInteractionCount(container, selectors.likeCount) || + this.getInteractionCount(document, selectors.likeCount); + if (likeCount) { + posts.facts.push(NewFact('tt_post_like_count', 'number', likeCount, url)); + } + + const shareCount = this.getInteractionCount(container, selectors.shareCount) || + this.getInteractionCount(document, selectors.shareCount); + if (shareCount) { + posts.facts.push(NewFact('tt_post_share_count', 'number', shareCount, url)); + } + + const commentCount = this.getInteractionCount(container, selectors.commentCount) || + this.getInteractionCount(document, selectors.commentCount); + if (commentCount) { + posts.facts.push(NewFact('tt_post_comment_count', 'number', commentCount, url)); + } + + const bookmarkCount = this.getInteractionCount(container, selectors.bookmarkCount) || + this.getInteractionCount(document, selectors.bookmarkCount); + if (bookmarkCount) { + posts.facts.push(NewFact('tt_post_bookmark_count', 'number', bookmarkCount, url)); + } + + const date = container.querySelector(selectors.date)?.textContent?.trim(); + if (date) { + posts.facts.push(NewFact('tt_post_duration_string', 'string', date, url)); + } + + this.addClassSaved(container); + } + + private extractUsernameFromUrl(url: string): string { + const match = url.match(/@([^\/]+)/); + return match ? match[1] : 'unknown'; + } + + private async extractPhotoPost(posts: model.FactCollection): Promise { + const container = document.querySelector(`${ELEMENT_SELECTORS.postPhoto.container}:not(.saved)`); + + if (!container) { + console.warn("โŒ No container found for photo post extraction or already saved"); + return; + } + + const url = window.location.href; + const selectors = ELEMENT_SELECTORS.postPhoto; + + posts.facts.push(NewFact('tt_post_source_url', 'url', url, url)); + + const displayName = this.getDisplayName(document, selectors.displayName); + if (displayName) posts.facts.push(NewFact('tt_post_display_name', 'name', displayName, url)); + + const username = this.getUsername(document, selectors.username); + if (username) posts.facts.push(NewFact('tt_post_user_name', 'name', username, url)); + + const profileImageElement = document.querySelector(selectors.profileImage); + if (profileImageElement?.src) { + posts.facts.push(NewFact('tt_post_profileimage_url', 'url', profileImageElement.src, url)); + } + + const content = (container ? this.getContent(container, selectors.content) : null) || + this.getContent(document, selectors.content); + if (content) posts.facts.push(NewFact('tt_post_content_md', 'md', content, url)); + + const viewCount = document.querySelector(selectors.view)?.textContent?.trim(); + const viewCountText = viewCount ? parseCount(viewCount) : "0"; + if (viewCount) posts.facts.push(NewFact('tt_post_view_count', 'number', viewCountText, url)); + + const likeCount = this.getInteractionCount(document, selectors.like); + if (likeCount) posts.facts.push(NewFact('tt_post_like_count', 'number', likeCount, url)); + + const shareCount = this.getInteractionCount(document, selectors.share); + if (shareCount) posts.facts.push(NewFact('tt_post_share_count', 'number', shareCount, url)); + + const commentCount = this.getInteractionCount(document, selectors.comment); + if (commentCount) posts.facts.push(NewFact('tt_post_comment_count', 'number', commentCount, url)); + + const bookmarkCount = this.getInteractionCount(document, selectors.bookmarkCount); + if (bookmarkCount) posts.facts.push(NewFact('tt_post_bookmark_count', 'number', bookmarkCount, url)); + + const date = container.querySelector(selectors.date)?.textContent?.trim() || ""; + if (date) posts.facts.push(NewFact('tt_post_duration_string', 'string', date, url)); + + this.addClassSaved(container); + } + + private extractAllPosts(posts: model.FactCollection): void { + const postElements = document.querySelectorAll('[id^="column-item-video-container"]:not(.saved)'); + + for (const element of postElements) { + let postUrl = ''; + const fullUrlEl = element.querySelector(ELEMENT_SELECTORS.post.url); + if (fullUrlEl) { + const href = fullUrlEl.getAttribute('href') || ''; + postUrl = href.startsWith('http') ? href : `https://www.tiktok.com${href}`; + posts.facts.push(NewFact('tt_post_source_url', 'url', href, href)); + } + + + const profileUrl = element.querySelector('[data-e2e="search-card-user-link"]')?.getAttribute('href') || ''; + if (profileUrl) { + const profileLink = profileUrl.startsWith('http') ? profileUrl : `https://www.tiktok.com${profileUrl}`; + posts.facts.push(NewFact('tt_post_profile_url', 'url', profileLink, postUrl)); + }else if(!window.location.href.includes('/search/')){ + const profileLink = window.location.href; + posts.facts.push(NewFact('tt_post_profile_url', 'url', profileLink, postUrl)); + } + + const userName = element.querySelector('[data-e2e="search-card-user-link"]')?.getAttribute('href')?.replace('@', '').replace("/", '').trim(); + if (userName) { + posts.facts.push(NewFact('tt_post_user_name', 'name', userName, postUrl)); + }else if(!window.location.href.includes('/search/')){ + const userName = window.location.href.split('@').pop() || ''; + posts.facts.push(NewFact('tt_post_user_name', 'name', userName, postUrl)); + } + this.addClassSaved(element); + } + } + + saveComment(): model.FactCollection { + const comments: model.FactCollection = model.NewFactCollection('comment'); + const selectors = ELEMENT_SELECTORS.comment; + const commentElements = document.querySelectorAll(`${selectors.container}:not(.saved)`); + + commentElements.forEach((element) => { + if (element.classList.contains('saved')) { + console.warn("โŒ Comment element already saved, skipping:", element); + return; + } + + const elements = { + displayName: element.querySelector(selectors.displayName), + username: element.querySelector(selectors.username), + content: element.querySelector(selectors.content), + likeCount: element.querySelector(selectors.like), + commentCount: element.querySelector(selectors.commentCount), + date: element.querySelector(selectors.date), + }; + + if (elements.displayName && elements.content) { + this.extractCommentFacts(elements, comments, element); + element.classList.add('saved'); + } + }); + + return comments; + } + + private extractCommentFacts( + elements: { + displayName: HTMLElement | null; + username: HTMLElement | null; + content: HTMLElement | null; + likeCount: HTMLElement | null; + commentCount: HTMLElement | null; + date: HTMLElement | null; + }, + comments: model.FactCollection, + element: HTMLElement + ): void { + const username = elements.username?.getAttribute('href')?.replace('@', '').replace("/", '').trim(); + const contextUrl = window.location.href + `#comment_by=${username}`; + + const displayName = elements.displayName?.innerText.trim(); + if (displayName) { + comments.facts.push(NewFact('tt_comment_display_name', 'name', displayName, contextUrl)); + } + + if (username) { + comments.facts.push(NewFact('tt_comment_user_name', 'name', username.replace('@', '').replace("/", ''), contextUrl)); + } + + const content = elements.content?.innerText.trim(); + if (content) { + comments.facts.push(NewFact('tt_comment_content_md', 'md', content, contextUrl)); + } + + const likeCount = elements.likeCount ? parseCount(elements.likeCount.innerText.trim()) : "0"; + comments.facts.push(NewFact('tt_comment_like_count', 'number', likeCount, contextUrl)); + + if (elements.commentCount) { + const commentCountText = elements.commentCount.innerText.trim(); + const digitsOnly = commentCountText.split(" ")[1]; + const commentCount = parseCount(digitsOnly); + if (commentCount) { + comments.facts.push(NewFact('tt_comment_comment_count', 'number', commentCount, contextUrl)); + } + } + + const dateText = elements.date?.textContent?.trim(); + if (dateText) { + comments.facts.push(NewFact('tt_comment_duration_string', 'string', dateText, contextUrl)); + } + } + + private createPostFacts(itemStruct: any, posts: model.FactCollection, url: string): void { + posts.facts.push(NewFact('tt_post_source_url', 'url', url, url)); + + let displayName = ''; + let username = ''; + + const author = itemStruct.statsV2?.author || itemStruct.author || {}; + + if (author.nickname) { + displayName = author.nickname; + } else { + displayName = this.extractUsernameFromUrl(url); + } + + if (author.uniqueId) { + username = author.uniqueId; + } else { + username = this.extractUsernameFromUrl(url); + } + + if (displayName) { + posts.facts.push(NewFact('tt_post_display_name', 'name', displayName, url)); + } + if (username) { + posts.facts.push(NewFact('tt_post_user_name', 'name', username, url)); + } + + if (itemStruct.author.avatarLarger) { + posts.facts.push(NewFact('tt_post_profileimage_url', 'url', itemStruct.author.avatarLarger, url)); + } + + if (itemStruct.desc) { + let contentFormat = ''; + const videoUrl = itemStruct.video?.bitrateInfo?.[0]?.PlayAddr?.UrlList?.[1] || + itemStruct.video?.playAddr || ''; + const content = itemStruct.desc.trim(); + + if (videoUrl && content) { + contentFormat = `[videoUrl](${videoUrl})\n\n${content}`; + } else if (videoUrl) { + contentFormat = `[videoUrl](${videoUrl})`; + } else if (content) { + contentFormat = turndownService.turndown(content); + } + + if (contentFormat) { + posts.facts.push(NewFact('tt_post_content_md', 'md', contentFormat, url)); + } + } + + const stats = itemStruct.statsV2 || itemStruct.stats || {}; + + if (stats.diggCount !== undefined) { + const likeCount = parseCount(stats.diggCount.toString()); + posts.facts.push(NewFact('tt_post_like_count', 'number', likeCount, url)); + } + + if (stats.commentCount !== undefined) { + const commentCount = parseCount(stats.commentCount.toString()); + posts.facts.push(NewFact('tt_post_comment_count', 'number', commentCount, url)); + } + + if (stats.shareCount !== undefined) { + const shareCount = parseCount(stats.shareCount.toString()); + posts.facts.push(NewFact('tt_post_share_count', 'number', shareCount, url)); + } + + if (stats.playCount !== undefined) { + const viewCount = parseCount(stats.playCount.toString()); + posts.facts.push(NewFact('tt_post_view_count', 'number', viewCount, url)); + } + + if (stats.collectCount !== undefined) { + const bookmarkCount = parseCount(stats.collectCount.toString()); + posts.facts.push(NewFact('tt_post_bookmark_count', 'number', bookmarkCount, url)); + } + + if (itemStruct.createTime) { + const date = new Date(itemStruct.createTime * 1000).toISOString(); + posts.facts.push(NewFact('tt_post_createdat_datetime', 'datetime', date, url)); + } + } + + private getDisplayName(element: HTMLElement | Document, selector: string): string | null { + const displayNameEl = element.querySelector(selector); + return displayNameEl ? displayNameEl.innerText.trim() : null; + } + + private getUsername(element: HTMLElement | Document, selector: string): string | null { + const usernameEl = element.querySelector(selector); + return usernameEl ? usernameEl.innerText.replace('@', '').replace("/", '').trim() : null; + } + + private getContent(element: HTMLElement | Document, contentSelector: string, mediaSelector?: string): string | null { + let content = ''; + + const contentEl = element.querySelector(contentSelector); + if (contentEl && contentEl.textContent) { + content = contentEl.textContent.trim(); + } + + const mediaItems: string[] = []; + + if (this.isVideoPage()) { + const videoSrc = this.getVideoSrc(element, ELEMENT_SELECTORS.postVideo.videoUrl); + if (videoSrc) { + if (videoSrc.startsWith('blob:')) { + mediaItems.push(`[video-blob](${videoSrc})`); + } else { + mediaItems.push(`[videoUrl](${videoSrc})`); + } + } + } + + if (this.isPhotoPage()) { + const photoUrls = this.getPhotoUrls(element, ELEMENT_SELECTORS.postPhoto.photoUrl); + photoUrls.forEach((photoUrl, index) => { + mediaItems.push(`[photo](${photoUrl})`); + }); + } + + let fullContent = ''; + + if (mediaItems.length > 0 && content) { + const mediaLine = mediaItems.join(' '); + fullContent = mediaLine + '\n\n' + content; + } else if (mediaItems.length > 0) { + fullContent = mediaItems.join(' '); + } else if (content) { + fullContent = turndownService.turndown(content); + } + + return fullContent || null; + } + + private getPhotoUrls(element: HTMLElement | Document, photoSelector: string): string[] { + const photoUrls: string[] = []; + const uniqueUrls = new Set(); + + const photoContainer = element.querySelector(photoSelector); + if (photoContainer) { + const images = photoContainer.querySelectorAll('img'); + images.forEach((img) => { + const src = img.src || img.getAttribute('data-src') || img.getAttribute('srcset')?.split(' ')[0] || ''; + if (src && !uniqueUrls.has(src)) { + uniqueUrls.add(src); + photoUrls.push(src); + } + }); + } + + return photoUrls; + } + + private getVideoSrc(element: HTMLElement | Document, videoSelector: string): string | null { + const videoEl = element.querySelector(videoSelector); + if (videoEl && videoEl.src) { + return videoEl.src; + } + + const anyVideoEl = element.querySelector('video[src]'); + if (anyVideoEl && anyVideoEl.src) { + return anyVideoEl.src; + } + + return null; + } + + private getInteractionCount(element: HTMLElement | Document, selector: string): string | null { + const interactionEl = element.querySelector(selector); + if (!interactionEl) return null; + + const textContent = interactionEl.textContent; + if (!textContent) return null; + + const count = parseCount(textContent.replace(/\D/g, '')); + return count || '0'; + } + + + + addClassTagged(element: HTMLElement): void { + element.classList.add('tagged'); + } + + addClassSaved(element: HTMLElement): void { + element.classList.add('saved'); + } +} \ No newline at end of file diff --git a/src/modules/x.ts b/src/modules/x.ts new file mode 100644 index 0000000..ca0e973 --- /dev/null +++ b/src/modules/x.ts @@ -0,0 +1,721 @@ +import * as model from '../model'; +import { NewFact } from '../model'; +import { turndownService } from '../utils/turndown'; + +const ELEMENT_SELECTORS = { + profile: { + ld: 'script[type="application/ld+json"]' + }, + post: { + container: '[data-testid="tweet"]', + showMore: 'button[data-testid="tweet-text-show-more-link"]', + url: '[data-testid="tweet"] a[href*="status"]:first-of-type', + profileImage: 'img[src*="profile_images"]', + content: "[data-testid='tweetText']", + imageUrl: "[data-testid='tweetPhoto'] img", + videoUrl: "[data-testid='tweetPhoto'] video", + like: "button[data-testid='like']", + share: "button[data-testid='retweet']", + comment: "button[data-testid='reply']", + bookmark: "button[data-testid='bookmark']", + views: "a[aria-label*='views']", + date: "article time", + displayName: "[data-testid='User-Name'] > div > div > a > div > div span", + username: "[data-testid='User-Name'] > div:nth-child(2) > div > div > a > div span", + }, + comment: { + container: '[data-testid="tweet"]', + url: '[data-testid="tweet"] a[href*="status"]:first-of-type', + content: "[data-testid='tweetText']", + profileImage: 'img[src*="profile_images"]', + imageUrl: "[data-testid='tweetPhoto'] img", + videoUrl: "[data-testid='tweetPhoto'] video", + like: "button[data-testid='like']", + share: "button[data-testid='retweet']", + commentCount: "button[data-testid='reply']", + bookmark: "button[data-testid='bookmark']", + views: "a[aria-label*='views']", + date: "article time", + displayName: "[data-testid='User-Name'] > div > div > a > div > div span", + username: "[data-testid='User-Name'] > div:nth-child(2) > div > div > a > div span", + } +} as const; + +export default class XModule { + context: string = ""; + + tagElement(): void { + console.log(window.location.host); + + Object.keys(ELEMENT_SELECTORS).forEach((type) => { + const selectorObj = ELEMENT_SELECTORS[type as keyof typeof ELEMENT_SELECTORS]; + Object.entries(selectorObj).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(sel => { + document.querySelectorAll(sel).forEach(element => this.addClassTagged(element)); + }); + } else if (key === "showMore") { + const showMoreButton = document.querySelectorAll(value); + showMoreButton.forEach(button => { + this.addClassTagged(button); + button.click(); + }); + } else { + document.querySelectorAll(value).forEach(element => this.addClassTagged(element)); + } + }); + }); + } + + async saveData(): Promise { + const factCollections: model.FactCollection[] = []; + + try { + if (this.isStatusPage()) { + await this.handleStatusPage(factCollections); + } else { + await this.handleProfilePage(factCollections); + } + } catch (error) { + console.error("Error in saveData:", error); + } + + return factCollections; + } + + private isStatusPage(): boolean { + return window.location.href.includes('/status/'); + } + + private async handleProfilePage(factCollections: model.FactCollection[]): Promise { + const profile = this.saveProfile(); + if (profile.facts.length > 0) { + factCollections.push(profile); + this.markElementSaved(ELEMENT_SELECTORS.profile.ld); + } + + // Expand all show more buttons and wait for content to load + await this.expandAllPostContent(); + + // Then save the posts with expanded content + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + } + + private async expandAllPostContent(): Promise { + const showMoreButtons = document.querySelectorAll(ELEMENT_SELECTORS.post.showMore); + + if (showMoreButtons.length === 0) { + console.log("No show more buttons found"); + return; + } + + console.log(`Found ${showMoreButtons.length} show more buttons to expand`); + + // Click all buttons first + showMoreButtons.forEach(button => { + this.addClassTagged(button); + button.click(); + }); + + // Wait for all content to expand + await new Promise(resolve => setTimeout(resolve, 1500)); + + console.log("All post content expansion completed"); + } + + private async handleStatusPage(factCollections: model.FactCollection[]): Promise { + // Expand all show more buttons first + await this.expandAllPostContent(); + + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + this.markElementSaved(ELEMENT_SELECTORS.post.container); + } + + const comments = await this.saveComment(); + if (comments.facts.length > 0) { + factCollections.push(comments); + } + } + + private markElementSaved(selector: string): void { + const element = document.querySelector(selector) || document.body; + this.addClassSaved(element); + } + + saveProfile(): model.FactCollection { + const collection: model.FactCollection = model.NewFactCollection('profile'); + collection.url = window.location.href; + + + const ldScript = document.querySelector(`${ELEMENT_SELECTORS.profile.ld}:not(.saved)`); + + if (!ldScript || !ldScript.textContent) { + return collection; + } + try { + const jsonData = JSON.parse(ldScript.textContent); + if (jsonData["@type"] === "ProfilePage" && jsonData.mainEntity && !ldScript.classList.contains('saved')) { + this.extractProfileFacts(jsonData.mainEntity, collection); + this.addClassSaved(ldScript); + } + } catch (error) { + console.error("Error parsing LD+JSON:", error); + } + + return collection; + } + + private extractProfileFacts(mainEntity: any, collection: model.FactCollection): void { + const contextUrl = collection.url; + + collection.facts.push(NewFact('x_profile_source_url', 'url', contextUrl, contextUrl)); + + const username = mainEntity.additionalName || this.extractUsernameFromUrl(mainEntity.url) || ''; + if (username) { + collection.facts.push(NewFact('x_profile_user_name', 'name', username, contextUrl)); + } + + const displayName = mainEntity.givenName || mainEntity.name || ''; + if (displayName) { + collection.facts.push(NewFact('x_profile_display_name', 'name', displayName, contextUrl)); + } + + const description = mainEntity.description || ''; + if (description) { + collection.facts.push(NewFact('x_profile_bio_md', 'md', description, contextUrl)); + } + + this.extractProfileImage(mainEntity.image, collection, contextUrl); + + if (mainEntity.interactionStatistic && Array.isArray(mainEntity.interactionStatistic)) { + this.extractInteractionStatistics(mainEntity.interactionStatistic, collection, contextUrl); + } + } + + private extractProfileImage(image: any, collection: model.FactCollection, contextUrl: string): void { + if (!image) return; + + let imageUrl = ''; + if (typeof image === "string") { + imageUrl = image; + } else if (typeof image === "object" && image.contentUrl) { + imageUrl = image.contentUrl; + } + + if (imageUrl) { + collection.facts.push(NewFact('x_profile_profileimage_url', 'url', imageUrl, contextUrl)); + } + } + + private extractInteractionStatistics(statistics: any[], collection: model.FactCollection, contextUrl: string): void { + statistics.forEach((stat: any) => { + const count = stat.userInteractionCount?.toString() || '0'; + + if (stat.interactionType === "https://schema.org/FollowAction" && stat.name === "Follows") { + collection.facts.push(NewFact('x_profile_follower_count', 'number', count, contextUrl)); + } else if (stat.interactionType === "https://schema.org/SubscribeAction" && stat.name === "Friends") { + collection.facts.push(NewFact('x_profile_following_count', 'number', count, contextUrl)); + } else if (stat.interactionType === "https://schema.org/WriteAction" && stat.name === "Tweets") { + collection.facts.push(NewFact('x_profile_post_count', 'number', count, contextUrl)); + } + }); + } + + async savePost(): Promise { + const posts: model.FactCollection = model.NewFactCollection('post'); + const selectors = ELEMENT_SELECTORS.post; + + + if (this.isStatusPage()) { + const conversationTimeline = document.querySelector('[aria-label="Timeline: Conversation"]:not(.saved)'); + console.log('Conversation Timeline:', conversationTimeline); + if (conversationTimeline) { + const postElements = conversationTimeline.querySelector(`${selectors.container}:not(.saved)`); + await this.extractFromPostElement(postElements, posts, selectors); + this.addClassSaved(conversationTimeline); + } else { + console.warn('No conversation timeline found on status page.'); + } + } else { + const postElements = document.querySelectorAll(`${selectors.container}:not(.saved)`); + postElements.forEach(element => this.addClassTagged(element)); + await this.extractFromPostElements(postElements, posts, selectors); + } + + return posts; + } + + private async extractFromPostElements( + postElements: NodeListOf | HTMLElement[], + posts: model.FactCollection, + selectors: typeof ELEMENT_SELECTORS.post + ): Promise { + await Promise.all(Array.from(postElements).map(async (element) => { + try { + const urlEl = element.querySelector(selectors.url); + if (!urlEl) return; + + const href = urlEl.getAttribute('href'); + if (!href) return; + + const fullUrl = href.startsWith('http') ? href : `https://x.com${href}`; + + const facts = this.createFactsFromExtractedData(element, selectors, fullUrl); + posts.facts.push(...facts); + this.addClassSaved(element); + } catch (error) { + console.error('Error processing post element:', error); + } + })); + } + + private async extractFromPostElement( + postElement: HTMLElement | null, + posts: model.FactCollection, + selectors: typeof ELEMENT_SELECTORS.post + ): Promise { + if (!postElement) return; + + try { + const urlEl = postElement.querySelector(selectors.url); + if (!urlEl) return; + + const href = urlEl.getAttribute('href'); + if (!href) return; + + const fullUrl = href.startsWith('http') ? href : `https://x.com${href}`; + + const facts = this.createFactsFromExtractedData(postElement, selectors, fullUrl); + posts.facts.push(...facts); + this.addClassSaved(postElement); + } catch (error) { + console.error('Error processing post element:', error); + } + } + + private createFactsFromExtractedData( + element: HTMLElement, + selectors: typeof ELEMENT_SELECTORS.post, + fullUrl: string + ): model.Fact[] { + const facts: model.Fact[] = []; + + const contextUrl = fullUrl; + facts.push(NewFact('x_post_source_url', 'url', fullUrl, contextUrl)); + + const postData = this.extractPostData(element, selectors); + + if (postData.profileUrl) facts.push(NewFact('x_post_profile_url', 'url', postData.profileUrl, contextUrl)); + + if (postData.displayName) facts.push(NewFact('x_post_display_name', 'name', postData.displayName, contextUrl)); + if (postData.username) facts.push(NewFact('x_post_user_name', 'name', postData.username, contextUrl)); + if (postData.profileImage) facts.push(NewFact('x_post_profileimage_url', 'url', postData.profileImage, contextUrl)); + if (postData.content) facts.push(NewFact('x_post_content_md', 'md', postData.content, contextUrl)); + if (postData.likeCount !== null) facts.push(NewFact('x_post_like_count', 'number', postData.likeCount, contextUrl)); + if (postData.shareCount !== null) facts.push(NewFact('x_post_share_count', 'number', postData.shareCount, contextUrl)); + if (postData.commentCount !== null) facts.push(NewFact('x_post_comment_count', 'number', postData.commentCount, contextUrl)); + if (postData.bookmarkCount !== null) facts.push(NewFact('x_post_bookmark_count', 'number', postData.bookmarkCount, contextUrl)); + if (postData.viewCount !== null) facts.push(NewFact('x_post_view_count', 'number', postData.viewCount, contextUrl)); + if (postData.date) facts.push(NewFact('x_post_createdat_datetime', 'datetime', postData.date, contextUrl)); + + return facts; + } + + private extractPostData(element: HTMLElement, selectors: typeof ELEMENT_SELECTORS.post): { + displayName: string | null; + profileUrl: string | null; + username: string | null; + profileImage: string | null; + content: string | null; + likeCount: string | null; + shareCount: string | null; + commentCount: string | null; + bookmarkCount: string | null; + viewCount: string | null; + date: string | null; + } { + return { + displayName: this.getDisplayName(element, selectors.displayName), + username: this.getUsername(element, selectors.username), + profileUrl: `https://x.com${element.querySelector('div[data-testid="User-Name"] a')?.getAttribute('href')}` || null, + profileImage: this.getProfileImage(element, selectors.profileImage), + content: this.getContent(element, { + content: selectors.content, + imageUrl: selectors.imageUrl, + videoUrl: selectors.videoUrl, + }), + likeCount: this.getInteractionCount(element, selectors.like), + shareCount: this.getInteractionCount(element, selectors.share), + commentCount: this.getInteractionCount(element, selectors.comment), + bookmarkCount: this.getBookmarkCount(element, selectors.bookmark), + viewCount: this.getViewCount(element, selectors.views), + date: this.getDate(element, selectors.date), + }; + } + + async saveComment(): Promise { + const comments: model.FactCollection = model.NewFactCollection('comment'); + + if (!this.isStatusPage()) { + return comments; + } + + const selectors = ELEMENT_SELECTORS.comment; + let commentElements: HTMLElement[] = []; + + const conversationTimeline = document.querySelector('[aria-label="Timeline: Conversation"]'); + + if (conversationTimeline) { + const allArticlesInConversation = conversationTimeline.querySelectorAll('[data-testid="tweet"]'); + const commentsInConversation = Array.from(allArticlesInConversation).slice(1).filter(element => { + return !element.classList.contains('saved'); + }); + commentElements.push(...commentsInConversation); + console.log(`Found ${allArticlesInConversation.length} total articles in conversation, ${commentsInConversation.length} comments in conversation`); + } + + const allTweetElements = document.querySelectorAll(`${selectors.container}:not(.saved)`); + const outsideCommentElements = Array.from(allTweetElements).filter(element => { + const conversationContainer = element.closest('[aria-label="Timeline: Conversation"]'); + return conversationContainer === null; + }); + + commentElements.push(...outsideCommentElements); + commentElements.forEach(element => this.addClassTagged(element)); + + await Promise.all(commentElements.map(async (element) => { + try { + const urlEl = element.querySelector(selectors.url); + if (!urlEl) return; + + const href = urlEl.getAttribute('href'); + if (!href) return; + + const fullUrl = href.startsWith('http') ? href : `https://x.com${href}`; + const pageContext = window.location.href; // context halaman status/thread + + const facts = this.createCommentFactsFromExtractedData(element, selectors, fullUrl, pageContext); + if (facts?.length > 0) { + comments.facts.push(...facts); + } + + this.addClassSaved(element); + } catch (error) { + console.error('Error processing comment element:', error); + } + })); + + return comments; + } + + private createCommentFactsFromExtractedData( + element: HTMLElement, + selectors: typeof ELEMENT_SELECTORS.comment, + fullUrl: string, + pageContext: string + ): model.Fact[] { + const facts: model.Fact[] = []; + + const commentData = this.extractCommentData(element, selectors); + if (!commentData.date) return facts; + + // comment URL dengan context = halaman saat ini + facts.push(NewFact('x_comment_source_url', 'url', fullUrl, fullUrl)); + // fakta lain memakai context = URL komentar itu sendiri + const contextUrl = fullUrl; + if (commentData.profileUrl) facts.push(NewFact('x_comment_profile_url', 'url', commentData.profileUrl, contextUrl)); + if (commentData.date) facts.push(NewFact('x_comment_createdat_datetime', 'datetime', commentData.date, contextUrl)); + if (commentData.displayName) facts.push(NewFact('x_comment_display_name', 'name', commentData.displayName, contextUrl)); + if (commentData.username) facts.push(NewFact('x_comment_user_name', 'name', commentData.username, contextUrl)); + if (commentData.profileImage) facts.push(NewFact('x_comment_profileimage_url', 'url', commentData.profileImage, contextUrl)); + if (commentData.content) facts.push(NewFact('x_comment_content_md', 'md', commentData.content, contextUrl)); + if (commentData.likeCount !== null) facts.push(NewFact('x_comment_like_count', 'number', commentData.likeCount, contextUrl)); + if (commentData.shareCount !== null) facts.push(NewFact('x_comment_share_count', 'number', commentData.shareCount, contextUrl)); + if (commentData.commentCount !== null) facts.push(NewFact('x_comment_comment_count', 'number', commentData.commentCount, contextUrl)); + if (commentData.bookmarkCount !== null) facts.push(NewFact('x_comment_bookmark_count', 'number', commentData.bookmarkCount, contextUrl)); + if (commentData.viewCount !== null) facts.push(NewFact('x_comment_view_count', 'number', commentData.viewCount, contextUrl)); + + return facts; + } + + private extractCommentData(element: HTMLElement, selectors: typeof ELEMENT_SELECTORS.comment): { + displayName: string | null; + username: string | null; + profileImage: string | null; + profileUrl: string | null; + content: string | null; + likeCount: string | null; + shareCount: string | null; + commentCount: string | null; + bookmarkCount: string | null; + viewCount: string | null; + date: string | null; + } { + return { + displayName: this.getDisplayName(element, selectors.displayName), + username: this.getUsername(element, selectors.username), + profileUrl: `https://x.com${element.querySelector('div[data-testid="User-Name"] a')?.getAttribute('href')}` || null, + profileImage: this.getProfileImage(element, selectors.profileImage), + content: this.getContent(element, { + content: selectors.content, + imageUrl: selectors.imageUrl, + videoUrl: selectors.videoUrl, + }), + likeCount: this.getInteractionCount(element, selectors.like), + shareCount: this.getInteractionCount(element, selectors.share), + commentCount: this.getInteractionCount(element, selectors.commentCount), + viewCount: this.getViewCount(element, selectors.views), + bookmarkCount: this.getBookmarkCount(element, selectors.bookmark), + date: this.getDate(element, selectors.date), + }; + } + + private getDisplayName(element: HTMLElement, selector: string): string | null { + const displayNameEl = element.querySelector(selector); + return displayNameEl ? displayNameEl.innerText.trim() : null; + } + + private getUsername(element: HTMLElement, selector: string): string | null { + const usernameEl = element.querySelector(selector); + return usernameEl ? usernameEl.innerText.replace('@', '').trim() : null; + } + + private getBookmarkCount(element: HTMLElement, selector: string): string | null { + const bookmarkEl = element.querySelector("[aria-label*='bookmark']"); + if (!bookmarkEl) return null; + + const ariaLabel = bookmarkEl.getAttribute('aria-label'); + if (!ariaLabel) return null; + + const parts = ariaLabel.split(', '); + const bookmarkPart = parts.find(part => part.includes('bookmark')); + + if (bookmarkPart) { + const bookmarkValue = bookmarkPart.trim().split(' ')[0]; + const count = parseInt(bookmarkValue || '0'); + return isNaN(count) ? '0' : count.toString(); + } + + return '0'; + } + + private buildFinalContent( + media: { type: 'photo' | 'video'; url: string }[], + text: string | null + ): string | null { + const lines: string[] = []; + + media.forEach(({ type, url }) => { + lines.push(`[${type}Url](${url})`); + }); + + const trimmedText = text?.trim(); + if (trimmedText) { + if (lines.length) lines.push(''); + lines.push(trimmedText); + } + + return lines.join('\n').trim() || null; + } + + private getContent( + element: HTMLElement, + selectors: Pick + ): string | null { + const lines: string[] = []; + + const photoEls = element.querySelectorAll(selectors.imageUrl); + photoEls.forEach(img => lines.push(`[photoUrl](${img.src})`)); + + const videoEls = element.querySelectorAll(selectors.videoUrl); + videoEls.forEach(v => { + if (v.poster) lines.push(`[videoUrl](${v.poster})`); + const src = v.querySelector('source')?.src || v.src; + if (src && !src.startsWith('blob:')) lines.push(`[videoUrl](${src})`); + }); + + const textEl = element.querySelector(selectors.content); + const text = textEl ? turndownService.turndown(textEl.innerHTML).trim() : null; + + if (text) { + if (lines.length) lines.push(''); + lines.push(text); + } + + return lines.join('\n').trim() || null; + } + + private hasVideoIndicator(element: HTMLElement): boolean { + const videoIndicators = [ + '[data-testid="videoPlayer"]', + '[data-testid="videoComponent"]', + '[data-testid="videoControls"]' + ]; + + for (const selector of videoIndicators) { + if (element.querySelector(selector)) { + console.log(`Found video indicator: ${selector}`); + return true; + } + } + + const tweetPhoto = element.querySelector('[data-testid="tweetPhoto"]'); + if (tweetPhoto) { + const playElements = [ + 'svg[aria-label*="Play" i]', + 'button[aria-label*="Play" i]', + 'button[aria-label*="Pause" i]', + 'button[aria-label*="Watch again" i]', + '[aria-label*="Play video" i]' + ]; + + for (const selector of playElements) { + const playElement = tweetPhoto.querySelector(selector); + if (playElement) { + console.log(`Found video play element: ${selector}`, playElement); + return true; + } + } + + const videoElement = tweetPhoto.querySelector('video'); + if (videoElement) { + console.log('Found video element:', videoElement); + return true; + } + } + + console.log('No video indicators found - treating as image content'); + return false; + } + + getProfileImage(element: HTMLElement, selector: string): string { + const imgEl = element.querySelector(selector); + if (!imgEl) return ''; + const imgSrc = imgEl.src.trim(); + return imgSrc || ''; + } + + private isVideoThumbnail(img: HTMLImageElement): boolean { + const parent = img.closest('[data-testid="tweetPhoto"]'); + if (!parent) return false; + + const hasVideoIndicators = parent.querySelector('[aria-label*="Play"]') || + parent.querySelector('svg') || + parent.querySelector('[role="button"]'); + + const videoThumbnailPatterns = [ + 'amplify_video_thumb', + 'video_thumb', + 'ext_tw_video_thumb', + 'thumbnail', + 'poster', + 'preview' + ]; + + const imgSrc = img.src.toLowerCase(); + const hasVideoPattern = videoThumbnailPatterns.some(pattern => imgSrc.includes(pattern)); + + return !!(hasVideoIndicators || hasVideoPattern); + } + + private hasStrongVideoIndicators(img: HTMLImageElement): boolean { + const parent = img.closest('[data-testid="tweetPhoto"]'); + if (!parent) return false; + + const hasExplicitPlayButton = parent.querySelector('svg[aria-label*="Play video" i]') || + parent.querySelector('button[aria-label*="Play video" i]') || + parent.querySelector('[data-testid="videoPlayer"]') || + parent.querySelector('[data-testid="videoComponent"]'); + + const hasVideoElement = parent.querySelector('video'); + const result = !!(hasExplicitPlayButton || hasVideoElement); + + if (result) { + console.log('Image has STRONG video indicators:', { + hasExplicitPlayButton: !!hasExplicitPlayButton, + hasVideoElement: !!hasVideoElement, + imgSrc: img.src + }); + } + + return result; + } + + private getInteractionCount(element: HTMLElement, selector: string): string | null { + const interactionEl = element.querySelector(selector); + if (!interactionEl) return null; + + const ariaLabel = interactionEl.getAttribute('aria-label'); + if (!ariaLabel) return null; + + const countStr = ariaLabel.split(' ')[0]; + const count = parseInt(countStr || '0'); + return isNaN(count) ? '0' : count.toString(); + } + + private getViewCount(element: HTMLElement, selector: string): string | null { + let viewEl = element.querySelector(selector); + + if (viewEl && viewEl.getAttribute('aria-label')) { + const ariaLabel = viewEl.getAttribute('aria-label'); + const viewValue = ariaLabel?.split(' ')[0] || '0'; + return viewValue; + } + + viewEl = element.querySelector("[aria-label*='views']"); + if (viewEl) { + const ariaLabel = viewEl.getAttribute('aria-label'); + if (ariaLabel) { + const parts = ariaLabel.split(', '); + const viewsPart = parts.find(part => part.includes('views')); + if (viewsPart) { + const viewValue = viewsPart.trim().split(' ')[0]; + return viewValue; + } + } + } + + viewEl = element.querySelector("span[data-testid='app-text-transition-container']"); + if (viewEl && viewEl.textContent) { + return viewEl.textContent.trim(); + } + + return null; + } + + private getDate(element: HTMLElement, selector: string): string | null { + const dateEl = element.querySelector(selector); + return dateEl ? dateEl.getAttribute('datetime') || null : null; + } + + + + extractUsernameFromUrl(url: string): string | null { + const match = url?.match(/x\.com\/([^\/]+)$/); + return match ? match[1] : null; + } + + addClassTagged(element: HTMLElement): void { + element.classList.add('x-tagged'); + } + + addClassSaved(element: HTMLElement): void { + element.classList.add('saved'); + } + + isTagged(element: HTMLElement): boolean { + return element.classList.contains('x-tagged'); + } + + isSaved(element: HTMLElement): boolean { + return element.classList.contains('saved'); + } +} + + diff --git a/src/modules/youtube.ts b/src/modules/youtube.ts new file mode 100644 index 0000000..016d7e7 --- /dev/null +++ b/src/modules/youtube.ts @@ -0,0 +1,1000 @@ +import * as model from "../model"; +import { NewFact } from "../model"; +import { turndownService } from "../utils/turndown"; +import { parseCount } from "../helper/parseCount"; +import { profile } from "console"; + +const ELEMENT_SELECTORS = { + profile: { + container: "yt-page-header-renderer", + displayName: "yt-dynamic-text-view-model", + username: "yt-content-metadata-view-model > div:first-of-type", + profileImage: "yt-avatar-shape img", + followerCount: "yt-content-metadata-view-model > div:nth-of-type(2) > span[role='text']:first-of-type", + postCount: "yt-content-metadata-view-model > div:nth-of-type(2) > span[role='text']:nth-child(3)", + showMore: "yt-description-preview-view-model", + description: 'tp-yt-paper-dialog:not([aria-hidden="true"]) #about-container', + stats: 'tp-yt-paper-dialog:not([aria-hidden="true"]) #additional-info-container table.style-scope.ytd-about-channel-renderer', + + }, + post: { + containerSearchPage: "#content.ytd-app", + container: "ytd-rich-item-renderer", + sectionSearchPage: "[lockup='true']", + url: "a#thumbnail, a[class^='shortsLockupViewModelHostEndpoint']", + title: "#video-title-link, #video-title", + jsonld: "script[type='application/ld+json']", + commentCount: "h2#count > yt-formatted-string > span:first-of-type", + content_md: "#expanded > yt-attributed-string > span", + seeMoreBtn: "tp-yt-paper-button[id='expand']", + }, + shorts: { + container: "ytd-reel-video-renderer#reel-video-renderer", + url: ".ytp-title-link", + username: ".ytReelChannelBarViewModelChannelName a", + profileImage: ".yt-spec-avatar-shape__image", + content: "yt-shorts-video-title-view-model .ytShortsVideoTitleViewModelShortsVideoTitle span", + likeCount: "#like-button .yt-spec-button-shape-with-label__label span", + commentCount: "#comments-button .yt-spec-button-shape-with-label__label span", + viewCount: "#factoids > view-count-factoid-renderer > factoid-renderer > div", + date: "#factoids > upload-time-factoid-renderer > factoid-renderer > div", + btnComment: "#comments-button > ytd-button-renderer > yt-button-shape > label > button" + }, + comment: { + commentContainerIsOpen: '#anchored-panel > ytd-engagement-panel-section-list-renderer:nth-child(1)[visibility="ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"]', + container: "ytd-comment-thread-renderer", + profileurl:"#author-text", + username: "#author-text > span", + profileImage: "[class*='author-thumbnail'] img", + content: "#content > #content-text", + like_count: "#vote-count-middle", + commentCount: "ytd-comment-replies-renderer span[role='text']", + date: "#published-time-text > a", + }, + shortsComment: { + + firstSection: "#contents > ytd-comment-thread-renderer:nth-child(1)", + profileurl: "#author-text", + username: "#author-text", + profileImage: "#author-thumbnail img", + content: "#content-text", + likeCount: "#vote-count-middle", + commentCount: "#more-replies .yt-core-attributed-string", + time: "#published-time-text a" + } +} as const; + +// untuk format date "6 day ago", "1 week ago" nama properti "duration_string" + +export default class YTModule { + context: string = ""; + private currentUrl: string = ""; + private currentProfileUrl: string = ""; + + constructor() { + this.currentUrl = window.location.href; + this.currentProfileUrl = this.extractProfileUrl(window.location.href); + + // Monitor URL changes for SPA navigation + this.startUrlMonitoring(); + } + + private startUrlMonitoring(): void { + // Monitor URL changes every 1 second + setInterval(() => { + this.checkUrlChange(); + }, 1000); + + // Also monitor on popstate events + window.addEventListener('popstate', () => { + setTimeout(() => this.checkUrlChange(), 100); + }); + } + + private extractProfileUrl(url: string): string { + const match = url.match(/@[^\/]+/); + return match ? match[0] : ""; + } + + private extractProfileSection(url: string): string { + // Extract section like /videos, /streams, /playlists, etc. + const match = url.match(/@[^\/]+\/([^\/\?]+)/); + return match ? match[1] : ""; + } + + private checkUrlChange(): void { + const newUrl = window.location.href; + const newProfileUrl = this.extractProfileUrl(newUrl); + const currentSection = this.extractProfileSection(this.currentUrl); + const newSection = this.extractProfileSection(newUrl); + + if (newUrl !== this.currentUrl) { + console.log("URL changed from", this.currentUrl, "to", newUrl); + + // If profile changed, clear all saved states + if (newProfileUrl !== this.currentProfileUrl) { + console.log("Profile changed, clearing all saved states"); + this.clearAllSavedStates(); + this.currentProfileUrl = newProfileUrl; + } + // If same profile but different section, clear post container saved states + else if (currentSection !== newSection) { + console.log("Section changed, clearing post saved states"); + this.clearPostSavedStates(); + } + + this.currentUrl = newUrl; + } + } + + private clearAllSavedStates(): void { + // Clear all saved classes from all elements + document.querySelectorAll('.saved').forEach(el => { + el.classList.remove('saved'); + }); + } + + private clearPostSavedStates(): void { + // Clear saved classes only from post containers + document.querySelectorAll(`${ELEMENT_SELECTORS.post.container}.saved`).forEach(el => { + el.classList.remove('saved'); + }); + } + + tagElement(): void { + Object.keys(ELEMENT_SELECTORS).forEach((type) => { + const selectorObj = ELEMENT_SELECTORS[type as keyof typeof ELEMENT_SELECTORS]; + Object.entries(selectorObj).forEach(([key, value]) => { + const elements = document.querySelectorAll(value); + elements.forEach((element) => this.addClassTagged(element)); + + if (key === "showMore") { + const showMore = document.querySelector(value); + showMore?.click(); + } + }); + }); + } + + async saveData(): Promise { + const factCollections: model.FactCollection[] = []; + + try { + // Check for URL changes and clear saved states if needed + this.checkUrlChange(); + + if (this.isProfilePage()) { + await this.handleProfilePage(factCollections); + } else if (this.isVideoPage()) { + await this.handleWatchPage(factCollections); + } else if (this.isSearchPage()) { + console.log("Search page detected, extracting all posts"); + this.handleSearchPage(factCollections); + } else if (this.isShortPage()) { + await this.handleShortsPage(factCollections); + } + } catch (error) { + console.error("Error in saveData:", error); + } + + return factCollections; + } + + private isSearchPage(): boolean { + return window.location.href.includes("/results?search_query="); + } + + private isShortPage(): boolean { + return window.location.href.includes("/shorts/"); + } + + private isProfilePage(): boolean { + return window.location.href.includes("@"); + } + + private isVideoPage(): boolean { + return window.location.href.includes("watch"); + } + + private async handleShortsPage(factCollection: model.FactCollection[]): Promise { + const shorts = await this.saveShorts(); + if (shorts.facts.length > 2) { + factCollection.push(shorts); + } + + const CommentIsExpanded = document.querySelector(ELEMENT_SELECTORS.comment.commentContainerIsOpen); + if (!CommentIsExpanded) { + const btnComment = document.querySelector(ELEMENT_SELECTORS.shorts.btnComment); + if (btnComment) { + btnComment.click(); + await new Promise((r) => setTimeout(r, 2000)); + } + } + + const comments = this.saveCommentShorts(); + if (comments.facts.length > 0) { + factCollection.push(comments); + } + + } + + private handleSearchPage(factCollections: model.FactCollection[]): void { + const containerEL = document.querySelector(ELEMENT_SELECTORS.post.containerSearchPage); + if (!containerEL) { + return; + } + const sectionEl = containerEL.querySelectorAll(`${ELEMENT_SELECTORS.post.sectionSearchPage}:not(.saved)`) + // const postEl = containerEL.querySelectorAll(`${ELEMENT_SELECTORS.post.url}:not(.saved)`); + + const posts = model.NewFactCollection("post"); + sectionEl.forEach((el) => { + if (el.classList.contains("saved")) { + return; + } + const url = el.querySelector(ELEMENT_SELECTORS.post.url)?.getAttribute("href"); + const title = el.querySelector(ELEMENT_SELECTORS.post.title)?.textContent; + if (!url?.includes("https://www.googleadservices.com/pagead/aclk") && url && title) { + let cleanedUrl = url.split("&pp")[0]; // Remove any additional parameters after '&' + cleanedUrl = cleanedUrl.split("&t=")[0].startsWith("https://www.youtube.com") ? cleanedUrl : `https://www.youtube.com${cleanedUrl}`; // Remove any additional parameters after '&' + posts.facts.push(NewFact("yt_post_source_url", "url", cleanedUrl, cleanedUrl)); + posts.facts.push(NewFact("yt_post_title_string", "string", title.trim(), cleanedUrl)) + } + this.addClassSaved(el); + }) + + if (posts.facts.length > 0) { + factCollections.push(posts); + } + } + + private async saveShorts(): Promise { + const posts: model.FactCollection = model.NewFactCollection("post"); + const selectors = ELEMENT_SELECTORS.shorts; + const postElements = document.querySelector( + `${selectors.container}` + ); + + const parentEl = postElements?.parentElement; + if (parentEl && parentEl.classList.contains("saved")) { + return posts; + } + + if (!postElements) { + return posts; + } + + const urlEl = postElements?.querySelector(selectors.url); + if (urlEl) posts.facts.push(NewFact("yt_post_source_url", "url", urlEl.getAttribute("href") || "", window.location.href)); + + + + const usernameEl = postElements?.querySelector(selectors.username); + if (usernameEl) { + + const profileUrl = usernameEl.textContent.startsWith("https") ? usernameEl.textContent : `https://www.youtube.com${usernameEl.getAttribute("href")}`.replace("/shorts", ""); + if (profileUrl) posts.facts.push(NewFact("yt_post_profile__url", "url", profileUrl, window.location.href)); + posts.facts.push(NewFact("yt_post_user_name", "name", usernameEl.innerText.trim(), window.location.href)); + } + + const profileImageEl = postElements?.querySelector(selectors.profileImage); + if (profileImageEl) posts.facts.push(NewFact("yt_profile_image_url", "url", profileImageEl.getAttribute("src") || "", window.location.href)); + + const contentEl = postElements?.querySelector(selectors.content); + if (contentEl) { + const markdownContent = turndownService.turndown(contentEl.innerHTML); + posts.facts.push(NewFact("yt_post_content_md", "md", markdownContent, window.location.href)); + } + + const likeCountEl = postElements?.querySelector(selectors.likeCount); + if (likeCountEl) { + const likeCount = parseCount(likeCountEl.innerText.trim()); + posts.facts.push(NewFact("yt_post_like_count", "integer", likeCount, window.location.href)); + } + + const commentCountEl = postElements?.querySelector(selectors.commentCount); + if (commentCountEl) { + const commentCount = parseCount(commentCountEl.innerText.trim()); + posts.facts.push(NewFact("yt_post_comment_count", "integer", commentCount, window.location.href)); + } + + const viewCountEl = document.querySelector(selectors.viewCount); + if (viewCountEl) { + const viewCount = parseCount(viewCountEl.getAttribute("aria-label") || viewCountEl.innerText.trim()); + posts.facts.push(NewFact("yt_post_view_count", "integer", viewCount, window.location.href)); + } + + const dateEl = document.querySelector(selectors.date); + if (dateEl) { + const dateText = dateEl.getAttribute("aria-label") || dateEl.innerText.trim(); + posts.facts.push(NewFact("yt_post_createdat_datetime", "datetime", dateText, window.location.href)); + } + + if (postElements) this.addClassSaved(parentEl||postElements); + return posts; + } + private saveCommentShorts(): model.FactCollection { + const comments: model.FactCollection = model.NewFactCollection("comment"); + const selectors = ELEMENT_SELECTORS.shortsComment; + const firstSection = document.querySelector(selectors.firstSection); + + if (!firstSection) { + return comments; + } + + const container = document.querySelector("#contents"); + const section = container?.querySelectorAll("ytd-comment-thread-renderer:not(.saved)"); + section?.forEach((element) => this.addClassTagged(element)); + + section?.forEach((element) => { + const elements = { + profileurl: element.querySelector(selectors.profileurl), + username: element.querySelector(selectors.username), + content: element.querySelector(selectors.content), + like_count: element.querySelector(selectors.likeCount), + commentCount: element.querySelector(selectors.commentCount), + date: element.querySelector(selectors.time), + profileImage: element.querySelector(selectors.profileImage), + }; + + if (elements.username && elements.content) { + this.extractCommentFacts(elements, comments, element); + } + + this.addClassSaved(element); + }); + + return comments; + } + private async handleProfilePage( + factCollections: model.FactCollection[] + ): Promise { + + // Click show more button and wait for content to load before saving profile + const profileContainer = document.querySelector(ELEMENT_SELECTORS.profile.container); + if (profileContainer?.classList.contains('saved')) { + console.log("Profile already saved, skipping profile extraction"); + } else { + await this.expandProfileContent(); + + console.log("Extracting profile data..."); + const profile = this.saveProfile(); + if (profile.facts.length > 0) { + factCollections.push(profile); + const profileContainerEl = document.querySelector(ELEMENT_SELECTORS.profile.container); + if (profileContainerEl) { + this.addClassSaved(profileContainerEl); + } + console.log(`Profile extracted with ${profile.facts.length} facts`); + } else { + console.log("No profile data extracted"); + } + } + + console.log("Extracting posts from profile page..."); + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + const postContainerEl = document.querySelector(ELEMENT_SELECTORS.post.container); + if (postContainerEl) { + this.addClassSaved(postContainerEl); + } + console.log(`Posts extracted with ${posts.facts.length} facts`); + } else { + console.log("No posts found on profile page"); + } + } + + private async expandProfileContent(): Promise { + const btnShowMore = document.querySelector(ELEMENT_SELECTORS.profile.showMore); + if (btnShowMore && btnShowMore.offsetParent !== null && !(btnShowMore as HTMLButtonElement).disabled) { + try { + btnShowMore.click(); + console.log("Clicked profile show more button"); + // Wait for content to load after clicking + await new Promise(resolve => setTimeout(resolve, 500)); + console.log("Profile content expanded, ready to save"); + } catch (error) { + console.error("Error clicking show more button:", error); + } + } + } + + private async handleWatchPage( + factCollections: model.FactCollection[] + ): Promise { + const posts = await this.savePost(); + if (posts.facts.length > 0) { + factCollections.push(posts); + const postContainerEl = document.querySelector(ELEMENT_SELECTORS.post.container); + if (postContainerEl) { + this.addClassSaved(postContainerEl); + } + } + + const comments = this.saveComment(); + if (comments.facts.length > 0) { + factCollections.push(comments); + const commentContainerEl = document.querySelector(ELEMENT_SELECTORS.comment.container); + if (commentContainerEl) { + this.addClassSaved(commentContainerEl); + } + } + } + + // private addClassSaved(selector: string): void { + // const element = document.querySelector(selector) || document.body; + // this.addClassSaved(element); + // } + + private saveProfile(): model.FactCollection { + const profileContainer = document.querySelector(ELEMENT_SELECTORS.profile.container); + + // Skip if already saved + if (profileContainer && profileContainer.classList.contains('saved')) { + return model.NewFactCollection("profile"); + } + + const profile: model.FactCollection = { + url: window.location.href, + facts: [], + savedAt: new Date().toISOString(), + type: "profile", + }; + + const cleanedUrl = window.location.href + .replace(/([&?])ab_channel=[^&]+/, "") + .replace(/\/(videos|posts|playlists|featured)\b\/?.*$/, ""); + + const urlFact = model.NewFact("yt_profile_source_url", "url", cleanedUrl, window.location.href); + if (urlFact) profile.facts.push(urlFact); + + if (urlFact?.value !== "") { + const elements = ELEMENT_SELECTORS.profile; + + // Extract stats from About page first + const stats = this.extractStatsProfile(); + + profile.facts.push( + ...[ + this.getDisplayName(elements.displayName), + this.getUsername(elements.username), + this.getProfileImage(elements.profileImage), + ].filter((fact): fact is model.Fact => fact !== null) + ); + + // Add the 3 key statistics from extracted stats + if (stats.jumlah_subscriber !== null) { + profile.facts.push(NewFact("yt_profile_follower_count", "integer", stats.jumlah_subscriber.toString(), cleanedUrl)); + } + if (stats.jumlah_video !== null) { + profile.facts.push(NewFact("yt_profile_post_count", "integer", stats.jumlah_video.toString(), cleanedUrl)); + } + if (stats.jumlah_ditonton !== null) { + profile.facts.push(NewFact("yt_profile_view_count", "integer", stats.jumlah_ditonton.toString(), cleanedUrl)); + } + + const description = [...document.querySelectorAll(elements.description)].pop()?.innerHTML; + if (description) { + const descFact = this.getDescription(elements.description); + if (descFact) profile.facts.push(descFact); + } + } + + // Mark profile container as saved + if (profileContainer) { + this.addClassSaved(profileContainer); + } + + return profile; + } + + private extractStatsProfile(): { [key: string]: string | number | null } { + const statsContainer = [...document.querySelectorAll(ELEMENT_SELECTORS.profile.stats)].pop(); + if (!statsContainer) { + return { + url_channel: null, + negara: null, + tanggal_bergabung: null, + jumlah_subscriber: null, + jumlah_video: null, + jumlah_ditonton: null, + phone: null, + }; + } + + const out = { + url_channel: null as string | null, + negara: null as string | null, + tanggal_bergabung: null as string | null, + jumlah_subscriber: null as number | null, + jumlah_video: null as number | null, + jumlah_ditonton: null as number | null, + phone: null as string | null, + }; + + const rows = Array.from(statsContainer.querySelectorAll("tr.description-item")); + + for (const tr of rows) { + // beberapa punya attribute hidden โ€” tetap diproses bila ada teks + const icon = tr.querySelector("yt-icon")?.getAttribute("icon") || ""; + const tds = tr.querySelectorAll("td"); + const secondTd = tds[1] || tr; // fallback + + // helper ambil teks (hapus NBSP) + const getText = (el: Element | null): string => + (el?.textContent || "").replace(/\u00a0/g, " ").trim(); + + switch (icon) { + case "language": { + const a = tr.querySelector("a[href]"); + out.url_channel = a?.getAttribute("href") || getText(secondTd) || null; + break; + } + case "privacy_public": { + out.negara = getText(secondTd) || null; + break; + } + case "info_outline": { + let t = getText(secondTd); + t = t.replace(/^Bergabung pada\s*/i, ""); + out.tanggal_bergabung = t || null; + break; + } + case "person_radar": { + const subscriberText = getText(secondTd); + out.jumlah_subscriber = subscriberText ? this.parseChannelCount(subscriberText) : null; + break; + } + case "my_videos": { + const videoText = getText(secondTd); + out.jumlah_video = videoText ? this.parseChannelCount(videoText) : null; + break; + } + case "trending_up": { + const viewText = getText(secondTd); + out.jumlah_ditonton = viewText ? this.parseChannelCount(viewText) : null; + break; + } + case "phone": { + // di HTML contoh, tidak ada nomor; kalau ada, ambil dari + const tel = tr.querySelector(".phone-status-info")?.textContent || ""; + out.phone = tel.trim() || null; + break; + } + // baris email/recaptcha dll bisa diabaikan + default: + break; + } + } + + return out; + } + + private parseChannelCount(text: string): number { + if (text == null) return 0; + + // Normalisasi dasar + let s = String(text) + .toLowerCase() + .replace(/\u00A0/g, " ") // non-breaking space -> space + .trim(); + + // Tentukan multiplier (rb/jt/b) โ€” gunakan yang paling spesifik dulu + let mult = 1; + const hasRibu = /\b(rb|ribu|k)\b/.test(s); + const hasJuta = /\b(jt|juta|m|mn)\b/.test(s); + const hasMiliar = /\b(b|miliar|milyar)\b/.test(s); + + if (hasMiliar) mult = 1_000_000_000; + else if (hasJuta) mult = 1_000_000; + else if (hasRibu) mult = 1_000; + + // Ambil angka utama (boleh ada koma/titik desimal). + // Kalau ada suffix (mult > 1), kita treat separator pertama sebagai desimal. + // Kalau tidak ada suffix, kita anggap semua non-digit sebagai pemisah ribuan dan kita hapus. + if (mult > 1) { + // Contoh yang didukung: "1,5 jt", "2.75m", "3,000 rb" (=> 3000), "4.2 k" + // Ambil token angka pertama yang punya optional decimal (',' atau '.') + const m = s.match(/(\d+(?:[.,]\d+)?)/); + if (!m) return 0; + + // Jika ada keduanya '.' dan ',', asumsikan format ID: '.' ribuan, ',' desimal + let numToken = m[1]; + if (numToken.includes(".") && numToken.includes(",")) { + numToken = numToken.replace(/\./g, "").replace(",", "."); + } else { + // Hanya salah satu separator: + // - Jika ada ',' โ†’ anggap sebagai desimal (umum ID) + // - Jika ada '.' โ†’ anggap sebagai desimal (umum EN) + // Catatan: kasus "12,345" dengan suffix jarang; kalau ada, kita perlakukan 12.345 (12.345 juta) -> wajar. + numToken = numToken.replace(",", "."); + } + + const base = parseFloat(numToken); + if (Number.isNaN(base)) return 0; + return Math.round(base * mult); + } else { + // Tidak ada suffix (angka utuh). Hapus semua non-digit. + // Menangani "12.345" / "12,345" / "1 200" -> 12345 / 12345 / 1200 + const digits = s.replace(/\D+/g, ""); + if (!digits) return 0; + // Hindari Number terlalu besar: pakai BigInt lalu clamp ke Number bila perlu + try { + const bi = BigInt(digits); + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + return bi > maxSafe ? Number.MAX_SAFE_INTEGER : Number(bi); + } catch { + // fallback aman + return parseInt(digits, 10) || 0; + } + } + } + + async savePost(): Promise { + const posts: model.FactCollection = model.NewFactCollection("post"); + const selectors = ELEMENT_SELECTORS.post; + + const postElements = document.querySelectorAll( + `${selectors.container}:not(.saved)` + ); + const jsonldElement = document.querySelector( + `${selectors.jsonld}:not(.saved)` + ); + const urlPost = JSON.parse(jsonldElement?.textContent || "{}")["@id"] || ""; + + // Handle video page (watch page) + if ( + jsonldElement && + window.location.href.includes(urlPost) && + !jsonldElement.classList.contains("saved") + ) { + const seeMoreBtn = document.querySelector(selectors.seeMoreBtn); + if (seeMoreBtn) { + seeMoreBtn.click(); + await new Promise((r) => setTimeout(r, 300)); + } + + const contentMd = document.querySelector(selectors.content_md); + if (contentMd) { + const markdownContent = turndownService.turndown(contentMd.innerHTML); + const contentFact = model.NewFact( + "yt_post_content_md", + "md", + markdownContent, + window.location.href + ); + posts.facts.push(contentFact); + } + + const profileUrlEl = document.querySelector("div#owner a"); + if (profileUrlEl) { + const profileUrl = profileUrlEl.getAttribute("href") || ""; + if (profileUrl) { + const profileFact = model.NewFact( + "yt_post_profile_url", + "url", + `https://www.youtube.com${profileUrl}`, + window.location.href.replace(/([&?])ab_channel=[^&]+/, "") + ); + posts.facts.push(profileFact); + } + } + + await this.extractFromJsonldElement(jsonldElement, posts, selectors); + this.addClassSaved(jsonldElement); + } else { + // Handle profile page posts and search results + console.log(`Found ${postElements.length} post elements to process`); + + postElements.forEach((el) => { + const postURL = el.querySelector(selectors.url)?.getAttribute("href"); + if (postURL?.includes("www.googleadservices.com") || !postURL) return; + + const fullUrl = postURL.startsWith("http") + ? postURL + : `https://www.youtube.com${postURL}`; + + const newFact = model.NewFact( + "yt_post_source_url", + "url", + fullUrl, + fullUrl + ); + + if (newFact) { + posts.facts.push(newFact); + console.log(`Added post URL: ${fullUrl}`); + } + const title = el.querySelector(selectors.title)?.innerText.trim(); + console.log(`Extracted title: ${title}`); + + if (title) { + const titleFact = model.NewFact( + "yt_post_title_string", + "string", + title, + fullUrl + ); + posts.facts.push(titleFact); + } + this.addClassSaved(el); + }); + } + + console.log(`Total posts extracted: ${posts.facts.length}`); + return posts; + } + + private async extractFromJsonldElement( + jsonldElements: HTMLElement, + posts: model.FactCollection, + selectors: typeof ELEMENT_SELECTORS.post + ): Promise { + const jsonldData = jsonldElements.textContent; + if (!jsonldData) return; + + try { + const jsonData = JSON.parse(jsonldData); + const data = this.extractFromJsonld(jsonData); + if (data.facts.length > 0) { + posts.facts.push(...data.facts); + const commentCountElement = document.querySelector(selectors.commentCount); + if (commentCountElement) { + const fact = this.getCommentCount(selectors.commentCount); + if (fact) { + posts.facts.push(fact); + this.addClassSaved(commentCountElement); + } + } + } + } catch (error) { + console.error("Error parsing JSON-LD data:", error); + } + } + + private extractFromJsonld(json: any): model.FactCollection { + const collection: model.FactCollection = model.NewFactCollection("post"); + if (!json || typeof json !== "object" || json["@type"] !== "VideoObject") { + return collection; + } + + collection.url = json["@id"] || ""; + collection.savedAt = new Date().toISOString(); + + collection.facts.push(NewFact("yt_post_source_url", "url", collection.url, collection.url)); + + const factMappings = [ + { key: "name", name: "yt_post_title_string", type: "string" as const }, + + { key: "uploadDate", name: "yt_post_createdat_datetime", type: "datetime" as const }, + ]; + + factMappings.forEach(({ key, name, type }) => { + const value = json[key]; + if (value) { + collection.facts.push(NewFact(name, type, value, collection.url)); + } + }); + + this.extractThumbnails(json, collection); + this.extractInteractionStatistics(json, collection); + return collection; + } + + private extractThumbnails(json: any, collection: model.FactCollection): void { + if (json["thumbnailUrl"]) { + const thumbnails = Array.isArray(json["thumbnailUrl"]) + ? json["thumbnailUrl"] + : [json["thumbnailUrl"]]; + thumbnails.forEach((thumb: string) => { + collection.facts.push(NewFact("yt_post_thumbnail_url", "url", thumb, collection.url)); + }); + } + } + + private extractInteractionStatistics( + json: any, + collection: model.FactCollection + ): void { + if (!json["interactionStatistic"] || !Array.isArray(json["interactionStatistic"])) { + return; + } + + json["interactionStatistic"].forEach((stat: any) => { + if (!stat.interactionType || stat.userInteractionCount === undefined) return; + + const type = stat.interactionType.split("/").pop() || ""; + const count = parseInt(stat.userInteractionCount, 10); + + // Save even if count is 0, but skip invalid numbers + if (isNaN(count) || count < 0) return; + + if (type === "WatchAction") { + collection.facts.push( + NewFact("yt_post_view_count", "integer", count.toString(), collection.url) + ); + } else if (type === "LikeAction") { + collection.facts.push( + NewFact("yt_post_like_count", "integer", count.toString(), collection.url) + ); + } + }); + } + + saveComment(): model.FactCollection { + const comments: model.FactCollection = model.NewFactCollection("comment"); + const selectors = ELEMENT_SELECTORS.comment; + const commentElements = document.querySelectorAll( + `${selectors.container}:not(.saved)` + ); + + commentElements.forEach((element) => this.addClassTagged(element)); + + commentElements.forEach((element) => { + const elements = { + username: element.querySelector(selectors.username), + profileurl : element.querySelector(selectors.profileurl), + content: element.querySelector(selectors.content), + like_count: element.querySelector(selectors.like_count), + commentCount: element.querySelector(selectors.commentCount), + date: element.querySelector(selectors.date), + }; + + if (elements.username && elements.content) { + this.extractCommentFacts(elements, comments, element); + } + + this.addClassSaved(element); + }); + + + + return comments; + } + + private extractCommentFacts( + elements: { + profileurl: HTMLElement | null; + username: HTMLElement | null; + content: HTMLElement | null; + like_count: HTMLElement | null; + commentCount: HTMLElement | null; + date: HTMLElement | null; + }, + comments: model.FactCollection, + element: HTMLElement + ): void { + const username = elements.username?.innerText.trim(); + const commentContext = `${window.location.href}#comment_by=${username}`; + if (elements.profileurl) { + const profileUrl = elements.profileurl.getAttribute("href") || ""; + if (profileUrl) { + const fullProfileUrl = profileUrl.startsWith("http") + ? profileUrl + : `https://www.youtube.com${profileUrl}`; + comments.facts.push( + NewFact("yt_comment_profile_url", "url", fullProfileUrl, commentContext) + ); + } + + } + if (username) { + comments.facts.push( + NewFact("yt_comment_user_name", "name", username, commentContext) + ); + } + + const content = elements.content?.innerHTML; + const contentMD = content ? turndownService.turndown(content) : ""; + if (content) { + comments.facts.push( + NewFact("yt_comment_content_md", "md", contentMD, commentContext) + ); + } + + // Always save like count, even if 0 or element not found + const likeCountText = elements.like_count?.innerText.trim() || ""; + const likeCount = likeCountText ? parseCount(likeCountText) : "0"; + comments.facts.push( + NewFact("yt_comment_like_count", "integer", likeCount, commentContext) + ); + + // Always save reply count, even if 0 or element not found + const commentCountText = elements.commentCount?.innerText.trim() || ""; + const replyCount = commentCountText ? parseCount(commentCountText.split(" ")[0]) : "0"; + comments.facts.push( + NewFact("yt_comment_comment_count", "integer", replyCount, commentContext) + ); + + const dateText = elements.date?.innerText.trim(); + if (dateText) { + comments.facts.push( + NewFact("yt_comment_duration_string", "string", dateText, commentContext) + ); + } + } + + /* โ”€โ”€ helpers ------------------------------------------------------------- */ + addClassTagged(element: HTMLElement): void { + element.classList.add("tagged"); + } + + addClassSaved(element: HTMLElement): void { + element.classList.add("saved"); + } + + private isValidNumericValue(value: string, originalText: string): boolean { + // Don't save if the value is "0" but the original text doesn't contain "0" + // This helps avoid saving "N/A" cases that got converted to "0" + if (value === "0" && !originalText.includes("0")) return false; + + // Don't save if it's not a valid number + const num = parseFloat(value); + return !isNaN(num) && num >= 0; + } + + + + private getDisplayName(selector: string): model.Fact { + const element = document.querySelector(selector) || document.body; + return model.NewFact("yt_profile_display_name", "name", element.innerText, window.location.href); + } + + private getUsername(selector: string): model.Fact { + const value = document.querySelector(selector)?.innerText || ""; + return model.NewFact("yt_profile_user_name", "name", value.replace("@", ""), window.location.href); + } + + private getProfileImage(selector: string): model.Fact { + const src = document.querySelector(selector)?.src || ""; + return model.NewFact("yt_profile_profileimage_url", "url", src, window.location.href); + } + + private getFollowerCount(selector: string): model.Fact | null { + const value = document.querySelector(selector)?.innerText || ""; + if (!value.trim()) return null; + + const cleaned = parseCount(value.replace(/subscribers?/g, "").trim()); + if (!this.isValidNumericValue(cleaned, value)) return null; + + return model.NewFact("yt_profile_follower_count", "integer", cleaned); + } + + private getPostCount(selector: string): model.Fact | null { + const value = document.querySelector(selector)?.innerText || ""; + if (!value.trim()) return null; + + const cleaned = parseCount(value.replace(/videos?/g, "").trim()); + if (!this.isValidNumericValue(cleaned, value)) return null; + + return model.NewFact("yt_profile_post_count", "integer", cleaned); + } + + private getDescription(selector: string): model.Fact { + const element = document.querySelector(selector); + if (!element) return model.NewFact("yt_profile_bio_text", "text", ""); + const markdown = turndownService.turndown(element.innerHTML); + return model.NewFact("yt_profile_bio_text", "text", markdown, window.location.href); + } + + private getCommentCount(selector: string): model.Fact | null { + const value = document.querySelector(selector)?.innerText || ""; + if (!value.trim()) return null; + + const cleaned = parseCount( + value.replace(/(comment|comments|balasan|\.)/gi, "").trim() + ); + + if (!this.isValidNumericValue(cleaned, value)) return null; + + let contextUrl = window.location.href; + contextUrl = contextUrl.replace(/&ab_channel=[^&]+/, ""); + const cleanedContext = contextUrl.startsWith("http") + ? contextUrl + : `https://www.youtube.com${contextUrl}`; + + return model.NewFact("yt_post_comment_count", "integer", cleaned, cleanedContext); + } + + +} \ No newline at end of file diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..f544c27 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,128 @@ +:root { + --primary-color: #333; + --background-color: #f8f9fa; + --text-color: #333; + --border-color: #e0e0e0; + --button-hover-color: #333; + --panel-width: 20rem; +} + +body { + width: var(--panel-width); + max-width: var(--panel-width); + min-width: var(--panel-width); + min-height: 100vh; + background: var(--background-color); + color: var(--text-color); + font-family: Arial, sans-serif; + margin: 0; + padding: 1rem; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + gap: 0.625rem; + box-sizing: border-box; + height: 100vh; + overflow-x: hidden; +} + +button { + background-color: var(--primary-color); + color: white; + border: none; + padding: 0.625rem 1.25rem; + border-radius: 0.313rem; + cursor: pointer; + transition: background-color 0.3s; + margin: 0.625rem 0; + width: 100%; + font-size: 1rem; + text-align: center; + box-sizing: border-box; +} + +button:hover { + background-color: var(--button-hover-color); +} + +.btn-menu { + width: 30px; + height: 30px; + justify-content: end; + display: flex; +} + +#output { + width: 100%; + max-width: 100%; + box-sizing: border-box; + overflow-x: hidden; + word-wrap: break-word; +} + +#output pre { + max-width: 100%; + overflow-x: auto; + overflow-y: auto; + max-height: 18.75rem; + font-size: 0.625rem; + line-height: 1.2; + white-space: pre-wrap; + word-break: break-all; + box-sizing: border-box; +} + +.menu-container { + position: absolute; + height: auto; + width: 8.125rem; + right: 0.625rem; + display: none; + top: 3.75rem; + background-color: white; + border: 0.063rem solid #ccc; + padding: 0.625rem; + justify-content: left; + border-radius: 0.625rem; +} + +.menu-container>ul { + justify-content: center; + display: flex; + flex-direction: column; + list-style: none; + padding: 0; + margin: 0; + gap: 0.313rem; +} + +.menu-container>ul>li { + gap: 1px; + cursor: pointer; + list-style: none; + font-family: Arial, sans-serif; + font-size: 0.813rem; + display: flex; + align-items: center; +} + +.menu-container>ul>li>a:hover { + background-color: #333333; + color: #fff; + +} + +.menu-container>ul>li>a { + text-decoration: none; + color: #333; + width: 100%; + display: block; + padding: 5px 5px; + border-radius: 5px; + font-weight: 400; +} + +.active { + display: block; +} \ No newline at end of file diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..d5aa8e1 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,51 @@ + + + + + + Popup + + + + + + + + + +
+ + +
+
+

+ Output +

+
+ + + + \ No newline at end of file diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..3ed8e8b --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,190 @@ +type PopupActionHandler = () => void; + +const popupActionHandlers: Record = { + tagElement: handleTagElement, + saveData: handleSaveData +} + +// Main function to execute a popup action +interface ExecuteActionResponse { + success: boolean; + data?: unknown; + key?: string; +} + +function executeAction(action: keyof typeof popupActionHandlers): void { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs: chrome.tabs.Tab[]) => { + chrome.tabs.sendMessage( + tabs[0].id as number, + { action }, + (response: ExecuteActionResponse) => { + if (chrome.runtime.lastError) { + console.error('Gagal mengirim pesan:', chrome.runtime.lastError.message); + return; + } + + if (response && response.success) { + console.log('Aksi berhasil:', response); + + // Tampilkan data otomatis setelah save + if (action === 'saveData' && response.data) { + displaySavedData(response.data, response.key); + } + } else { + console.error('Aksi gagal:', response); + } + } + ); + }); +} + +interface DisplaySavedDataParams { + data: unknown; + key?: string; +} + +function displaySavedData(data: DisplaySavedDataParams['data'], key: DisplaySavedDataParams['key']): void { + const outputElement: HTMLElement | null = document.getElementById('output'); + if (outputElement) { + let htmlContent = ` +
+

โœ… Data Berhasil Disimpan

+

Key: ${key || 'N/A'}

+ `; + + // Check if data is an array of fact collections + if (Array.isArray(data)) { + data.forEach((collection: any, index: number) => { + htmlContent += ` +
+
+ ${collection.type || 'Collection'} ${index + 1} - ${collection.url || 'N/A'} +
+
+

+ Saved: ${collection.savedAt ? new Date(collection.savedAt).toLocaleString() : 'N/A'} +

+
+ + + + + + + + + + + `; + + // Add facts to table + if (collection.facts && Array.isArray(collection.facts)) { + collection.facts.forEach((fact: any) => { + const value = fact.value ? String(fact.value) : 'N/A'; + const context = fact.context || 'N/A'; + + htmlContent += ` + + + + + + + `; + }); + } + + htmlContent += ` + +
NameTypeValueContext
${fact.name || 'N/A'}${fact.type || 'N/A'}${value}${context}
+
+
+
+ `; + }); + } else { + // Fallback for non-array data + htmlContent += ` +
${JSON.stringify(data, null, 2)}
+ `; + } + + htmlContent += `
`; + outputElement.innerHTML = htmlContent; + } + + +} + +function handleTagElement(): void { + // Aksi untuk menandai elemen + // alert('Element tagged!'); + executeAction('tagElement'); +} + +function handleSaveData(): void { + // Aksi untuk menyimpan data + executeAction('saveData'); + +} + +document.addEventListener('DOMContentLoaded', () => { + const popupAction = document.querySelectorAll('[data-action]'); + popupAction.forEach(button => { + button.addEventListener('click', () => { + const action = button.getAttribute('data-action'); + if (action && action in popupActionHandlers) { + popupActionHandlers[action](); + } else { + console.error(`Unknown popup action: ${action}`); + } + }) + }); + + const btnMenu = document.querySelector('.btn-menu'); + if (btnMenu) { + btnMenu.addEventListener('click', () => { + const menu = document.querySelector('.menu-container'); + if (menu) { + menu.classList.toggle('active'); + } + }); + } + + const toggleBtn = document.getElementById('toggle-sidebar-btn'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => { + // Ask the current tab to toggle the sidebar + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + chrome.tabs.sendMessage(tabs[0].id!, { action: 'toggleSidebar' }); + }); + }); + } + + const formApiEndpoint = document.getElementById('form-api-endpoint') as HTMLFormElement; + if (formApiEndpoint) { + formApiEndpoint.addEventListener('submit', (event) => { + event.preventDefault(); + const input = formApiEndpoint.querySelector('input[name="api-endpoint"]') as HTMLInputElement; + const apiURL = input.value.trim(); + if (apiURL.startsWith('http://') || apiURL.startsWith('https://')) { + // Save the API endpoint to storage + chrome.storage.local.set({ apiURL }, () => { + // console.log('API endpoint saved:', apiURL); + alert('API endpoint saved successfully!'); + }); + } else { + alert('API endpoint cannot be empty / must start with http:// or https://'); + } + }); + } + + chrome.storage.local.get('apiURL', (result) => { + const apiField = document.getElementById('field-api-endpoint') as HTMLInputElement; + if (apiField) { + apiField.value = result.apiURL || ''; + } + console.log('Current API endpoint:', result.apiURL); + }); + +}); diff --git a/src/public/manifest.json b/src/public/manifest.json new file mode 100644 index 0000000..524c7ec --- /dev/null +++ b/src/public/manifest.json @@ -0,0 +1,65 @@ +{ + "manifest_version": 3, + "name": "New Browser Input", + "version": "1.0", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, nisl nec ultricies lacinia, nunc nisl aliquet nunc, vitae aliquam nisl nunc vitae nisl. Sed euismod, nisl nec ultricies lacinia, nunc nisl aliquet nunc, vitae aliquam nisl nunc vitae nisl.", + + "permissions": [ + "storage", + "activeTab", + "scripting", + "webRequest", + "webRequestBlocking" + ], + + "host_permissions": [ + "https://x.com/*", + "https://www.tiktok.com/*", + "https://www.linkedin.com/*", + "https://www.youtube.com/*", + "https://www.facebook.com/*", + "https://www.instagram.com/*" + ], + + "web_accessible_resources": [ + { + "resources": ["popup/popup.html", "popup/popup.js", "popup/popup.css"], + "matches": [ + "https://x.com/*", + "https://www.tiktok.com/*", + "https://www.linkedin.com/*", + "https://www.youtube.com/*", + "https://www.facebook.com/*", + "https://www.instagram.com/*" + ] + } + ], + + "side_panel": { + "default_path": "popup/popup.html" + }, + + "content_scripts": [ + { + "matches": [ + "https://x.com/*", + "https://www.tiktok.com/*", + "https://www.linkedin.com/*", + "https://www.youtube.com/*", + "https://www.facebook.com/*", + "https://www.instagram.com/*" + ], + "js": ["contentScript.js"], + "css": ["mark.css"] + } + ], + + "background": { + "service_worker": "background.js" + }, + + "action": { + "default_title": "New Browser Input", + "default_popup": "popup/popup.html" + } +} diff --git a/src/utils/downloadVideo.ts b/src/utils/downloadVideo.ts new file mode 100644 index 0000000..8c9a5fe --- /dev/null +++ b/src/utils/downloadVideo.ts @@ -0,0 +1,219 @@ +// Versi TypeScript dengan dukungan File System dan tipe data + +import fs from 'fs'; +import https from 'https'; +import http from 'http'; +import { URL } from 'url'; +import path from 'path'; + +// Tipe untuk response hasil download menggunakan https +interface HttpsDownloadResponse { + ok: boolean; + status: number; + headers: http.IncomingHttpHeaders; + arrayBuffer: () => Promise; +} + +// Fungsi untuk download video +export async function downloadVideo(videoUrl: string, filename = 'video.mp4'): Promise { + try { + console.log('๐Ÿ”„ Downloading video from:', videoUrl); + + let response: any; + + if (typeof fetch !== 'undefined') { + response = await fetch(videoUrl); + } else { + response = await downloadWithHttps(videoUrl); + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contentType = response.headers.get ? + response.headers.get('content-type') : + response.headers['content-type']; + console.log('๐Ÿ“ Content Type:', contentType); + + const contentLength = response.headers.get ? + response.headers.get('content-length') : + response.headers['content-length']; + if (contentLength) { + console.log('๐Ÿ“Š File Size:', (parseInt(contentLength) / 1024 / 1024).toFixed(2) + ' MB'); + } + + const videoBuffer = typeof fetch !== 'undefined' ? + Buffer.from(await response.arrayBuffer()) : + response as Buffer; + + console.log('โœ… Video downloaded successfully'); + await saveVideoToFile(videoBuffer, filename); + return videoBuffer; + + } catch (error: any) { + console.error('โŒ Error downloading video:', error.message); + return null; + } +} + +export function downloadWithHttps(videoUrl: string): Promise { + return new Promise((resolve, reject) => { + const urlObj = new URL(videoUrl); + const protocol = urlObj.protocol === 'https:' ? https : http; + + const request = protocol.get(videoUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); + return; + } + + const chunks: Buffer[] = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const buffer = Buffer.concat(chunks); + resolve({ + ok: true, + status: response.statusCode || 200, + headers: response.headers, + arrayBuffer: () => Promise.resolve(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)) + }); + }); + }); + + request.on('error', reject); + request.setTimeout(30000, () => { + request.destroy(); + reject(new Error('Request timeout')); + }); + }); +} + +export async function saveVideoToFile(buffer: Buffer, filename: string): Promise { + try { + const downloadsDir = path.join(process.cwd(), 'downloads'); + if (!fs.existsSync(downloadsDir)) { + fs.mkdirSync(downloadsDir, { recursive: true }); + } + + const filePath = path.join(downloadsDir, filename); + await fs.promises.writeFile(filePath, buffer); + + console.log('๐Ÿ’พ File saved to:', filePath); + console.log('๐Ÿ“ File size:', (buffer.length / 1024 / 1024).toFixed(2) + ' MB'); + + return filePath; + } catch (error: any) { + console.error('โŒ Error saving file:', error); + throw error; + } +} + +export async function getVideoInfo(videoUrl: string): Promise { + const urlObj = new URL(videoUrl); + const protocol = urlObj.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const request = protocol.request(videoUrl, { method: 'HEAD' }, (response) => { + resolve({ + url: videoUrl, + status: response.statusCode, + contentType: response.headers['content-type'], + contentLength: response.headers['content-length'], + lastModified: response.headers['last-modified'], + size: response.headers['content-length'] ? + (parseInt(response.headers['content-length']) / 1024 / 1024).toFixed(2) + ' MB' : 'Unknown' + }); + }); + + request.on('error', reject); + request.setTimeout(10000, () => { + request.destroy(); + reject(new Error('Request timeout')); + }); + + request.end(); + }); +} + +export async function downloadVideoWithProgress(videoUrl: string, filename = 'video.mp4'): Promise<{ buffer: Buffer; filePath: string; size: number; } | null> { + try { + console.log('๐Ÿ”„ Starting download with progress tracking...'); + + const url = new URL(videoUrl); + const protocol = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const request = protocol.get(videoUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); + return; + } + + const total = parseInt(response.headers['content-length'] || '0', 10); + let loaded = 0; + const chunks: Buffer[] = []; + + console.log('๐Ÿ“Š Total size:', (total / 1024 / 1024).toFixed(2) + ' MB'); + + response.on('data', (chunk) => { + chunks.push(chunk); + loaded += chunk.length; + if (total) { + const progress = Math.round((loaded / total) * 100); + const loadedMB = (loaded / 1024 / 1024).toFixed(2); + const totalMB = (total / 1024 / 1024).toFixed(2); + process.stdout.write(`\r๐Ÿ“ฅ Progress: ${progress}% (${loadedMB}/${totalMB} MB)`); + } + }); + + response.on('end', async () => { + console.log('\nโœ… Download completed!'); + const buffer = Buffer.concat(chunks); + try { + const filePath = await saveVideoToFile(buffer, filename); + resolve({ buffer, filePath, size: buffer.length }); + } catch (error) { + reject(error); + } + }); + }); + + request.on('error', reject); + request.setTimeout(60000, () => { + request.destroy(); + reject(new Error('Request timeout')); + }); + }); + + } catch (error: any) { + console.error('Download failed:', error); + return null; + } +} + +export async function testDownload(): Promise { + const videoUrl = 'https://your.video.url.mp4'; + console.log('=== Testing Video Download in Node.js ==='); + + try { + console.log('\n1. Getting video info...'); + const info = await getVideoInfo(videoUrl); + console.log('Video Info:', info); + + console.log('\n2. Downloading video with progress...'); + const result = await downloadVideoWithProgress(videoUrl, 'instagram_video.mp4'); + + if (result) { + console.log('โœ… Download completed!'); + console.log('๐Ÿ“ File saved to:', result.filePath); + console.log('๐Ÿ“Š File size:', (result.size / 1024 / 1024).toFixed(2) + ' MB'); + } + } catch (error: any) { + console.error('โŒ Test failed:', error.message); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + testDownload(); +} diff --git a/src/utils/turndown-plugin-gfm.d.ts b/src/utils/turndown-plugin-gfm.d.ts new file mode 100644 index 0000000..86462fc --- /dev/null +++ b/src/utils/turndown-plugin-gfm.d.ts @@ -0,0 +1,4 @@ +declare module 'turndown-plugin-gfm' { + import { Plugin } from 'turndown'; + export const gfm: Plugin; +} \ No newline at end of file diff --git a/src/utils/turndown.ts b/src/utils/turndown.ts new file mode 100644 index 0000000..08e814d --- /dev/null +++ b/src/utils/turndown.ts @@ -0,0 +1,47 @@ +import TurndownService from 'turndown'; +import { gfm } from 'turndown-plugin-gfm'; + +export const turndownService = new TurndownService(); +turndownService.use(gfm); + +// bug table +const tableRule = turndownService.rules.array[2]; +if (!tableRule.filter.toString().includes('TABLE')) + throw new Error('Incorrect rule selected. Expected to find table rule'); +tableRule.filter = ['table']; + +// Filter element button youtube +turndownService.addRule('filterButtonContainer', { + filter: function (node) { + return node.id === 'button-container'; + }, + replacement: function () { + return ''; // Hilangkan element ini sepenuhnya + } +}); + +// Filter element YouTube +turndownService.addRule('youtubeElements', { + filter: function (node) { + // Filter semua element yang dimulai dengan 'yt-' atau 'ytd-' + if (node.nodeName && (node.nodeName.toLowerCase().startsWith('yt-') || + node.nodeName.toLowerCase().startsWith('ytd-'))) { + return true; + } + return false; + }, + replacement: function (content) { + // Ambil teks dari dalam element YouTube + return content; + } +}); + +// Filter element yang tersembunyi +turndownService.addRule('hiddenElements', { + filter: function (node) { + return node.hasAttribute && node.hasAttribute('hidden'); + }, + replacement: function () { + return ''; + } +}); diff --git a/tmp/timeConverter.js b/tmp/timeConverter.js new file mode 100644 index 0000000..0b17c91 --- /dev/null +++ b/tmp/timeConverter.js @@ -0,0 +1,60 @@ +const timeConverter = (hoursAgo) => { + const date = new Date(); + date.setTime(date.getTime() - (parseInt(hoursAgo) * 60 * 60 * 1000)); + + return { + indonesian: date.toLocaleString('id-ID', { + timeZone: 'Asia/Jakarta' + }), + iso: date.toISOString() + }; +}; + +const parseRelativeTime = (relativeTimeString) => { + const match = relativeTimeString.match(/(\d+)(hr|h|hours?|min|m|minutes?|d|days?)\s*ago/i); + + if (!match) { + throw new Error('Invalid relative time format'); + } + + const value = parseInt(match[1]); + const unit = match[2].toLowerCase(); + + const date = new Date(); + + switch (unit) { + case 'hr': + case 'h': + case 'hour': + case 'hours': + date.setTime(date.getTime() - (value * 60 * 60 * 1000)); + break; + case 'min': + case 'm': + case 'minute': + case 'minutes': + date.setTime(date.getTime() - (value * 60 * 1000)); + break; + case 'd': + case 'day': + case 'days': + date.setTime(date.getTime() - (value * 24 * 60 * 60 * 1000)); + break; + default: + throw new Error('Unsupported time unit'); + } + + return { + indonesian: date.toLocaleString('id-ID', { + timeZone: 'Asia/Jakarta' + }), + iso: date.toISOString(), + timestamp: date.getTime() + }; +}; + + +console.log('4 hours ago:', parseRelativeTime('4hr ago')); +console.log('30 minutes ago:', parseRelativeTime('30min ago')); +console.log('2 days ago:', parseRelativeTime('2d ago')); +console.log('Original timeConverter:', timeConverter('4')); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..63a5b6a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "6.0" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file