mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-09-12 10:22:10 +02:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee20c7437c | |||
| e500088e65 | |||
| 3657de9b2c | |||
| 4e6dfd1977 | |||
| 51afbdaee1 | |||
| 6fb6726a0c | |||
| 0afa1be8be | |||
| d6cbaadf05 | |||
| 54ff493ea4 | |||
| ea7f1c0b38 | |||
| c0c3a2ea5f | |||
| 3d3c7ef60c | |||
| 590a764bd6 | |||
| 00242eaef0 | |||
| 40fda6dd32 | |||
| fb46b07db6 | |||
| 70d4fda075 | |||
| bb527d6da1 | |||
| 5ec6f3730c | |||
| 5c6f698c3a | |||
| 180840a063 | |||
| 3f1b07310e | |||
| bd4b8dcee4 | |||
| 98b5ac7ca5 | |||
| 5eb07df2e0 | |||
| 9f3faae202 | |||
| c92d5896eb | |||
| 86aeb6eaf2 | |||
| 2844720d10 | |||
| cb5838cf62 | |||
| 43dd3b6824 | |||
| db0a2e71a8 | |||
| f619fcf68f | |||
| 7c3d8dac68 | |||
| fa308ce07d | |||
| f43ca3b865 | |||
| 9c5a4b8b06 | |||
| 0206a8e7e2 | |||
| 08471f1d32 | |||
| b1e66e3ad8 | |||
| 55a21ad85e | |||
| 1592751376 | |||
| 995229fc2e | |||
| b70ca93301 | |||
| 129ba619e8 | |||
| a748ffeb7e | |||
| 04aec7be69 | |||
| 4fae69734c | |||
| 9205727284 | |||
| 29c8e7b173 | |||
| be303203a9 | |||
| ce2ad7094d | |||
| 0e4646e8f6 | |||
| fc0b0feaa2 | |||
| abb9d0359b | |||
| 49f0201805 | |||
| 46e451600a | |||
| 0d23402f1a | |||
| 35b6ec0909 | |||
| 18c680e250 | |||
| 86dfaa1449 | |||
| 427a49aa78 | |||
| 30213b9ecf | |||
| 928d6df4c7 | |||
| c8f23b2c7d | |||
| cf26dd6095 | |||
| 65e61453f9 | |||
| 971e682ff9 | |||
| e7aab61eed | |||
| 6caa654b76 | |||
| cdf58e72a1 | |||
| f31d1d4143 | |||
| f20dba6453 | |||
| a42df5bb82 | |||
| 42c283755a | |||
| 08ee253c05 | |||
| 7d2fe5019c | |||
| 2628ff4420 | |||
| d2f334b9bc | |||
| 7ddc193a18 | |||
| bbf29fb2c2 | |||
| d62130b898 | |||
| fbf00e474d | |||
| 3d788b3d31 | |||
| 1339af1378 | |||
| bbf2269e5f | |||
| 7c4058a351 | |||
| dfcc400868 | |||
| bf98563c17 | |||
| eda0aca9e9 | |||
| 954e0a7caf | |||
| f1a2cd54c2 | |||
| 1ed03f7cc9 | |||
| 2882cfd6e6 | |||
| 63a8b2868b | |||
| 27d97a8b5a | |||
| 6e15426637 | |||
| bdf45878a8 | |||
| 0608077614 | |||
| 7da80768dd | |||
| b133adeecb | |||
| 47517ce0bd | |||
| fbf80ab174 | |||
| 6791543617 | |||
| 2f2153ed37 | |||
| 614c33376d | |||
| b6367ae334 | |||
| f78bd7708a | |||
| 3ff6ce1209 | |||
| 4ea103c745 | |||
| 6f89e4d22a | |||
| 4650337f8e | |||
| 0105c6c141 | |||
| fc76589e77 | |||
| 1e2935b2fa | |||
| c4961aeb4b | |||
| a6289794d2 | |||
| da77fe9a2f | |||
| 6239453b9d | |||
| adbd0fd947 | |||
| 2c7dd8d8ca | |||
| fdf3c7fc11 | |||
| ccbc9a71b4 | |||
| c965d73abd | |||
| 15caa3b141 | |||
| b49114bb1b | |||
| f18be54de1 |
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"]
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": ["rewire"]
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Generated output must remain byte-for-byte stable across platforms.
|
||||
"Extensions/UserScript/Return Youtube Dislike.user.js" text eol=lf
|
||||
|
||||
@@ -5,7 +5,7 @@ labels: ["bug"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Have you tried to find similar open issues?
|
||||
label: Have you tried to find similar issues (open or recently closed)?
|
||||
options:
|
||||
- label: "Yes, this issue is not a duplicate"
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/Website"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Test Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "Extensions/combined/**"
|
||||
- "Extensions/common/**"
|
||||
- "Extensions/UserScript/**"
|
||||
- "Extensions/e2e/**"
|
||||
- ".github/workflows/build.yaml"
|
||||
- ".babelrc"
|
||||
- ".gitattributes"
|
||||
- ".nvmrc"
|
||||
- "extension-build-receipt*.js"
|
||||
- "userscript-build-receipt*.js"
|
||||
- "jest.config.js"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "playwright*.js"
|
||||
- "webpack*.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "Extensions/combined/**"
|
||||
- "Extensions/common/**"
|
||||
- "Extensions/UserScript/**"
|
||||
- "Extensions/e2e/**"
|
||||
- ".github/workflows/build.yaml"
|
||||
- ".babelrc"
|
||||
- ".gitattributes"
|
||||
- ".nvmrc"
|
||||
- "extension-build-receipt*.js"
|
||||
- "userscript-build-receipt*.js"
|
||||
- "jest.config.js"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "playwright*.js"
|
||||
- "webpack*.js"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Jest tests
|
||||
run: npm test -- --runInBand
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
|
||||
- name: Check generated extension artifact
|
||||
run: npm run check:extension-artifact
|
||||
|
||||
- name: Build userscript
|
||||
run: npm run build:userscript
|
||||
|
||||
- name: Check generated userscript
|
||||
run: git diff --exit-code -- "Extensions/UserScript/Return Youtube Dislike.user.js"
|
||||
|
||||
- name: Install Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run generated-artifact browser tests
|
||||
run: npm run test:e2e:artifacts
|
||||
|
||||
- name: Run extension browser tests
|
||||
run: npm run test:e2e:extension:run
|
||||
|
||||
- name: Run userscript browser tests
|
||||
run: npm run test:e2e:userscript:run
|
||||
|
||||
- name: Upload Chrome artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ryd-chrome
|
||||
path: ./Extensions/combined/dist/chrome
|
||||
|
||||
- name: Upload Firefox artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ryd-firefox
|
||||
path: ./Extensions/combined/dist/firefox
|
||||
|
||||
- name: Upload Safari artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ryd-safari
|
||||
path: ./Extensions/combined/dist/safari
|
||||
|
||||
- name: Upload Playwright failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-browser-failures
|
||||
path: |
|
||||
./test-results
|
||||
./playwright-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -1,53 +0,0 @@
|
||||
name: commentCommands
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: created
|
||||
|
||||
jobs:
|
||||
assign-commenter:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.comment.body, '/assignme') ||
|
||||
contains(github.event.comment.body, '/assign me')
|
||||
steps:
|
||||
- name: Assigning to commenter
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/assignees \
|
||||
-d '{"assignees":["${{ github.event.comment.user.login }}"]}'
|
||||
request-issue-framing-improvement:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.comment.body,'/improve') && (
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association == 'CONTRIBUTOR' )
|
||||
steps:
|
||||
- name: request-issue-framing-improvement
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \
|
||||
-d '{"body":"This issue is put on hold due to low quality. No reviews or fixes will be performed at this time. Eventually, it will be closed. While we appreciate your effort writing, we are not able to further investigate it. Please improve it by writing a better title or providing more details, and you may re-open it."}'
|
||||
add-label-duplicate:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.comment.body, '/duplicate') && (
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association != 'CONTRIBUTOR' )
|
||||
steps:
|
||||
- name: add-label-duplicate
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \
|
||||
-d '{"labels":["duplicate"]}'
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Comment Commands
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
assign-commenter:
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
contains(github.event.comment.body, '/assignme') ||
|
||||
contains(github.event.comment.body, '/assign me')
|
||||
steps:
|
||||
- name: Assigning commenter
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/assignees \
|
||||
-d '{"assignees":["${{ github.event.comment.user.login }}"]}'
|
||||
|
||||
request-issue-framing-improvement:
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
contains(github.event.comment.body, '/improve') && (
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association == 'CONTRIBUTOR'
|
||||
)
|
||||
steps:
|
||||
- name: Request issue framing improvement
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments \
|
||||
-d '{"body":"This issue is put on hold due to low quality. No reviews or fixes will be performed at this time. Eventually, it will be closed. While we appreciate your effort writing, we are not able to further investigate it. Please improve it by writing a better title or providing more details, and you may re-open it."}'
|
||||
|
||||
add-duplicate-label:
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
contains(github.event.comment.body, '/duplicate') && (
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR' ||
|
||||
github.event.comment.author_association != 'CONTRIBUTOR'
|
||||
)
|
||||
steps:
|
||||
- name: Add duplicate label
|
||||
run: |
|
||||
curl \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels \
|
||||
-d '{"labels":["duplicate"]}'
|
||||
@@ -4,22 +4,28 @@ name: Website Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- Website/**
|
||||
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- Website/**
|
||||
|
||||
jobs:
|
||||
weblint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@main
|
||||
- run: npm install
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: Website
|
||||
- run: npm run lint
|
||||
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
working-directory: Website
|
||||
|
||||
+8
-2
@@ -1,10 +1,13 @@
|
||||
*cert
|
||||
*Backend
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
|
||||
# Website node modules and build output
|
||||
Website/package-lock.json
|
||||
|
||||
# AI assistant artifacts
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
Website/.editorconfig
|
||||
Website/node_modules
|
||||
Website/.nuxt
|
||||
@@ -36,5 +39,8 @@ Extensions/combined/bundled-content-script.js
|
||||
|
||||
# Dist Files
|
||||
Extensions/combined/dist/*
|
||||
package-lock.json
|
||||
Website/package-lock.json
|
||||
|
||||
# Playwright output
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Return YouTube Dislike 4.0.6
|
||||
|
||||
What's new since 3.0.0.18:
|
||||
|
||||
- **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync.
|
||||
- **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit.
|
||||
- **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps.
|
||||
- **Hide clutter:** keep Like and Dislike visible while moving extra YouTube actions into the More menu.
|
||||
- **Refreshed controls:** an updated popup, improved display settings, and raw RYD like/dislike totals in the side panel.
|
||||
- **New: GitHub contributor sign-in**, alongside Patreon access to premium features.
|
||||
|
||||
Requires Firefox 140 or newer.
|
||||
@@ -0,0 +1,21 @@
|
||||
# AMO 4.0.6 Reviewer Notes
|
||||
|
||||
4.0.6 fixes the `authenticationInfo` consent handling rejected in 4.0.5. Account requests require an explicit grant from
|
||||
`browser.permissions.getAll().data_collection`. Sign-in requests consent from the user's click; denial blocks account
|
||||
traffic, and revocation clears the session. Dislike counts work without signing in.
|
||||
|
||||
Build the supplied source with Node.js 22.17.0 and npm 10.8.2:
|
||||
|
||||
```sh
|
||||
node scripts/build-firefox-source.mjs
|
||||
```
|
||||
|
||||
Output: `Extensions/combined/dist/firefox`. The source rebuild matches the submitted package byte for byte.
|
||||
|
||||
To test premium statistics, select **Login with Patreon**, accept the optional consent, and use the account below.
|
||||
Open a YouTube video to view the statistics. Revoke authentication consent in `about:addons` to verify sign-out.
|
||||
|
||||
```text
|
||||
Login:
|
||||
Password:
|
||||
```
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
Read this in other languages: [русский](CONTRIBUTINGru.md), [Nederlands](CONTRIBUTINGnl.md), [Français](CONTRIBUTINGfr.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md)
|
||||
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Welcome To Return YouTube Dislikes contributing guide
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# مرحبًا بكم في دليل المساهمة في Return YouTube Dislikes
|
||||
|
||||
شكرًا لك على استثمار وقتك في المساهمة في مشروعنا! سيتم عكس جميع تغييراتك في الإصدار التالي من الامتداد (أو [الموقع](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## البدء
|
||||
|
||||
يرجى استخدام Prettier بالإعدادات الافتراضية للتنسيق.
|
||||
|
||||
#### المتطلبات الأساسية
|
||||
|
||||
تحتاج إلى تثبيت node و npm لإنشاء النسخة المجمعة من المصدر.
|
||||
|
||||
الإصدارات المستخدمة عند الإعداد:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
لإنشاء `bundled-content-script.js` الذي يحتوي على معظم منطق العمل لهذا الامتداد، يجب عليك تثبيت جميع التبعيات أولاً.
|
||||
|
||||
1. انتقل إلى جذر المستودع وقم بتشغيل:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. قم بتشغيل الأمر التالي لإنشاء `bundled-content-script.js` الذي يستخدم في `manifest.json`
|
||||
|
||||
```
|
||||
npm start // لإنشاء ملف(ات) البناء وبدء مراقب الملفات الذي يعيد التحميل تلقائيًا عند الحفظ
|
||||
|
||||
// أو
|
||||
|
||||
npm run build // لإنشاء ملف(ات) البناء مرة واحدة
|
||||
```
|
||||
|
||||
تهانينا، أنت الآن جاهز للتطوير!
|
||||
|
||||
إذا كنت جديدًا في تطوير ملحقات Chrome، أو تحتاج إلى مساعدة إضافية، يرجى مشاهدة [هذا الفيديو التعليمي على YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### المشاكل
|
||||
|
||||
#### فتح مشكلة جديدة
|
||||
|
||||
إذا كانت لديك أي مشاكل مع الامتداد، يرجى البحث للتأكد من أن المشكلة لم يتم الإبلاغ عنها بالفعل. إذا لم تكن كذلك، افتح مشكلة، يوصى بشدة باستخدام نموذج المشكلة ولكنه ليس إلزاميًا.
|
||||
|
||||
#### حل مشكلة
|
||||
|
||||
إذا وجدت مشكلة تشعر أنك قد تكون قادرًا على حلها، لا تخجل. افتح PR مع الإصلاح وتأكد من ذكر المشكلة التي تقوم بإصلاحها.
|
||||
|
||||
### طلب ميزة
|
||||
|
||||
#### فتح طلب ميزة جديدة
|
||||
|
||||
إذا كانت لديك فكرة للامتداد، لا تتردد في فتح طلب ميزة، ولكن يرجى البحث أولاً للتأكد من أن الميزة لم يتم اقتراحها بالفعل. يوصى بشدة باستخدام نموذج الميزة ولكنه ليس إلزاميًا.
|
||||
|
||||
#### تنفيذ طلب ميزة
|
||||
|
||||
إذا وجدت ميزة تشعر أنك قد تكون قادرًا على تنفيذها، لا تخجل. افتح PR مع الإصلاح وتأكد من ذكر الميزة التي تقوم بتنفيذها.
|
||||
|
||||
### ما PRs التي نقبلها؟
|
||||
|
||||
- إصلاحات المشاكل.
|
||||
- تنفيذ الميزات.
|
||||
- الأخطاء المطبعية أو الكلمات الأفضل والأسهل للاستخدام.
|
||||
- مساهمات الموقع.
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# "YouTube Dislike Sayını Geri Qaytar" layihəsinə qatqı qaydalarına Xoş Gəlmisiniz
|
||||
|
||||
Layihəmizə qatqı etmək üçün vaxt ayırdığınız üçün təşəkkür edirik! Bütün dəyişiklikləriniz uzantının növbəti versiyasına (və ya [internet saytına](https://www.returnyoutubedislike.com/)) əlavə ediləcəkdir.
|
||||
|
||||
## Başlamaq
|
||||
|
||||
Formatlama üçün xahiş olunur ki, Prettier-i standart parametrlərdə istifadə edin.
|
||||
|
||||
#### Tələblər
|
||||
|
||||
Layihənin paketlənmiş versiyasını yaratmaq üçün node və npm quraşdırılmalıdır.
|
||||
|
||||
Quraşdırma zamanı istifadə olunan versiyalar:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Bu uzantının əsas məntiqini ehtiva edən `bundled-content-script.js` faylını yaratmaq üçün əvvəlcə bütün asılılıqları quraşdırmalısınız.
|
||||
|
||||
1. Deponun əsas qovluğuna keçin və aşağıdakı əmri icra edin:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. `manifest.json` içərisində istifadə olunan `bundled-content-script.js` faylını yaratmaq üçün aşağıdakı əmri çalışdırın.
|
||||
|
||||
```
|
||||
npm start // fayl yaradılarkən və saxlanarkən avtomatik yenilənən fayl izləyicisini başlatmaq üçün
|
||||
|
||||
// ya da
|
||||
|
||||
npm run build // faylları yalnız bir dəfə yaratmaq üçün
|
||||
```
|
||||
|
||||
Təbriklər, artıq inkişaf etməyə hazırsınız!
|
||||
|
||||
Chrome uzantılarının inkişafına yeni başlamısınızsa və ya əlavə yardıma ehtiyacınız varsa, lütfən [bu YouTube dərsinə](https://www.youtube.com/watch?v=mdOj6HYE3_0) baxın.
|
||||
|
||||
### Issue'lar
|
||||
|
||||
#### Yeni bir issue açmaq
|
||||
|
||||
Uzantı ilə bağlı hər hansı bir probleminiz varsa, problemin əvvəllər bildirilmədiyindən əmin olmaq üçün axtarış aparın. Əgər daha əvvəl bildirilməyibsə, bir mövzu açın. Problem formunun istifadəsi şiddətlə tövsiyə olunur, lakin məcburi deyil.
|
||||
|
||||
#### Bir issue'yu həll etmək
|
||||
|
||||
Həll edə biləcəyinizi düşündüyünüz bir problem tapsanız, çəkinmədən çalışın. Problemi həll edən bir PR açın və həll etdiyiniz problemi müəyyənləşdirdiyinizdən əmin olun.
|
||||
|
||||
### Xüsusiyyət İstəkləri
|
||||
|
||||
#### Yeni bir xüsusiyyət istəyi açmaq
|
||||
|
||||
Uzantı haqqında bir ideyanız varsa, yeni bir xüsusiyyət istəyi açmaqdan çəkinməyin, lakin xüsusiyyətin daha əvvəl təklif olunmadığından əmin olmaq üçün axtarış edin. Xüsusiyyət formasının istifadəsi şiddətlə tövsiyə olunur, lakin məcburi deyil.
|
||||
|
||||
#### Bir xüsusiyyət istəyini tətbiq etmək
|
||||
|
||||
Tətbiq edə biləcəyinizi düşündüyünüz bir xüsusiyyət tapsanız, çəkinmədən çalışın. Xüsusiyyəti tətbiq edən bir PR açın və əlavə etdiyiniz xüsusiyyəti qeyd etdiyinizdən əmin olun.
|
||||
|
||||
### Hansı PR növlərini qəbul edirik?
|
||||
|
||||
- Problem həlləri.
|
||||
- Xüsusiyyət tətbiqləri.
|
||||
- Yazı səhvləri və ya daha aydın ifadələr.
|
||||
- Sayt qatqıları.
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Добре дошли в ръководството за сътрудничество на Return YouTube Dislikes
|
||||
|
||||
Благодарим ви, че отделяте време за принос към нашия проект! Всички вашите промени ще се отразят в следващата версия на разширението (или на [уебсайта](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Започване
|
||||
|
||||
Моля, използвайте Prettier с настройките по подразбиране за форматиране.
|
||||
|
||||
#### Изисквания
|
||||
|
||||
Трябва да имате инсталирани node и npm, за да създадете обединената версия на изходния код.
|
||||
|
||||
Използвани версии при настройване:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
За да създадете `bundled-content-script.js`, който съдържа повечето от бизнес логиката на това разширение, трябва първо да инсталирате всички зависимости.
|
||||
|
||||
1. Отидете в коренната папка на хранилището и изпълнете:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Изпълнете следната команда, за да създадете `bundled-content-script.js`, който се използва в `manifest.json`
|
||||
|
||||
```
|
||||
npm start // за създаване на файл(ове) за сборка и стартиране на файлов наблюдател, който рестартира автоматично след запазване
|
||||
|
||||
// или
|
||||
|
||||
npm run build // за създаване на файл(ове) за сборка веднъж
|
||||
```
|
||||
|
||||
Поздравления, вече сте готови за разработка!
|
||||
|
||||
Ако сте нов в разработката на разширения за Chrome или ви трябва допълнителна помощ, моля вижте [този урок на YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Проблеми
|
||||
|
||||
#### Отваряне на нов проблем
|
||||
|
||||
Ако имате проблеми с разширението, моля, направете търсене, за да се уверите, че проблемът вече не е сигнализиран. Ако не е, отворете проблема (issue), използването на формуляра за проблем е настоятелно препоръчително, но не задължително.
|
||||
|
||||
#### Решаване на проблем
|
||||
|
||||
Ако сте открили проблем, който чувствате, че можете да решите, не бъдете срамежливи. Направете PR с решението и се уверете, че споменавате проблема, който решавате.
|
||||
|
||||
### Заявки за функционалности
|
||||
|
||||
#### Отваряне на нова заявка за функционалност
|
||||
|
||||
Ако имате идея за разширението, не се колебайте да отворите заявка за функционалност, но моля, направете търсене, за да се уверите, че функционалността вече не е предложена. Използването на формуляра за функционалности е настоятелно препоръчително, но не задължително.
|
||||
|
||||
#### Изпълнение на заявка за функционалност
|
||||
|
||||
Ако сте открили функционалност, която чувствате, че можете да изпълните, не бъдете срамежливи. Отворете PR с решението и се уверете, че споменавате функционалността, която изпълнявате.
|
||||
|
||||
### Какви PR-ове приемаме?
|
||||
|
||||
- Коригиране на проблеми.
|
||||
- Изпълнение на функционалности.
|
||||
- Правописни грешки или по-добри и по-лесни думи за употреба.
|
||||
- Приноси към уебсайта.
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# 欢迎来到 Return YouTube Dislikes 贡献指南
|
||||
|
||||
感谢您花时间为我们的项目做出贡献!您的所有更改都将反映在扩展的下一个版本中(或 [网站](https://www.returnyoutubedislike.com/))。
|
||||
|
||||
## 入门
|
||||
|
||||
请使用 Prettier 的默认设置进行格式化。
|
||||
|
||||
#### 先决条件
|
||||
|
||||
您需要安装 node 和 npm 才能创建源代码的捆绑版本。
|
||||
|
||||
设置时使用的版本:
|
||||
|
||||
- node:12.18.4
|
||||
- npm:6.14.6
|
||||
|
||||
要创建包含此扩展的大部分业务逻辑的 `bundled-content-script.js`,您必须先安装所有依赖项。
|
||||
|
||||
1. 转到 repo 的根目录并运行:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 运行以下命令创建 `manifest.json` 中使用的 `bundled-content-script.js`
|
||||
|
||||
```
|
||||
npm start // 创建构建文件并启动在保存时热重载的文件观察器
|
||||
|
||||
// 或
|
||||
|
||||
npm run build // 创建构建文件一次
|
||||
```
|
||||
|
||||
恭喜,您现在可以开始开发了!
|
||||
|
||||
如果您是 Chrome 扩展程序开发新手,或者需要额外帮助,请参阅[此 YouTube 教程](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### 问题
|
||||
|
||||
#### 打开新问题
|
||||
|
||||
如果您对扩展程序有任何问题,请搜索以确保该问题尚未被报告。如果没有,请打开一个问题,强烈建议使用问题表单,但这不是强制性的。
|
||||
|
||||
#### 解决问题
|
||||
|
||||
如果您发现一个您认为可以解决的问题,请随时提出来,不必客气。打开带有修复程序的 PR,并确保提及您正在修复的问题。
|
||||
|
||||
### 功能请求
|
||||
|
||||
#### 打开新的功能请求
|
||||
|
||||
如果您对扩展程序有想法,请随时打开功能请求,但请先搜索以确保该功能尚未被建议。强烈建议使用功能表单,但这不是强制性的
|
||||
|
||||
#### 实现功能请求
|
||||
|
||||
如果您发现您认为可以实现的功能,请随时提出来,不必客气。打开带有修复的 PR,并确保提及您正在实现的功能。
|
||||
|
||||
### 我们接受哪些 PR?
|
||||
|
||||
- 问题修复。
|
||||
- 功能实现。
|
||||
- 拼写错误或更好、更易用的词语。
|
||||
- 网站贡献。
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Velkommen til Return YouTube Dislike bidragsvejledning
|
||||
|
||||
Tak fordi du bruger din tid på at bidrage til vores projekt! Alle dine ændringer vil blive afspejlet i den næste version af udvidelsen (eller på [websitet](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Kom godt i gang
|
||||
|
||||
Brug venligst Prettier med standardindstillinger til formatering.
|
||||
|
||||
#### Forudsætninger
|
||||
|
||||
Du skal have Node og npm installeret for at kunne opbygge den bundtede version af koden.
|
||||
|
||||
Versioner brugt ved opsætning:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
For at oprette `bundled-content-script.js`, som indeholder størstedelen af udvidelsens forretningslogik, skal du først installere alle afhængigheder.
|
||||
|
||||
1. Gå til rodmappen af repoet og kør:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Kør følgende kommando for at oprette `bundled-content-script.js`, som bruges i `manifest.json`
|
||||
|
||||
```
|
||||
npm start // opret build-fil(er) og start en watcher, der genindlæser ved gem
|
||||
|
||||
// eller
|
||||
|
||||
npm run build // opret build-fil(er) én gang
|
||||
```
|
||||
|
||||
Tillykke, du er nu klar til at udvikle!
|
||||
|
||||
Hvis du er ny i udvikling af Chrome‑udvidelser eller har brug for ekstra hjælp, se [denne YouTube‑vejledning](https://www.youtube.com/watch?v=mdOj6HYE3_0).
|
||||
|
||||
### Issues
|
||||
|
||||
#### Opret en ny issue
|
||||
|
||||
Hvis du har problemer med udvidelsen, så søg først for at sikre, at problemet ikke allerede er rapporteret. Hvis ikke, opret en issue. Det anbefales stærkt at bruge issue‑skabelonen, men det er ikke påkrævet.
|
||||
|
||||
#### Løs en issue
|
||||
|
||||
Hvis du har fundet et problem, du mener, du kan løse, så tøv ikke. Opret en PR med rettelsen, og husk at nævne den issue, du løser.
|
||||
|
||||
### Feature‑ønsker
|
||||
|
||||
#### Opret et nyt feature‑ønske
|
||||
|
||||
Hvis du har en idé til udvidelsen, er du velkommen til at oprette et feature‑ønske, men søg venligst først for at sikre, at det ikke allerede er foreslået. Brug af feature‑skabelonen anbefales, men er ikke påkrævet.
|
||||
|
||||
#### Implementér et feature‑ønske
|
||||
|
||||
Hvis du har fundet et feature, som du kan implementere, så tøv ikke. Opret en PR med implementeringen, og husk at nævne det feature, du implementerer.
|
||||
|
||||
### Hvilke PR’er accepterer vi?
|
||||
|
||||
- Fejlrettelser.
|
||||
- Implementering af funktioner.
|
||||
- Ret stavefejl eller brug bedre og enklere formuleringer.
|
||||
- Bidrag til websitet.
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Willkommen beim Leitfaden für Beitragende von Return YouTube Dislikes
|
||||
|
||||
Vielen Dank, dass du deine Zeit investierst, um zu unserem Projekt beizutragen! Alle deine Änderungen werden in der nächsten Version der Erweiterung (oder der [Website](https://www.returnyoutubedislike.com/)) reflektiert.
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
Bitte verwende Prettier mit den Standardeinstellungen für die Formatierung.
|
||||
|
||||
#### Voraussetzungen
|
||||
|
||||
Du musst Node und npm installiert haben, um die gebündelte Version der Quelle zu erstellen.
|
||||
|
||||
Verwendete Versionen beim Einrichten:
|
||||
|
||||
- Node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Um die `bundled-content-script.js` zu erstellen, die den Großteil der Geschäftslogik dieser Erweiterung enthält, musst du zuerst alle Abhängigkeiten installieren.
|
||||
|
||||
1. Gehe zum Root-Verzeichnis des Repos und führe aus:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Führe den folgenden Befehl aus, um `bundled-content-script.js` zu erstellen, das in `manifest.json` verwendet wird:
|
||||
|
||||
```
|
||||
npm start // um die Build-Datei(en) zu erstellen und einen Datei-Watcher zu starten, der bei jedem Speichern neu lädt
|
||||
|
||||
// oder
|
||||
|
||||
npm run build // um die Build-Datei(en) einmal zu erstellen
|
||||
```
|
||||
|
||||
Herzlichen Glückwunsch, du bist jetzt bereit zum Entwickeln!
|
||||
|
||||
Wenn du neu in der Entwicklung von Chrome-Erweiterungen bist oder zusätzliche Hilfe benötigst, sieh dir bitte [dieses YouTube-Tutorial](https://www.youtube.com/watch?v=mdOj6HYE3_0) an.
|
||||
|
||||
### Probleme
|
||||
|
||||
#### Öffnen eines neuen Problems
|
||||
|
||||
Wenn du Probleme mit der Erweiterung hast, suche bitte, um sicherzustellen, dass das Problem noch nicht gemeldet wurde. Wenn nicht, öffne ein Problem. Die Verwendung des Problemformulars wird dringend empfohlen, ist aber nicht zwingend erforderlich.
|
||||
|
||||
#### Lösung eines Problems
|
||||
|
||||
Wenn du ein Problem gefunden hast, das du lösen könntest, sei nicht schüchtern. Öffne einen PR mit der Lösung und vergewissere dich, dass du das behobene Problem erwähnst.
|
||||
|
||||
### Feature-Anfrage
|
||||
|
||||
#### Öffnen einer neuen Feature-Anfrage
|
||||
|
||||
Wenn du eine Idee für die Erweiterung hast, kannst du gerne eine Feature-Anfrage öffnen, aber suche bitte zuerst, um sicherzustellen, dass das Feature noch nicht vorgeschlagen wurde. Die Verwendung des Feature-Formulars wird dringend empfohlen, ist aber nicht zwingend erforderlich.
|
||||
|
||||
#### Implementierung einer Feature-Anfrage
|
||||
|
||||
Wenn du ein Feature gefunden hast, das du implementieren könntest, sei nicht schüchtern. Öffne einen PR mit der Implementierung und vergewissere dich, dass du das implementierte Feature erwähnst.
|
||||
|
||||
### Welche PRs akzeptieren wir?
|
||||
|
||||
- Fehlerbehebungen.
|
||||
- Implementierung von Funktionen.
|
||||
- Rechtschreibfehler oder bessere und einfachere Wörter zur Verwendung.
|
||||
- Beiträge zur Website.
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Bienvenido(a) a la guía de contribución de Return YouTube Dislikes
|
||||
|
||||
¡Gracias por invertir tiempo en contribuir al proyecto! Todos sus cambios serán mostrados en la siguiente versión de la extensión (o en la [pagina web](https://www.returnyoutubedislike.com/))
|
||||
|
||||
## Empecemos
|
||||
|
||||
Por favor, utiliza Prettier con la configuración predeterminada para el formateo
|
||||
|
||||
#### Requisitos previos
|
||||
|
||||
Necesita tener instalados node y npm para crear la versión empaquetada del código fuente
|
||||
|
||||
Versiones utilizadas durante la configuración:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Para crear el archivo `bundled-content-script.js` que contiene la mayor parte de la lógica de negocio de esta extensión, primero debe instalar todas las dependencias
|
||||
|
||||
1. Ve a la raíz del repositorio y ejecute:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Ejecuta el siguiente comando para crear `bundled-content-script.js`, el cual se utiliza en `manifest.json`
|
||||
|
||||
```
|
||||
npm start // para crear el/los archivo(s) de compilación y comenzar un observador de archivos que se recargue automáticamente al guardar cambios
|
||||
|
||||
// o
|
||||
|
||||
npm run build // para crear el/los archivo(s) de compilación una vez
|
||||
```
|
||||
|
||||
¡Felicitaciones! ¡Ahora estás listo(a) para desarrollar!
|
||||
|
||||
Si eres nuevo/a en el desarrollo de extensiones de Chrome o necesitas ayuda adicional, por favor consulta [este tutorial en YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Problemas
|
||||
|
||||
#### Abrir un nuevo problema
|
||||
|
||||
Si tiene algún problema con la extensión, por favor verifique si el problema ya ha sido reportado. Si no lo ha sido, abre un problema. Se recomienda encarecidamente utilizar el formulario de problema, pero no es obligatorio.
|
||||
|
||||
#### Resolver un problema
|
||||
|
||||
Si encontró un problema que crea que pueda resolver, no dude en hacerlo. Abra una solicitud de extracción (PR) con la solución y asegúrese de mencionar el problema que está solucionando
|
||||
|
||||
### Solicitud de función
|
||||
|
||||
#### Abrir una nueva solicitud de función
|
||||
|
||||
If you have an idea for the extension, feel free to open a feature request, but please search it before to make sure the feature isn't already suggested. Using the feature form is highly recommended but not mandatory
|
||||
|
||||
#### Implementando una solicitud de funcionalidad
|
||||
|
||||
Si encontró una función que pueda implementar, no dude en hacerlo. Abra una solicitud de extracción (PR) con la solución y asegúrese de mencionar la función que está implementando
|
||||
|
||||
### ¿Qué PRs aceptamos?
|
||||
|
||||
- Corrección de problemas.
|
||||
- Implementación de funciones.
|
||||
- Corrección de errores tipográficos o uso de palabras mejores y más sencillas.
|
||||
- Contribuciones al sitio web.
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
|
||||
Lisez ceci dans d'autres langues : [English](CONTRIBUTING.md), [русский](CONTRIBUTINGru.md), Nederlands](CONTRIBUTINGnl.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md)
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Bienvenue dans le guide de contribution à Return YouTube Dislikes
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Welcome To Return YouTube Dislikes contributing guide
|
||||
|
||||
Σας ευχαριστούμε που αφιερώσατε χρόνο για να συνεισφέρετε στο έργο μας! Όλες οι αλλαγές σας θα εμφανιστούν στην επόμενη έκδοση της επέκτασης (ή στην [ιστοσελίδα](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Ξεκινώντας
|
||||
|
||||
Παρακαλώ χρησιμοποιήστε το Prettier με τις προκαθορισμένες ρυθμίσεις για τη μορφοποίηση.
|
||||
|
||||
#### Προαπαιτούμενα
|
||||
|
||||
Πρέπει να έχετε το node και το npm εγκαταστημένο για να δημιουργήσετε την ομαδοποιημένη έκδοση της πηγής.
|
||||
|
||||
Εκδόσεις για την εγκατάσταση:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Για να δημιουργήσετε το `bundled-content-script.js` που περιέχει το περισσότερο business logic αυτής της επέκτασης πρέπει να εγκαταστήσετε όλα τα dependencies πρώτα.
|
||||
|
||||
1. Πηγαίντε στη ρίζα (root) του repo και εκτελέστε:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Εκτελέστε την ακόλουθη εντολή για να δημιουργήσετε το `bundled-content-script.js` το οποίο χρησιμοποιείται στο `manifest.json`
|
||||
|
||||
```
|
||||
npm start // για να δημιουργήσετε τα build file(s) και να ξεκινήσετε ένα πρόγραμμα παρακολούθυησης αρχείων που τα φορτώνει ξανά κατά την αποθήκευση
|
||||
|
||||
// ή
|
||||
|
||||
npm run build // για να δημιουργήσετε τα build file(s) μία φορά
|
||||
```
|
||||
|
||||
Συγχαρητήρια, τώρα είστε έτοιμος να δημιουργήσετε!
|
||||
|
||||
Εάν είστε νέος στη δημιουργία επεκτάσεων στο Chrome, ή χρειάζεστε επιπλέον βοήθεια, παρακαλώ δείτε [αυτόν τον οδηγό στο YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Ζητήματα
|
||||
|
||||
#### Δημιουργώντας ένα νέο ζήτημα
|
||||
|
||||
Εάν αντιμετωπίζετε κάποιο πρόβλημα με την επέκταση, παρακαλώ κάντε μια αναζήτηση για να σιγουρευτείτε ότι το ζήτημα δεν έχει αναφερθεί ήδη. Αν δεν είναι, δημιουργήστε ένα νέο ζήτημα χρησιμοποιώντας την φόρμα ζητημάτων η οποία συστήνεται αλλά δεν είναι απαραίτητη.
|
||||
|
||||
#### Λύνοντας ένα ζήτημα
|
||||
|
||||
Εάν βρήκατε ένα ζήτημα που πιστεύετε ότι ίσως μπορέσετε να λύσετε, μην ντρέπεστε. Ανοίξτε ένα PR με την επιδιόρθωση και φροντίστε να αναφέρετε το πρόβλημα που επιδιορθώνετε.
|
||||
|
||||
### Αίτηση για νέα λειτουργίας
|
||||
|
||||
#### Άνοιγμα αιτήματος νέας λειτουργίας
|
||||
|
||||
Εάν έχετε μια ιδέα για την επέκταση, μη διστάσετε να ανοίξετε ένα αίτημα λειτουργίας, αλλά ψάξτε το προηγουμένως για να βεβαιωθείτε ότι η λειτουργία δεν έχει ήδη προταθεί. Η χρήση της φόρμας για νέες λειτουργίες συστήνεται αλλά δεν είναι απαραίτητη.
|
||||
|
||||
#### Υλοποιώντας ένα αίτημα νέας λειτουργίας
|
||||
|
||||
Αν βρήκατε μια δυνατότητα που πιστεύετε ότι μπορεί να μπορέσετε να εφαρμόσετε, μην ντρέπεστε. Ανοίξτε ένα PR με την επιδιόρθωση και φροντίστε να αναφέρετε τη δυνατότητα που εφαρμόζετε.
|
||||
|
||||
### Τι PR δεχόμαστε;
|
||||
|
||||
- Διορθώσεις ζητημάτων.
|
||||
- Υλοποίηση νέων λειτουργιών.
|
||||
- Τυπογραφικά λάθη ή καλύτερες και πιο εύκολες λέξεις στη χρήση.
|
||||
- Συνεισφορές στην ιστοσελίδα.
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Üdvözlünk a Return YouTube Dislike közreműködői útmutatójában
|
||||
|
||||
Köszönjük, hogy időt szánsz a projekthez való hozzájárulásra! Minden módosításod meg fog jelenni a bővítmény következő verziójában (vagy a [weboldalon](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Kezdő lépések
|
||||
|
||||
Kérjük, a formázáshoz használd a Prettier eszközt alapértelmezett beállításokkal.
|
||||
|
||||
#### Előkövetelmények
|
||||
|
||||
A forrás kimenetének elkészítéséhez szükséged lesz a Node és az npm telepítésére.
|
||||
|
||||
Az előkészítéskor használt verziók:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
A `bundled-content-script.js` létrehozásához (ami a bővítmény üzleti logikájának nagy részét tartalmazza) először telepíteni kell az összes függőséget.
|
||||
|
||||
1. Lépj a repó gyökerébe és futtasd:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Futtasd az alábbi parancsot a `bundled-content-script.js` elkészítéséhez, amelyet a `manifest.json` használ:
|
||||
|
||||
```
|
||||
npm start // a build fájl(ok) létrehozása és egy watcher indítása, amely mentéskor újratölt
|
||||
|
||||
// vagy
|
||||
|
||||
npm run build // a build fájl(ok) egyszeri létrehozása
|
||||
```
|
||||
|
||||
Gratulálunk, készen állsz a fejlesztésre!
|
||||
|
||||
Ha új vagy a Chrome‑bővítmények fejlesztésében, vagy extra segítségre van szükséged, nézd meg [ezt a YouTube‑oktatóanyagot](https://www.youtube.com/watch?v=mdOj6HYE3_0).
|
||||
|
||||
### Hibák és problémák
|
||||
|
||||
#### Új issue megnyitása
|
||||
|
||||
Ha problémád van a bővítménnyel, először keress rá, hogy nem jelentették‑e már. Ha nem, nyiss egy issue‑t. Erősen ajánlott az issue űrlap használata, de nem kötelező.
|
||||
|
||||
#### Egy issue megoldása
|
||||
|
||||
Ha találtál egy problémát, amit meg tudsz oldani, ne habozz. Nyiss egy PR‑t a javítással, és mindenképp említsd meg, melyik issue‑t javítod.
|
||||
|
||||
### Funkciókérések
|
||||
|
||||
#### Új funkciókérés megnyitása
|
||||
|
||||
Ha van egy ötleted a bővítményhez, nyugodtan nyiss funkciókérést, de előtte keresd meg, hogy nem javasolták‑e már. A funkció űrlap használata ajánlott, de nem kötelező.
|
||||
|
||||
#### Funkciókérés megvalósítása
|
||||
|
||||
Ha találtál egy funkciót, amit meg tudsz valósítani, ne habozz. Nyiss egy PR‑t a megvalósítással, és említsd meg a funkciót, amit implementálsz.
|
||||
|
||||
### Milyen PR‑okat fogadunk el?
|
||||
|
||||
- Hibajavítások.
|
||||
- Funkciók implementálása.
|
||||
- Elírások javítása, jobb és egyszerűbb megfogalmazások.
|
||||
- Közreműködések a weboldalon.
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Selamat datang di petunjuk kontribusi Return Youtube Dislikes
|
||||
|
||||
Terima kasih telah menggunakan waktumu untuk berkontribusi pada proyek kami! Semua perubahan akan diaplikasikan pada versi extension selanjutnya (atau [website](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Mula-mula
|
||||
|
||||
Tolong gunakan Prettier dengan setting bawaan untuk melakukan formatting.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Kamu harus menginstall node dan npm untuk membuat versi bundled dari source code.
|
||||
|
||||
Versi yang digunakan:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Untuk membuat `bundled-content-script.js` yang memiliki semua logic dari extension ini, kamu harus menginstall semua dependency terlebih dahulu.
|
||||
|
||||
1. Masuk ke root dari repo dan jalankan:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Jalankan command berikut untuk membuat `bundled-content-script.js` yang akan digunakan di `manifest.json`
|
||||
|
||||
```
|
||||
npm start // untuk membuat build file dan menjalankan file watcher yang akan melakukan hot-reloads ketika menyimpan.
|
||||
|
||||
// atau
|
||||
|
||||
npm run build // untuk membuat build file satu kali.
|
||||
```
|
||||
|
||||
Selamat, kamu sekarang sudah siap untuk melakukan develop!
|
||||
|
||||
Jika kamu baru dalam melakukan develop extension Chrome, atau membutuhkan bantuan tambahan, tolong kunjungi [tutorial Youtube ini](https://www.youtube.com/watch?v=mdOj6HYE3_0).
|
||||
|
||||
### Issues
|
||||
|
||||
#### Membuat issue baru
|
||||
|
||||
Jika kamu menemuakan issue mengenai extension, tolong cari terlebih dahulu untuk memastikan issue tersebuat telah di laporkan. Jika belum, buat issue baru, direkomendasikan menggunakan form issue tapi tidak harus.
|
||||
|
||||
#### Menyelesaikan issue
|
||||
|
||||
Jika kamu menemukan issue yang dapat kamu selesaikan, jangan malu. Buat PR dan pastikan untuk mention issue yang sedang diperbaiki.
|
||||
|
||||
### Rqeust Fitur
|
||||
|
||||
#### Membuat request fitur
|
||||
|
||||
Jika kamu memiliki ide mengenai extension, silakan untuk membuat request fitur, tapi tolong cari terlebih dahulu untuk memastikan fitur tersebut belum direquest oleh orang lain. Direkomendasikan menggunakan form request fitur tapi tidak harus.
|
||||
|
||||
#### Implementasi request fitur
|
||||
|
||||
Jika kamu menemukan request fitur yang dapat kamu kerjakan, jangan malu. Buat PR dan pastikan untuk mention request fitur yang sedang dikerjakan.
|
||||
|
||||
### PR apa saja yang kami terima?
|
||||
|
||||
- Memperbaiki issue.
|
||||
- Implementasi fitur.
|
||||
- Typo atau improvisasi kata dan kalimat.
|
||||
- Kontribusi website.
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Return YouTube Dislike へのコントリビュートガイドへようこそ
|
||||
|
||||
本プロジェクトへのご協力に時間を割いていただきありがとうございます! 皆さまの変更は、拡張機能の次のリリース(または[ウェブサイト](https://www.returnyoutubedislike.com/))に反映されます。
|
||||
|
||||
## はじめに
|
||||
|
||||
フォーマットには Prettier をデフォルト設定のまま使用してください。
|
||||
|
||||
#### 前提条件
|
||||
|
||||
ソースのバンドル版を作成するには、Node と npm をインストールする必要があります。
|
||||
|
||||
セットアップ時のバージョン:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
この拡張機能の大部分のロジックを含む `bundled-content-script.js` を作成するには、まず依存関係をインストールします。
|
||||
|
||||
1. リポジトリのルートに移動して、以下を実行します:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. `manifest.json` で使用される `bundled-content-script.js` を作成するには、次のコマンドを実行します。
|
||||
|
||||
```
|
||||
npm start // ビルドファイルを作成し、保存時に自動リロードするウォッチャーを開始
|
||||
|
||||
// または
|
||||
|
||||
npm run build // 一度だけビルドファイルを作成
|
||||
```
|
||||
|
||||
おめでとうございます。これで開発を始める準備ができました!
|
||||
|
||||
Chrome 拡張の開発が初めて、または追加のヘルプが必要な場合は、[こちらの YouTube チュートリアル](https://www.youtube.com/watch?v=mdOj6HYE3_0)をご覧ください。
|
||||
|
||||
### Issue について
|
||||
|
||||
#### 新しい Issue を作成する
|
||||
|
||||
拡張機能に問題がある場合は、既に報告されていないか検索してください。未報告であれば Issue を作成してください。Issue フォームの使用は強く推奨しますが必須ではありません。
|
||||
|
||||
#### Issue を解決する
|
||||
|
||||
解決できそうな Issue を見つけた場合は、遠慮なく修正の PR を作成し、修正した Issue を必ず明記してください。
|
||||
|
||||
### 機能リクエスト
|
||||
|
||||
#### 新しい機能リクエストを作成する
|
||||
|
||||
拡張機能に関するアイデアがある場合は、自由に機能リクエストを作成してください。ただし、既に提案されていないか事前に検索してください。機能フォームの使用は強く推奨しますが必須ではありません。
|
||||
|
||||
#### 機能リクエストを実装する
|
||||
|
||||
実装できそうな機能を見つけた場合は、遠慮なく実装の PR を作成し、実装する機能を必ず明記してください。
|
||||
|
||||
### 受け付ける PR
|
||||
|
||||
- 不具合修正
|
||||
- 機能の実装
|
||||
- 誤字修正や、より良く簡潔な表現への改善
|
||||
- ウェブサイトへの貢献
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Return YouTube Dislike 기여 가이드에 오신 것을 환영합니다
|
||||
|
||||
프로젝트에 기여해 주셔서 감사합니다! 여러분의 변경 사항은 확장 프로그램의 다음 버전(또는 [웹사이트](https://www.returnyoutubedislike.com/))에 반영됩니다.
|
||||
|
||||
## 시작하기
|
||||
|
||||
코드 포매팅에는 기본 설정의 Prettier를 사용해 주세요.
|
||||
|
||||
#### 사전 준비물
|
||||
|
||||
소스의 번들 버전을 만들려면 Node와 npm이 설치되어 있어야 합니다.
|
||||
|
||||
설정 시 사용한 버전:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
이 확장 프로그램의 대부분의 비즈니스 로직을 담은 `bundled-content-script.js`를 생성하려면 먼저 모든 의존성을 설치해야 합니다.
|
||||
|
||||
1. 저장소 루트로 이동하여 다음을 실행하세요:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. `manifest.json`에서 사용하는 `bundled-content-script.js`를 생성하려면 다음 명령을 실행하세요:
|
||||
|
||||
```
|
||||
npm start // 빌드 파일을 생성하고 저장 시 자동 새로고침하는 감시자 시작
|
||||
|
||||
// 또는
|
||||
|
||||
npm run build // 빌드 파일을 한 번만 생성
|
||||
```
|
||||
|
||||
축하합니다. 이제 개발할 준비가 되었습니다!
|
||||
|
||||
Chrome 확장 프로그램 개발이 처음이거나 추가 도움이 필요하다면 [이 YouTube 튜토리얼](https://www.youtube.com/watch?v=mdOj6HYE3_0)을 참고하세요.
|
||||
|
||||
### 이슈
|
||||
|
||||
#### 새 이슈 만들기
|
||||
|
||||
확장 프로그램에 문제가 있다면, 먼저 동일한 이슈가 이미 보고되지 않았는지 검색해 주세요. 없다면 이슈를 생성해 주세요. 이슈 템플릿 사용을 권장하지만 필수는 아닙니다.
|
||||
|
||||
#### 이슈 해결하기
|
||||
|
||||
해결할 수 있을 것 같은 이슈를 찾았다면 주저하지 마세요. 수정 사항으로 PR을 열고, 해결 중인 이슈를 꼭 언급해 주세요.
|
||||
|
||||
### 기능 요청
|
||||
|
||||
#### 새 기능 요청 만들기
|
||||
|
||||
확장 프로그램에 대한 아이디어가 있다면 기능 요청을 자유롭게 열어 주세요. 다만, 이미 제안된 내용이 아닌지 먼저 검색해 주세요. 기능 템플릿 사용을 권장하지만 필수는 아닙니다.
|
||||
|
||||
#### 기능 요청 구현하기
|
||||
|
||||
구현할 수 있을 것 같은 기능을 찾았다면 주저하지 마세요. 구현 내용으로 PR을 열고, 구현하는 기능을 꼭 언급해 주세요.
|
||||
|
||||
### 어떤 PR을 수락하나요?
|
||||
|
||||
- 버그 수정
|
||||
- 기능 구현
|
||||
- 오타 수정 또는 더 좋고 간결한 표현으로 개선
|
||||
- 웹사이트 관련 기여
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
Lees dit in andere talen: [English](CONTRIBUTINGen.md), [русский](CONTRIBUTINGru.md), [Français](CONTRIBUTINGfr.md), [Türkçe](CONTRIBUTINGtr.md)
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Welkom bij de YouTube Dislikes bijdragengids
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Witamy w przewodniku współtworzenia Return YouTube Dislike
|
||||
|
||||
Dziękujemy za zainwestowanie czasu w rozwój naszego projektu! Wszystkie Twoje zmiany znajdą się w następnej wersji rozszerzenia ([bądź strony](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Początek
|
||||
|
||||
Prosimy używać Prettier z domyślnymi ustawieniami do formatowania.
|
||||
|
||||
#### Wymagania wstępne
|
||||
|
||||
Musisz mieć zainstalowane node i npm, aby utworzyć dołączoną wersję źródła.
|
||||
|
||||
Wersje używane przy ustawianiu:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Aby utworzyć `bundled-content-script.js`, które zawiera większość logiki tego rozszerzenia, musisz najpierw zainstalować wszystkie zależności.
|
||||
|
||||
1. Przejdź do korzenia tego repo i uruchom:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Użyj polecenia poniżej aby stworzyć `bundled-content-script.js`, które jest używane w `manifest.json`
|
||||
|
||||
```
|
||||
npm start // aby utworzyć plik(i) build-u i uruchomić obserwatora pliku, który przeładowuje po zapisie
|
||||
|
||||
// lub
|
||||
|
||||
npm run build // aby jednorazowo utworzyć plik(i) build-u
|
||||
```
|
||||
|
||||
Gratulacje, jesteś gotów pisać!
|
||||
|
||||
Jeśli jesteś nowy w pisaniu rozszerzeń do Chrome, lub potrzebujesz dodatkowej pomocy, obejrzyj [ten poradnik na YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Problemy
|
||||
|
||||
#### Otwieranie nowego problemu
|
||||
|
||||
Jeśli masz jakiekolwiek problemy z rozszerzeniem, najpierw wyszukaj go aby upewnić się, że dany problem nie został już zgłoszony. Jeżeli nie, otwórz problem. Używanie formularza problemu jest zalecane, ale nie jest konieczne.
|
||||
|
||||
#### Rozwiązywanie problemu
|
||||
|
||||
Jeżeli znalazłeś problem, który myślisz, że jesteś w stanie rozwiązać, nie wstydź się. Otwórz PR z fix-em i opisz problem, który naprawiasz.
|
||||
|
||||
### Prośba o funkcjonalność
|
||||
|
||||
#### Otwieranie nowej prośby o funkcjonalność
|
||||
|
||||
Jeżeli masz pomysł na rozszerzenie, śmiało otwórz nowe żądanie o funkcjonalność, ale prosimy o wyszukanie swojego pomysłu, aby upewnić się, że nie został on już zasugerowany. Używanie formularza jest zalecane, ale nie jest konieczne.
|
||||
|
||||
#### Implementacja prośby o funkcjonalność
|
||||
|
||||
Jeżeli znalazłeś pomysł na funkcjonalność, którą myślisz, że jesteś w stanie zaimplementować, nie wstydź się. Otwórz PR z fix-em, i opisz funkcjonalność, którą implementujesz.
|
||||
|
||||
### Jakie PR-y przyjmujemy?
|
||||
|
||||
- Naprawy problemów.
|
||||
- Implementacja funkcjonalności.
|
||||
- Literówki lub lepsze i łatwiejsze w zrozumieniu słowa.
|
||||
- Współtworzenie strony.
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Bem-vindo ao guia de contribuição do Return YouTube Dislikes
|
||||
|
||||
Obrigado por investir seu tempo contribuindo para o nosso projeto! Todas as mudanças serão refletidas na próxima versão da extensão (ou do [website](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Começando
|
||||
|
||||
Por favor use o Prettier com as configurações padrão de formatação.
|
||||
|
||||
#### Pré requisitos
|
||||
|
||||
Você precisa ter o node e o npm instalados para criar o pacote a partir do código-fonte.
|
||||
|
||||
Versões usadas na configuração:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Para criar o `bundled-content-script.js` que contém muita da lógica de negócio dessa extensão você precisará instalar todas as dependências primeiro.
|
||||
|
||||
1. Vá para a raiz do repositório e execute:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Execute o comando a seguir para criar o `bundled-content-script.js` que será usado no `manifest.json`
|
||||
|
||||
```
|
||||
npm start // Para criar os arquivos de build e iniciar um file watcher que recarrega o conteúdo automaticamente ao salvar
|
||||
|
||||
// ou
|
||||
|
||||
npm run build // Para criar os arquivos de build apenas uma vez
|
||||
```
|
||||
|
||||
Parabéns, agora você está pronto para desenvolver!
|
||||
|
||||
Se você é novo no desenvolvimento de extensões para o Chrome, ou precisa de ajuda extra, por favor dê uma olhada [nesse tutorial do YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Problemas(Issues)
|
||||
|
||||
#### Abrindo uma nova issue
|
||||
|
||||
Se você tiver qualquer problema com a extensão, por favor pesquise nas issues do nosso repositório para ter certeza de que o problema já não foi reportado. Se não foi, abra uma issue, usar o formulário de issues é altamente recomendado mas não é obrigatório.
|
||||
|
||||
#### Resolvendo issues
|
||||
|
||||
Se você encontrou uma issue que você sente que é capaz de corrigir, não tenha vergonha. Abra uma PR com a correção e certifique-se de mencionar a issue que você está corrigindo.
|
||||
|
||||
### Sugerir um nova funcionalidade(Feature request)
|
||||
|
||||
#### Abrindo uma feature request
|
||||
|
||||
Se você tem uma ideia de funcionalidade para a extensão, sinta-se livre para abrir uma feature request no nosso repositório, mas por favor certifique-se de que a funcionalidade já não foi sugerida. Usar o formulário de features é altamente recomendado mas não é obrigatório.
|
||||
|
||||
#### Implementando uma feature request
|
||||
|
||||
Se você encontrou uma feature request que você sente que é capaz de implementar, não tenha vergonha. Abra uma PR com a implementação e certifique-se de mencionar a feature request que você está implementando.
|
||||
|
||||
### Quais PRs nós aceitamos?
|
||||
|
||||
- Correção de problemas.
|
||||
- Implementação de funcionalidades.
|
||||
- Erros de digitação e palavras melhores ou mais fáceis de entender.
|
||||
- Contribuições para o Website.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Прочитать на других языках: [English](CONTRIBUTING.md), [Nederlands](CONTRIBUTINGnl.md), [Français](CONTRIBUTINGfr.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md)
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Добро пожаловать в руководство по внесению вклада Return YouTube Dislikes
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
Read this in other languages: [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# Välkommen till Return YouTube Dislike – bidragsguide
|
||||
|
||||
Tack för att du lägger tid på att bidra till vårt projekt! Alla dina ändringar kommer att synas i nästa version av tillägget (eller på [webbplatsen](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Kom igång
|
||||
|
||||
Använd gärna Prettier med standardinställningar för formatering.
|
||||
|
||||
#### Förutsättningar
|
||||
|
||||
Du behöver ha Node och npm installerat för att skapa den bundlade versionen av koden.
|
||||
|
||||
Versioner som användes vid uppsättning:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
För att skapa `bundled-content-script.js`, som innehåller större delen av tilläggets logik, behöver du först installera alla beroenden.
|
||||
|
||||
1. Gå till repo‑ts roten och kör:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Kör följande kommando för att skapa `bundled-content-script.js`, som används i `manifest.json`:
|
||||
|
||||
```
|
||||
npm start // skapar build‑fil(er) och startar en filbevakare som laddar om vid spara
|
||||
|
||||
// eller
|
||||
|
||||
npm run build // skapar build‑fil(er) en gång
|
||||
```
|
||||
|
||||
Grattis! Du är nu redo att utveckla.
|
||||
|
||||
Om du är ny på att utveckla Chrome‑tillägg, eller behöver extra hjälp, se [den här YouTube‑guiden](https://www.youtube.com/watch?v=mdOj6HYE3_0).
|
||||
|
||||
### Ärenden (Issues)
|
||||
|
||||
#### Skapa ett nytt ärende
|
||||
|
||||
Om du har problem med tillägget, sök först för att säkerställa att ärendet inte redan är rapporterat. Om inte, skapa ett ärende. Det rekommenderas starkt att använda ärendeformuläret, men det är inte obligatoriskt.
|
||||
|
||||
#### Lösa ett ärende
|
||||
|
||||
Om du hittat ett ärende du tror att du kan lösa, tveka inte. Öppna en PR med lösningen och nämn vilket ärende du åtgärdar.
|
||||
|
||||
### Funktionsförslag
|
||||
|
||||
#### Skapa ett nytt funktionsförslag
|
||||
|
||||
Har du en idé för tillägget? Skapa gärna ett funktionsförslag, men sök först för att se till att förslaget inte redan finns. Att använda funktionsformuläret rekommenderas men är inte obligatoriskt.
|
||||
|
||||
#### Implementera ett funktionsförslag
|
||||
|
||||
Om du hittat en funktion du kan implementera, tveka inte. Öppna en PR med implementationen och nämn vilken funktion du implementerar.
|
||||
|
||||
### Vilka PR:er accepterar vi?
|
||||
|
||||
- Buggfixar.
|
||||
- Implementering av funktioner.
|
||||
- Rättning av stavfel eller förbättrade, enklare formuleringar.
|
||||
- Bidrag till webbplatsen.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Bunu diğer dillerde okuyun: [English](CONTRIBUTING.md), [русский](CONTRIBUTINGru.md), [Nederlands](CONTRIBUTINGnl.md), [Français](CONTRIBUTINGfr.md), [українська](CONTRIBUTINGuk.md)
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
# "YouTube Dislike Sayısını Geri Getir"in katkı kılavuzuna Hoş Geldiniz
|
||||
|
||||
|
||||
+68
-67
@@ -1,67 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [русский](CONTRIBUTINGru.md), [Français](CONTRIBUTINGfr.md), [Türkçe](CONTRIBUTINGtr.md)
|
||||
|
||||
# Вітаємо у посібнику внеску в Return YouTube Dislikes
|
||||
|
||||
Дякуємо, що вкладаєте свій час у розвиток нашого проєкту! Усі ваші зміни буде відображено в наступній версії розширення (або ж [вебсайту](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Почнімо працювати
|
||||
|
||||
Будь ласка, використовуйте Prettier із налаштуваннями за замовчуванням для форматування коду.
|
||||
|
||||
#### Заздалегідь
|
||||
|
||||
Вам потрібно встановити node і npm, щоб створити bundled версію джерела.
|
||||
|
||||
Версії, що використовувались при налаштуванні:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Для створення `bundled-content-script.js`, який містить більшу частину бізнес-логіки цього розширення, спочатку потрібно встановити всі залежності.
|
||||
|
||||
1. Перейдіть в корінь репозиторію та виконайте наступне:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Виконайте наступну команду, щоб створити `bundled-content-script.js`, який використовується в `manifest.json`
|
||||
|
||||
```
|
||||
npm start // для створення файлу(ів) збірки та запуску спостерігача за файлами, який виконує hot-reload при збереженні
|
||||
|
||||
// або ж
|
||||
|
||||
npm run build // для створення файлу(ів) збірки один раз
|
||||
```
|
||||
|
||||
Вітаємо, тепер ви готові до розробки!
|
||||
|
||||
Якщо ви новачок у розробці розширення Chrome або вам потрібна додаткова допомога, перегляньте [це керівництво на YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0) (англ.)
|
||||
|
||||
### Проблеми
|
||||
|
||||
#### Відкриття нової проблеми
|
||||
|
||||
Якщо у вас виникли проблеми з розширенням, здійсніть пошук і переконайтеся, що про цю проблему ще не повідомляли. Якщо ні, створіть Issue, використання форми проблеми наполегливо рекомендується, але не є обов’язковим.
|
||||
|
||||
#### Вирішення проблеми
|
||||
|
||||
Якщо ви знайшли проблему, і гадаєте, що можете її вирішити, не соромтеся. Створіть Pull Request на вилучення з виправленням і обов'язково вкажіть проблему, яку ви усуваєте.
|
||||
|
||||
### Запит функції
|
||||
|
||||
#### Відкриття запиту на нову функцію
|
||||
|
||||
Якщо у вас є ідея щодо розширення, не соромтеся створіть Pull Request, але, будь ласка, здійсніть пошук і переконайтеся, що ця функція ще не запропонована. Використання форми функції наполегливо рекомендується, але не є обов’язковим.
|
||||
|
||||
#### Реалізація запиту функції
|
||||
|
||||
Якщо ви знайшли функцію, і гадаєте, що можете її реалізувати, не соромтеся. Створіть Pull Request із виправленням і обов’язково вкажіть функцію, яку ви впроваджуєте.
|
||||
|
||||
### Які Pull Request ми приймаємо?
|
||||
|
||||
- Виправлення проблем.
|
||||
- Впровадження нових функцій.
|
||||
- Виправлення помилок та спрощення тексту.
|
||||
- Поліпшення сайту.
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Вітаємо у посібнику внеску в Return YouTube Dislikes
|
||||
|
||||
Дякуємо, що вкладаєте свій час у розвиток нашого проєкту! Усі ваші зміни буде відображено в наступній версії розширення (або ж [вебсайту](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Почнімо працювати
|
||||
|
||||
Будь ласка, використовуйте Prettier із налаштуваннями за замовчуванням для форматування коду.
|
||||
|
||||
#### Заздалегідь
|
||||
|
||||
Вам потрібно встановити node і npm, щоб створити bundled версію джерела.
|
||||
|
||||
Версії, що використовувались при налаштуванні:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Для створення `bundled-content-script.js`, який містить більшу частину бізнес-логіки цього розширення, спочатку потрібно встановити всі залежності.
|
||||
|
||||
1. Перейдіть в корінь репозиторію та виконайте наступне:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Виконайте наступну команду, щоб створити `bundled-content-script.js`, який використовується в `manifest.json`
|
||||
|
||||
```
|
||||
npm start // для створення файлу(ів) збірки та запуску спостерігача за файлами, який виконує hot-reload при збереженні
|
||||
|
||||
// або ж
|
||||
|
||||
npm run build // для створення файлу(ів) збірки один раз
|
||||
```
|
||||
|
||||
Вітаємо, тепер ви готові до розробки!
|
||||
|
||||
Якщо ви новачок у розробці розширення Chrome або вам потрібна додаткова допомога, перегляньте [це керівництво на YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0) (англ.)
|
||||
|
||||
### Проблеми
|
||||
|
||||
#### Відкриття нової проблеми
|
||||
|
||||
Якщо у вас виникли проблеми з розширенням, здійсніть пошук і переконайтеся, що про цю проблему ще не повідомляли. Якщо ні, створіть Issue, використання форми проблеми наполегливо рекомендується, але не є обов’язковим.
|
||||
|
||||
#### Вирішення проблеми
|
||||
|
||||
Якщо ви знайшли проблему, і гадаєте, що можете її вирішити, не соромтеся. Створіть Pull Request на вилучення з виправленням і обов'язково вкажіть проблему, яку ви усуваєте.
|
||||
|
||||
### Запит функції
|
||||
|
||||
#### Відкриття запиту на нову функцію
|
||||
|
||||
Якщо у вас є ідея щодо розширення, не соромтеся створіть Pull Request, але, будь ласка, здійсніть пошук і переконайтеся, що ця функція ще не запропонована. Використання форми функції наполегливо рекомендується, але не є обов’язковим.
|
||||
|
||||
#### Реалізація запиту функції
|
||||
|
||||
Якщо ви знайшли функцію, і гадаєте, що можете її реалізувати, не соромтеся. Створіть Pull Request із виправленням і обов’язково вкажіть функцію, яку ви впроваджуєте.
|
||||
|
||||
### Які Pull Request ми приймаємо?
|
||||
|
||||
- Виправлення проблем.
|
||||
- Впровадження нових функцій.
|
||||
- Виправлення помилок та спрощення тексту.
|
||||
- Поліпшення сайту.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
Read this in other languages: [English](CONTRIBUTING.md), [العربية](CONTRIBUTINGar.md), [Azərbaycan dili](CONTRIBUTINGaz.md), [български](CONTRIBUTINGbg.md), [中文](CONTRIBUTINGcn.md), [Dansk](CONTRIBUTINGda.md), [Deutsch](CONTRIBUTINGde.md), [Español](CONTRIBUTINGes.md), [Français](CONTRIBUTINGfr.md), [Ελληνικά](CONTRIBUTINGgr.md), [Magyar](CONTRIBUTINGhu.md), [Bahasa Indonesia](CONTRIBUTINGid.md), [日本語](CONTRIBUTINGja.md), [한국어](CONTRIBUTINGkr.md), [Nederlands](CONTRIBUTINGnl.md), [Polski](CONTRIBUTINGpl.md), [Português do Brasil](CONTRIBUTINGpt_BR.md), [русский](CONTRIBUTINGru.md), [Svenska](CONTRIBUTINGsv.md), [Türkçe](CONTRIBUTINGtr.md), [українська](CONTRIBUTINGuk.md), [Tiếng Việt](CONTRIBUTINGvi.md)
|
||||
|
||||
|
||||
# Chào mừng tới Hướng dẫn Đóng góp của Return YouTube Dislike (Trả lại số lượt Không thích trên YouTube) <!-- # Welcome To Return YouTube Dislikes contributing guide -->
|
||||
|
||||
Cảm ơn bạn đã dành thời gian để đóng góp cho dự án của chúng tôi! Tất cả các thay đổi của bạn sẽ được hiển thị trong phiên bản tiếp theo của tiện ích mở rộng này (hoặc của [trang mạng](https://www.returnyoutubedislike.com/)).
|
||||
|
||||
## Bắt đầu <!-- ## Getting Started -->
|
||||
|
||||
Hãy dùng [Prettier](https://prettier.io/) với thiết lập mặc định để định dạng mã.
|
||||
|
||||
#### Yêu cầu sơ bộ <!-- ### Prerequisites -->
|
||||
|
||||
Bạn cần phải cài **node** và **npm** để tạo bản đóng gói của mã nguồn.
|
||||
|
||||
Các phiên bản được dùng khi cài đặt:
|
||||
|
||||
- node: 12.18.4
|
||||
- npm: 6.14.6
|
||||
|
||||
Dể có thể tạo tệp `bundled-content-script.js`, trong đó có chứa hầu hết các lôgic kinh doanh của tiện ích mở rộng này, trước tiên bạn phải cài các đối tượng phụ thuộc.
|
||||
|
||||
1. Tới thư mục gốc của kho mã nguồn và chạy lệnh:
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Chạy lệnh dưới đây để tạo `bundled-content-script.js`, sẽ dùng tới trong `manifest.json`
|
||||
|
||||
```
|
||||
npm start // để tạo (các) tệp xây dựng và khởi chạy một trình quan sát tập tin, đảm nhiệm việc tự động tải lại dự án khi có thay đổi được lưu
|
||||
|
||||
// hoặc
|
||||
|
||||
npm run build // để tạo (các) tệp xây dựng chỉ một lần
|
||||
```
|
||||
|
||||
Chúc mừng! Bạn đã sẵn sàng để phát triển chương trình!
|
||||
|
||||
Nếu bạn chưa bao giờ phát triển tiện ích mở rộng cho Chrome hoặc cần sự trợ giúp, hãy xem [hướng dẫn này trên YouTube](https://www.youtube.com/watch?v=mdOj6HYE3_0)
|
||||
|
||||
### Vấn đề <!-- ### Issues -->
|
||||
|
||||
#### Tạo một vấn đề mới <!-- #### Opening a new issue -->
|
||||
|
||||
Nếu bạn có bất kì vấn đề gì với tiện ích mở rộng này, trước tiên hãy đọc qua danh sách các vấn đề đang có. Nếu vấn đề của bạn không có trong danh sách các vấn đề, hãy [tạo một vấn đề](https://github.com/Anarios/return-youtube-dislike/issues/new?assignees=&labels=bug&template=bug.yml&title=%28Bug%29%3A+). Dùng mẫu đơn vấn đề nếu có thể, nhưng không bắt buộc.
|
||||
|
||||
#### Giải quyết một vấn đề <!-- #### Solving an issue -->
|
||||
|
||||
Nếu bạn cảm thấy có thể giải quyết một vấn đề nào đó, đừng ngần ngại. Hãy tạo một [yêu cầu kéo](https://github.com/Anarios/return-youtube-dislike/pulls) cho sự thay đổi của bạn và nhớ hãy ghi tên lỗi mà bạn giải quyết.
|
||||
|
||||
### Yêu cầu Tính năng <!-- ### Feature Request -->
|
||||
|
||||
#### Tạo một yêu cầu tính năng mới <!-- #### Opening a new feature request -->
|
||||
|
||||
Nếu bạn có một ý tưởng dành cho tiện ích mở rộng này, hãy mạnh dạn [tạo một yêu cầu chức năng](https://github.com/Anarios/return-youtube-dislike/issues/new?assignees=&labels=enhancement&template=feature-request.yml&title=%28Feature+Request%29%3A+), nhưng hãy chắc rằng bạn đã tìm và không thấy yêu cầu tính năng y hệt trong danh sách yêu cầu tính năng. Dùng mẫu đơn yêu cầu tính năng nếu có thể, nhưng không bắt buộc.
|
||||
|
||||
#### Thực hiện một yêu cầu tính năng <!-- #### Implementing a feature request -->
|
||||
|
||||
Nếu bạn cảm thấy có thể thực hiện một tính năng nào đó, đừng ngần ngại. Hãy tạo một [yêu cầu kéo](https://github.com/Anarios/return-youtube-dislike/pulls) cho sự thay đổi của bạn và nhớ hãy ghi tên tính năng mà bạn thực hiện.
|
||||
|
||||
### Những yêu cầu kéo mà chúng tôi chấp nhận? <!-- ### What PRs do we accept? -->
|
||||
|
||||
- Giải quyết vấn đề.
|
||||
- Thực hiện tính năng.
|
||||
- Sửa lỗi chính tả hoặc đề xuất cách dùng từ tốt hơn.
|
||||
- Đóng góp cho trang mạng.
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
Read this in other languages: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md),[Türkçe](FAQtr.md), [українська](FAQuk.md)
|
||||
Read this in other languages: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
|
||||
# Frequently Asked Questions
|
||||
@@ -17,7 +17,7 @@ We save all available data to our DB for it to be available after Google shuts d
|
||||
|
||||
### **2. Video dislike count doesn't update**
|
||||
|
||||
Right now video dislikes are cached, and aren't updated very frequenly. Once every 2-3 days, not more often.
|
||||
Right now video dislikes are cached, and aren't updated very frequently. Once every 2-3 days, not more often.
|
||||
|
||||
Yeah, it's not ideal, but it is what it is. Working on improving how often we can update them.
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
اقرأ هذا بلغات أخرى: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
|
||||
|
||||
# الأسئلة الشائعة
|
||||
|
||||
## قبل طرح سؤال على GitHub أو Discord، يرجى الرجوع إلى هذا.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. من أين تحصل هذه الإضافة على البيانات؟**
|
||||
|
||||
مزيج من واجهات برمجة تطبيقات Google والبيانات المستخرجة.
|
||||
|
||||
نحن نحفظ جميع البيانات المتاحة في قاعدة بياناتنا لتكون متاحة بعد أن تقوم Google بإيقاف عداد عدم الإعجاب في واجهة برمجة التطبيقات الخاصة بهم.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. عداد عدم الإعجاب بالفيديو لا يتم تحديثه**
|
||||
|
||||
حاليًا، يتم تخزين عدم الإعجاب بالفيديو مؤقتًا، ولا يتم تحديثه بشكل متكرر. مرة كل 2-3 أيام، وليس أكثر.
|
||||
|
||||
نعم، إنه ليس مثاليًا، لكنه ما هو عليه. نعمل على تحسين عدد مرات التحديث.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. كيف يعمل هذا؟**
|
||||
|
||||
تجمع الإضافة معرف الفيديو الذي تشاهده، وتسترجع عدم الإعجاب (وحقول أخرى مثل المشاهدات، الإعجابات، إلخ) باستخدام واجهة برمجة التطبيقات الخاصة بنا، إذا كانت هذه هي المرة الأولى التي يتم فيها استرجاع الفيديو بواسطة واجهة برمجة التطبيقات الخاصة بنا، فستستخدم واجهة برمجة تطبيقات YouTube للحصول على البيانات، ثم تخزن البيانات في قاعدة بيانات للتخزين المؤقت (مخزنة مؤقتًا لمدة 2-3 أيام) ولأغراض الأرشفة وتعيدها إليك. ثم تعرض الإضافة عدم الإعجاب لك.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. ماذا سيحدث بعد أن تتوقف واجهة برمجة تطبيقات YouTube عن إرجاع عداد عدم الإعجاب؟**
|
||||
|
||||
سيتحول الخلفية إلى استخدام مزيج من إحصائيات عدم الإعجاب المؤرشفة، والتقديرات المستخلصة من بيانات مستخدمي الإضافة والتقديرات بناءً على نسب المشاهدة/الإعجاب للفيديوهات التي لم يتم أرشفة عدم الإعجاب بها ولأرشيفات عدم الإعجاب القديمة.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. كيف يتم حساب عداد عدم الإعجاب؟**
|
||||
|
||||
تستخدم RYD أصوات مستخدميها لاستخلاص عداد عدم الإعجاب.
|
||||
|
||||
- إذا تم تحميل الفيديو بعد إيقاف واجهة برمجة التطبيقات:
|
||||
|
||||
$$ \textup{عداد عدم الإعجاب في RYD} = \left( \frac{\textup{عداد عدم الإعجاب لمستخدمي RYD}}{\textup{عداد الإعجاب لمستخدمي RYD}} \right) \times \textup{عداد الإعجاب العام} $$
|
||||
|
||||
- إذا كانت قاعدة بيانات RYD تحتوي بطريقة ما على عداد الإعجاب وعدم الإعجاب الفعلي (مقدم من الناشر أو من الأرشيف)، فسيتم حساب عداد عدم الإعجاب بناءً على كل من - أصوات المستخدمين والقيمة المؤرشفة. سيكون للقيمة المؤرشفة تأثير أقل على العد النهائي مع مرور الوقت.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
هذا في شكل فيديو
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## لدي مخاوف بشأن الأمان / الخصوصية
|
||||
|
||||
انظر [هذه الصفحة](SECURITY-FAQ.md) لمزيد من المعلومات.
|
||||
@@ -0,0 +1,57 @@
|
||||
Bunu digər dillərdə oxuyun: [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md)
|
||||
|
||||
# Tez-tez verilən suallar
|
||||
|
||||
## GitHub və ya Discord-da sual verməzdən əvvəl bura nəzər salın.
|
||||
|
||||
<br>
|
||||
|
||||
### **1.Bu uzantı məlumatları haradan əldə edir??**
|
||||
|
||||
Google API-lərin və silinmiş məlumatların birləşməsi.Google API-lərin və silinmiş məlumatların birləşməsi.
|
||||
|
||||
Google öz API-də bəyənməmə saylarını bağladıqdan sonra istifadə oluna bilməsi üçün bütün mövcud məlumatları verilənlər bazamızda saxlayırıq.
|
||||
<br>
|
||||
|
||||
### **2. Videonun bəyənməmə sayı yenilənmir**
|
||||
|
||||
Hazırda videonun bəyənməmələri keşlənir və tez-tez yenilənmir. Hər 2-3 gündə, daha tez-tez deyil.
|
||||
|
||||
Bəli, bu ideal deyil, amma olduğu kimidir. Biz bunları daha tez-tez necə yeniləyə biləcəyimizi anlamaq üzərində işləyirik.
|
||||
<br>
|
||||
|
||||
### **3. Bu uzantı necə işləyir?**
|
||||
|
||||
Artırma bizim API-dən istifadə edərək baxdığınız videonun video identifikatorunu götürür, bəyənmədiklərini (və baxışlar, bəyənmələr və s. kimi digər sahələri) alır; video API tərəfindən ilk dəfə alınırsa, YouTube API-dən istifadə edir. Məlumatları əldə etmək üçün o, keşləmə (təxminən 2-3 gün ərzində yaddaşda saxlanılır) və arxivləşdirmə məqsədləri üçün verilənlər bazasında saxlanılır və sizə qaytarılır. Daha sonra uzantı sizə bəyənmədiyinizləri göstərir.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. YouTube API bəyənməmə saylarını qaytarmağı dayandırdıqda nə baş verir?**
|
||||
|
||||
Backend arxivləşdirilmiş bəyənməmə statistikası, genişləndirmə istifadəçi məlumatlarından ekstrapolyasiya edilmiş təxminlər və arxivləşdirilməmiş videolar və köhnə bəyənməmə arxivləri üçün baxış/bəyənmə nisbətlərinə əsaslanan təxminlərin birləşməsindən istifadə etməyə keçəcək.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Bəyənməmələrin sayı necə hesablanır?**
|
||||
|
||||
YDS bəyənməmələrin sayını hesablamaq üçün istifadəçilərinin səslərindən istifadə edir.
|
||||
|
||||
- Video API bağlandıqdan sonra yüklənibsə:
|
||||
|
||||
$$ \textup{YDS Bəyənməmə Sayısı} = \left( \frac{\textup{YDS İstifadəçiləri Saymağı Bəyənirlər}}{\textup{YDS İstifadəçilərinin Bəyənmədiyi Say}} \right) \times \textup{İctimai Bəyənmə Sayısı} $$
|
||||
|
||||
- Əgər YDS verilənlər bazasında hansısa şəkildə real bəyənmə və bəyənməmə sayları varsa (yükləyən və ya arxivdən təqdim olunur), bəyənməmə sayı həm istifadəçilərin səsləri, həm də arxivləşdirilmiş dəyər əsasında hesablanacaq. Arxivləşdirilmiş dəyər yaşlandıqca son hesaba daha az təsir edəcək.
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Bu video formada
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Məxfilik/təhlükəsizliklə bağlı narahatlıqlarım var
|
||||
Ətraflı məlumat üçün [bu səyfə](SECURITY-FAQtr.md)ya baxın.
|
||||
@@ -0,0 +1,62 @@
|
||||
Прочетете това на други езици: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md)
|
||||
|
||||
|
||||
# Често задавани въпроси
|
||||
|
||||
## Преди да зададете въпрос в GitHub или Discord, моля, обърнете се към това.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Откъде взема разширението тези данни?**
|
||||
|
||||
Комбинация от Google APIs и скрейпнати данни.
|
||||
|
||||
Запазваме всички налични данни в нашата база данни, за да бъдат налични след като Google затвори броя на дизлайковете в техния API.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Броят на дизлайковете на видеото не се обновява**
|
||||
|
||||
В момента дизлайковете на видеата се кешират и не се обновяват много често. Веднъж на всеки 2-3 дни, не по-често.
|
||||
|
||||
Да, не е идеално, но каквото, такова. Работим по подобрение на това, колко често можем да ги актуализираме.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. HКак работи това?**
|
||||
|
||||
Разширението взима video id на видеото, което гледате, извлича дизлайковете (и други полета като гледания, харесвания и т.н.) чрез нашия API. Ако това е първият път, когато видеото беше извлечено от нашия API, той ще използва YouTube API, за да получи данните, след което ще ги запази в база данни за кеширане (кеширане за около 2-3 дни) и архивиране и ще ги върне. Разширението след това ви показва дизлайковете.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Какво ще стане, след като YouTube API спре да връща броя на дизлайковете?**
|
||||
|
||||
Бекендът ще премине към използване на комбинация от архивирани статистики за дизлайкове, оценки екстраполирани от данните на потребителите на разширението и оценки на базата на съотношението гледания/харесвания за видеа, чиито дизлайкове не са били архивирани и за стари архиви с дизлайкове.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Как се изчислява броят на дизлайковете?**
|
||||
|
||||
RYD използва гласовете от своите потребители, за да екстраполира броя на дизлайковете.
|
||||
|
||||
- Ако видеото е качено след като API е затворил:
|
||||
|
||||
$$ \textup{RYD Брой на дизлайковете} = \left( \frac{\textup{RYD Брой на дизлайковете от потребителите}}{\textup{RYD Брой на харесвания от потребителите}} \right) \times \textup{Общ брой харесвания} $$
|
||||
|
||||
- Ако базата данни на RYD по някакъв начин има реалния брой на харесвания и дизлайкове (предоставени от качителя или от архива), броят на дизлайковете ще бъде изчислен на базата и на гласовете на потребителите, и на архивната стойност. Архивната стойност ще има по-малко влияние върху крайния брой, докато остарее.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Това във видео форма
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Имам опасения относно сигурността / личните данни
|
||||
|
||||
Вижте [тази страница](SECURITY-FAQbg.md) за повече информация.
|
||||
@@ -0,0 +1,62 @@
|
||||
以其他语言阅读: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
|
||||
|
||||
|
||||
# 常见问题
|
||||
|
||||
## 在 GitHub 或 Discord 上提问之前,请先参阅此内容.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. 此扩展程序从哪里获取数据?**
|
||||
|
||||
Google API 和抓取数据的组合。
|
||||
|
||||
我们将所有可用数据保存到我们的数据库中,以便 Google 关闭其 API 中的不喜欢计数后仍可用。
|
||||
|
||||
<br>
|
||||
|
||||
### **2. 视频不喜欢计数未更新**
|
||||
|
||||
目前,视频的点赞信息会被缓存,更新频率不高。每 2-3 天更新一次,不会更频繁。
|
||||
|
||||
这并不理想,但这就是现状。我们正在努力提高更新频率。
|
||||
|
||||
<br>
|
||||
|
||||
### **3. 这是如何运作的?**
|
||||
|
||||
该扩展程序会收集您正在观看的视频的视频 ID,使用我们的 API 获取不喜欢的内容(以及其他字段,如观看次数、喜欢次数等),如果这是我们的 API 首次获取该视频,它将使用 YouTube API 获取数据,然后将数据存储在数据库中以供缓存(缓存约 2-3 天)和存档,并将其返回给您。然后,该扩展程序会向您显示不喜欢的内容。
|
||||
|
||||
<br>
|
||||
|
||||
### **4. YouTube API 停止返回不喜欢计数后会发生什么?**
|
||||
|
||||
后端将切换到使用已存档的不喜欢统计数据、从扩展用户数据推断出的估计值以及基于未存档的不喜欢的视频和过时的不喜欢存档的观看/喜欢比率的估计值的组合。
|
||||
|
||||
<br>
|
||||
|
||||
### **5. 如何计算不喜欢数?**
|
||||
|
||||
RYD 使用用户的投票来推断不喜欢的数量。
|
||||
|
||||
- 如果视频是在 API 关闭后上传的:
|
||||
|
||||
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
|
||||
|
||||
- 如果 RYD 数据库以某种方式拥有实际的喜欢和不喜欢计数(由上传者提供或来自存档),则不喜欢计数将根据用户的投票和存档值计算。存档值随着时间的推移对最终计数的影响会越来越小。
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
这是视频形式
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## I have security / privacy concerns
|
||||
|
||||
有关详细信息,请参阅[此页面](SECURITY-FAQ.md)。
|
||||
@@ -0,0 +1,61 @@
|
||||
Read this in other languages: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md)
|
||||
|
||||
# Häufig gestellte Fragen
|
||||
|
||||
## Bevor Sie eine Frage auf GitHub oder Discord stellen, sehen Sie bitte hier nach.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Woher stammt die Datenquelle dieser Erweiterung?**
|
||||
|
||||
Eine Kombination aus Google-APIs und gescrapten Daten.
|
||||
|
||||
Wir speichern alle verfügbaren Daten in unserer Datenbank, damit sie verfügbar sind, nachdem Google die Anzahl der Dislikes in ihrer API abschaltet.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Die Anzahl der Dislikes bei Videos aktualisiert sich nicht**
|
||||
|
||||
Aktuell werden die Dislikes bei Videos zwischengespeichert und nicht sehr häufig aktualisiert. Etwa alle 2-3 Tage, nicht öfter.
|
||||
|
||||
Ja, das ist nicht ideal, aber es ist wie es ist. Wir arbeiten daran, wie oft wir sie aktualisieren können, zu verbessern.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Wie funktioniert das?**
|
||||
|
||||
Die Erweiterung sammelt die Video-ID des von Ihnen angesehenen Videos, ruft die Dislikes (und andere Felder wie Aufrufe, Likes usw.) über unsere API ab. Wenn dies das erste Mal ist, dass das Video von unserer API abgerufen wurde, wird die YouTube-API verwendet, um die Daten zu erhalten. Anschließend werden die Daten für den Zwischenspeicher (ca. 2-3 Tage zwischengespeichert) und Archivierungszwecke in einer Datenbank gespeichert und an Sie zurückgegeben. Die Erweiterung zeigt Ihnen dann die Dislikes an.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Was passiert, wenn die YouTube-API aufhört, die Anzahl der Dislikes zurückzugeben?**
|
||||
|
||||
Das Backend wird auf eine Kombination aus archivierten Dislike-Statistiken, Schätzungen, die aus den Daten der Erweiterungsnutzer extrapoliert werden, und Schätzungen basierend auf Ansichts-/Like-Verhältnissen für Videos, deren Dislikes nicht archiviert wurden, und veralteten Dislike-Archiven umstellen.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Wie wird die Anzahl der Dislikes berechnet?**
|
||||
|
||||
RYD verwendet die Stimmen seiner Benutzer, um die Anzahl der Dislikes zu extrapolieren.
|
||||
|
||||
- Wenn das Video nach dem Abschalten der API hochgeladen wurde:
|
||||
|
||||
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
|
||||
|
||||
- Wenn die RYD-Datenbank auf irgendeine Weise die tatsächliche Anzahl von Likes und Dislikes enthielt (vom Ersteller bereitgestellt oder aus dem Archiv), wird die Anzahl der Dislikes basierend auf beiden - den Stimmen der Benutzer und dem archivierten Wert - berechnet. Der archivierte Wert wird mit zunehmendem Alter weniger Einfluss auf die endgültige Zählung haben.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Dies in Videoform
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Ich habe Sicherheits-/Privatsphärebedenken
|
||||
|
||||
Siehe [diese Seite](SECURITY-FAQde.md) für weitere Informationen.
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
Lisez ceci dans d'autres langues : [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md)
|
||||
|
||||
Lisez ceci dans d'autres langues : [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Foire Aux Questions
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
Baca ini dibahasa lain: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [Português do Brasil](FAQpt_BRmd)
|
||||
|
||||
|
||||
# Pertayaan yang Sering Ditanyakan
|
||||
|
||||
## Sebelum bertanya di GitHub atau Discord, tolong lihat halaman ini.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Darimana extension ini mendapatkan datanya?**
|
||||
|
||||
Kombinasi dari API Google dan scraping data.
|
||||
|
||||
Kami telah menyimpan semua data yang ada kedalam DB agar datanya tetap ada meskipun setelah Google menghapus jumlah dislike dari API mereka.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Jumlah dislike video tidak update**
|
||||
|
||||
|
||||
Sekarang jumlah dislike video dicache, dan tidak update selalu.
|
||||
Sekali setiap 2-3 hari, tidak lebih dari itu.
|
||||
|
||||
Benar, itu tidak ideal, tapi inilah yang bisa kami lakukan dan kami sedang melakukan improvisasi agar dapat melakukan update sesering mungkin.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Bagaimana cara kerjanya?**
|
||||
|
||||
Extension mengumpulkan id dari video yang sedang ditonton, mengambil data dislike (dan lainnya seperti jumlah views, likes, dll) menggunakan API kami, jika video ini pertama kali diambil datanya menggunakan API kami, API yang digunakan akan diubah ke API Youtube untuk mengambil datanya dan datanya akan disimpan kedalam database kami untuk dilakukan cache (untuk 2-3 hari) yang bertujuan untuk pengarsipan data dan datanya dikembalikan ke kamu. Lalu extension akan menampilkan jumlah dislike ke kamu.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Apa yang akan terjadi setelah API Youtube menghapus jumlah dislike?**
|
||||
|
||||
Bagian backend akan berganti dengan menggunakan kombinasi dari data arsip dislike, estimasi perkiraan dari pengguna, dan estimasi berdasarkan jumlah rasio view/like untuk video yang dislikenya tidak diarsip dan arsip dislike yang sudah tua.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Bagaimana cara menghitung jumlah dislike?**
|
||||
|
||||
RYD mernggunakan sistem voting dari pengguna untuk memperkirakan jumlah dislike.
|
||||
|
||||
- Jika video diupload setelah API dislike dihapus:
|
||||
|
||||
$$ \textup{Jumlah RYD Dislike} = \left( \frac{\textup{Jumlah RYD Dislike Pengguna}}{\textup{Jumlah RYD Like Pengguna}} \right) \times \textup{Jumlah Public Like Publik} $$
|
||||
|
||||
- Jika entah bagaimana Database RYD memiliki data jumlah asli dari like dan dislike (diberikan dari pengupload atau arsip), jumlah dislike akan dihitung berdasarkan jumlah voting dari pengguna dan arsip. Semakin tua data arsip maka semakin sedikit pengaruhnya ke hasil akhir perhitungan dislike.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Berikut adalah form video
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Saya memiliki masalah keamanan/privasi
|
||||
|
||||
Lihat [halaman ini](SECURITY-FAQid.md) untuk informasi lebih lanjut.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Lees dit in andere talen: [Engels](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md)
|
||||
Lees dit in andere talen: [Engels](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Veel Gestelde Vragen
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
Read this in other languages: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Często zadawane pytania
|
||||
|
||||
## Przeczytaj poniższe przed zadawaniem pytań na GitHubie lub Discordzie.
|
||||
|
||||
<br>
|
||||
|
||||
### **1.Skąd rozszerzenie otrzymuje swoje dane?**
|
||||
|
||||
Kombinacja API Google i danych scrape-owanych.
|
||||
|
||||
Zapisujemy wszystkie dostępne dane do naszej bazy danych, żeby były dostępne po tym jak Google wyłączy liczniki łapek w dół w swoim API.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Licznik łapek w dół się nie aktualizuje**
|
||||
|
||||
Na chwile obecną łapki w dół są buforowane i nie są bardzo często aktualizowane. Raz na 2-3 dni, nie częściej.
|
||||
|
||||
No nie jest to idealne, ale tak już jest. Pracujemy nad tym jak częściej możemy to aktualizować.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Jak to działa?**
|
||||
|
||||
Rozszerzenie zbiera ID filmu, którego oglądasz, pobiera ilość łapek w dół (i inne pola, takie jak wyświetlenia, łapki w górę itd.) za pomocą naszego API. Jeżeli film jest pierwszy raz pobrany przez nasze API, zostanie użyte YouTube API do pobrania danych, a potem przechowania w bazie danych do buforu (przez jakieś 2-3 dni) i archiwizacji, po czym zwracane jest Tobie. Rozszerzenie potem wyświetla ilość łapek w dół.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Co się wydarzy gdy YouTube API przestanie zwracać liczbę łapek w dół?**
|
||||
|
||||
Backend przełączy się na używanie kombinacji zarchiwizowanych statystyk łapek w dół, szacunków ekstrapolowanych z danych użytkowników rozszerzenia i szacowań opartych na stosunkach wyświetleń/łapek w górę dla filmów, których ilość łapek w dół nie została zarchiwizowana i dla przestarzałych archiwów.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Jak wyliczana jest liczba łapek w dół?**
|
||||
|
||||
RYD używa głosów użytkowników, aby ekstrapolować liczbę łapek w dół.
|
||||
|
||||
- Jeżeli film został wrzucony przed wyłączeniem API:
|
||||
|
||||
$$ \textup{Liczba łapek w dół RYD} = \left( \frac{\textup{Liczba łapek w dół użytkowników RYD}}{\textup{Liczba łapek w górę użytkowników RYD}} \right) \times \textup{Publiczna liczba łapek w górę} $$
|
||||
|
||||
- Jeśli baza danych RYD jakimś cudem miałaby prawidłową liczbę łapek w górę i dół (zapewnione przez twórce lub z archiwum), liczba łapek w dół będzie obliczana w oparciu jednocześnie głosów użytkowników i wartości zarchiwizowanych. Zarchiwizowana wartość będzie miała mniejszy wpływ na ostateczną liczbę z biegiem czasu.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
To samo w formie filmu.
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Mam zastrzeżenia co do bezpieczeństwa / prywatności
|
||||
|
||||
Przejdź [tutaj](SECURITY-FAQ.md) aby uzyskać więcej informacji.
|
||||
@@ -0,0 +1,61 @@
|
||||
Leia isso em outros Idiomas: [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md)
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## Before asking a question on GitHub or Discord, please refer to this.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Where does this extension get the data?**
|
||||
|
||||
A Combination of Google APIs and scraped data.
|
||||
|
||||
We save all available data to our DB for it to be available after Google shuts down dislike counts in their API.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Video dislike count doesn't update**
|
||||
|
||||
Right now video dislikes are cached, and aren't updated very frequenly. Once every 2-3 days, not more often.
|
||||
|
||||
Yeah, it's not ideal, but it is what it is. Working on improving how often we can update them.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. How does this work?**
|
||||
|
||||
The extension collects the video id of the video you are watching, fetches the dislike (and other fields like views, likes etc) using our API, if this is the first time the video was fetched by our API, it will use the YouTube API to get the data, then stores the data in a database for caching (cached for around 2-3 days) and archiving purposes and returns it to you. The extension then displays the dislikes to you.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. What will happen after the YouTube API stops returning the dislike count?**
|
||||
|
||||
The backend will switch to using a combination of archived dislike stats, estimates extrapolated from extension user data and estimates based on view/like ratios for videos whose dislikes weren't archived and for outdated dislike archives.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. How is the dislike count calculated?**
|
||||
|
||||
RYD uses the votes from its users to extrapolate the dislike count.
|
||||
|
||||
- If the video was uploaded after the API was shut down:
|
||||
|
||||
$$ \textup{RYD Dislike Count} = \left( \frac{\textup{RYD Users Dislike Count}}{\textup{RYD Users Like Count}} \right) \times \textup{Public Like Count} $$
|
||||
|
||||
- If the RYD database somehow had the actual like and dislike count (provided by the uploader or from the archive), the dislike count will be calculated based on both - the users' votes and the archived value. The archived value will have less influence on the final count as it ages.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Essa a This in video form
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## I have security / privacy concerns
|
||||
|
||||
See [this page](SECURITY-FAQpt_BR.md) for more info.
|
||||
+6
-2
@@ -1,25 +1,29 @@
|
||||
Read this in other languages: [English](FAQ.md), [Nederlands](FAQnl.md), Français](FAQfr.md), [Türkçe](FAQtr.md), [українська](FAQuk.md)
|
||||
|
||||
Read this in other languages: [English](FAQ.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Часто задаваемые вопросы
|
||||
|
||||
## Прежде чем задать вопрос на GitHub или в Discord, пожалуйста, ознакомьтесь с этим.
|
||||
|
||||
### **1. Откуда это расширение получает данные?**
|
||||
|
||||
Комбинация API Google и старых данных.
|
||||
|
||||
Мы сохраняем все имеющиеся данные в нашей базе данных, чтобы они были доступны после того, как Google прекратит подсчёт отметок «Не нравится» в своём API.
|
||||
|
||||
### **2. Количество не понравившихся видео не обновляется**
|
||||
|
||||
В настоящее время видео с отметками «Не нравится» кэшируются и обновляются не очень часто. Раз в 2-3 дня, не чаще.
|
||||
|
||||
Да, это не идеально, но это то, что есть. Мы работаем над тем, чтобы улучшить частоту их обновления.
|
||||
|
||||
### **3. Как это работает?**
|
||||
|
||||
Расширение собирает идентификатор видео, которое вы смотрите, извлекает данные об отметках «Не нравится» (и другие поля, такие как просмотры, отметки «Нравится» и т.д.) с помощью нашего API, если видео было извлечено нашим API впервые, оно использует YouTube API для получения данных, затем сохраняет данные в базе данных для кэширования (кэшируются около 2-3 дней) и архивирования и возвращает их вам. После этого расширение отображает отметки «Не нравится» вам.
|
||||
|
||||
### **4. Что произойдёт после того, как API YouTube перестанет возвращать данные о количестве отметок «Не нравится»?**
|
||||
|
||||
Внутренняя часть нашего сервера переключится на использование комбинации архивных статистик отметок «Не нравится», оценок, экстраполированных из данных о пользователях расширения, и оценок, основанных на соотношении просмотров и отметок «Нравится» для видео, чьи отметки «Не нравится» не были заархивированы, и для устаревших архивов с отметками «Не нравится».
|
||||
|
||||
## Я беспокоюсь о безопасности / конфиденциальности
|
||||
|
||||
Более подробную информацию смотрите на [этой странице](SECURITY-FAQ.md).
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
Bunu diğer dillerde okuyun: [English](FAQ.md), [русский](FAQru.md), ), [Nederlands](FAQnl.md), [Français](FAQfr.md), [українська](FAQuk.md)
|
||||
|
||||
Bunu diğer dillerde okuyun: [English](FAQ.md), [русский](FAQru.md), [Nederlands](FAQnl.md), [Français](FAQfr.md), [українська](FAQuk.md), [Polski](FAQpl.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Sıkça Sorulan Sorular
|
||||
|
||||
|
||||
+61
-61
@@ -1,61 +1,61 @@
|
||||
Read this in other languages: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md)
|
||||
|
||||
# Часті питання
|
||||
|
||||
## Перш ніж задавати питання на GitHub або у Discord, будь ласка, ознайомтеся з цим.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Звідки це розширення отримує дані?**
|
||||
|
||||
Комбінація Google API та старих даних.
|
||||
|
||||
Ми зберігаємо всі доступні дані в нашій базі, аби вони були доступні після того, як Google вимкне лічильник відміток «Не подобається» у своєму API.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Лічильник «Не подобається» не оновлюється**
|
||||
|
||||
Наразі відео з відмітками «Не подобається» кешуються і оновлюються не надто часто. Не частіше, ніж раз в 2-3 дні.
|
||||
|
||||
Так, це не ідеально, але маємо те, що маємо. Ми працюємо над тим, щоб збільшити частоту їх оновлення.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Як це працює?**
|
||||
|
||||
Розширення отримує ID відео, яке ви переглядаєте, та дізнається кількість відміток «Не подобається» (та інші дані: перегляди, відмітки «Подобається» тощо) за допомогою нашого API, якщо відео було витягнуте нашим API вперше, воно використовує YouTube API для отримання даних, потім зберігає дані в базі даних для кешування (кешуються близько 2-3 днів) та архівування й повертає їх вам. Після цього розширення відображає відмітки «Не подобається» вам.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Що станеться після того, як YouTube API перестане повертати кількість відміток «Не подобається»?**
|
||||
|
||||
Сервер перейде на використання комбінації заархівованих статистичних даних відміток «Не подобається» екстрапольованих із даними .
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Як розраховується кількість відміток «Не подобається»?**
|
||||
|
||||
RYD використовує відмітки своїх користувачів, щоб екстраполювати кількість відміток «Не подобається».
|
||||
|
||||
- Якщо відео було завантажено після вимкнення API:
|
||||
|
||||
$$ \textup{К-ть відміток «Не подобається» у RYD} = \left( \frac{\textup{К-ть відміток «Подобається» серед користувачів RYD}}{\textup{К-ть відміток «Не подобається» серед користувачів RYD}} \right) \times \textup{Публічна к-ть відміток «Подобається»} $$
|
||||
|
||||
- Якщо база даних RYD якимось чином мала фактичну кількість відміток «Подобається» та «Не подобається» (надану завантажувачем або з архіву), кількість відміток «Не подобається» буде розраховано на основі відміток користувачів і архівного значення. Архівне значення матиме менший вплив на остаточний підрахунок у міру актуальності.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Це все, але у відео форматі
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Я турбуюся про безпеку/конфіденційність
|
||||
|
||||
Детальніше про це дивіться [тут](SECURITY-FAQuk.md).
|
||||
Read this in other languages: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Türkçe](FAQtr.md), [Deutsch](FAQde.md), [العربية](FAQar.md), [Bahasa Indonesia](FAQid.md), [中文](FAqcn.md), [български](FAQbg.md), [Tiếng Việt](FAQvi.md)
|
||||
|
||||
# Часті питання
|
||||
|
||||
## Перш ніж задавати питання на GitHub або у Discord, будь ласка, ознайомтеся з цим.
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Звідки це розширення отримує дані?**
|
||||
|
||||
Комбінація Google API та старих даних.
|
||||
|
||||
Ми зберігаємо всі доступні дані в нашій базі, аби вони були доступні після того, як Google вимкне лічильник відміток «Не подобається» у своєму API.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Лічильник «Не подобається» не оновлюється**
|
||||
|
||||
Наразі відео з відмітками «Не подобається» кешуються і оновлюються не надто часто. Не частіше, ніж раз в 2-3 дні.
|
||||
|
||||
Так, це не ідеально, але маємо те, що маємо. Ми працюємо над тим, щоб збільшити частоту їх оновлення.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Як це працює?**
|
||||
|
||||
Розширення отримує ID відео, яке ви переглядаєте, та дізнається кількість відміток «Не подобається» (та інші дані: перегляди, відмітки «Подобається» тощо) за допомогою нашого API, якщо відео було витягнуте нашим API вперше, воно використовує YouTube API для отримання даних, потім зберігає дані в базі даних для кешування (кешуються близько 2-3 днів) та архівування й повертає їх вам. Після цього розширення відображає відмітки «Не подобається» вам.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Що станеться після того, як YouTube API перестане повертати кількість відміток «Не подобається»?**
|
||||
|
||||
Сервер перейде на використання комбінації заархівованих статистичних даних відміток «Не подобається» екстрапольованих із даними .
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Як розраховується кількість відміток «Не подобається»?**
|
||||
|
||||
RYD використовує відмітки своїх користувачів, щоб екстраполювати кількість відміток «Не подобається».
|
||||
|
||||
- Якщо відео було завантажено після вимкнення API:
|
||||
|
||||
$$ \textup{К-ть відміток «Не подобається» у RYD} = \left( \frac{\textup{К-ть відміток «Подобається» серед користувачів RYD}}{\textup{К-ть відміток «Не подобається» серед користувачів RYD}} \right) \times \textup{Публічна к-ть відміток «Подобається»} $$
|
||||
|
||||
- Якщо база даних RYD якимось чином мала фактичну кількість відміток «Подобається» та «Не подобається» (надану завантажувачем або з архіву), кількість відміток «Не подобається» буде розраховано на основі відміток користувачів і архівного значення. Архівне значення матиме менший вплив на остаточний підрахунок у міру актуальності.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Це все, але у відео форматі
|
||||
|
||||
[](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Я турбуюся про безпеку/конфіденційність
|
||||
|
||||
Детальніше про це дивіться [тут](SECURITY-FAQuk.md).
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
Đọc bằng các ngôn ngữ khác: [English](FAQ.md), [русский](FAQru.md), [Français](FAQfr.md), [Nederlands](FAQnl.md), [Türkçe](FAQtr.md), [українська](FAQuk.md), [Polski](FAQpl.md)
|
||||
|
||||
|
||||
# Câu Hỏi Thường Gặp <!-- # Frequently Asked Questions -->
|
||||
|
||||
## Trước khi đưa ra bất kì câu hỏi nào trên GitHub hay trên Discord, vui lòng đọc hết trang này. <!-- ## Before asking a question on GitHub or Discord, please refer to this. -->
|
||||
|
||||
<br>
|
||||
|
||||
### **1. Tiện ích mở rộng này lấy dữ liệu từ đâu?** <!-- ### Where does this extension get the data? -->
|
||||
|
||||
Kết hợp dữ liệu lấy từ API của Google và dữ liệu thu thập được.
|
||||
|
||||
Chúng tôi lưu giữ tất cả dữ liệu mà chúng tôi có được vào cơ sở dữ liệu của chúng tôi, kể từ khi Google loại bỏ số lượt đánh giá "không thích" khỏi API của họ.
|
||||
|
||||
<br>
|
||||
|
||||
### **2. Số lượt "không thích" không được cập nhật** <!-- ### Video dislike count doesn't update -->
|
||||
|
||||
Hiện tại, các lượt đánh giá "không thích" sẽ được lưu lại và số lượt đánh giá tổng được hiển thị không được cập nhật thường xuyên. Thông thường, cứ mỗi 2 đến 3 ngày, chứ không ngắn hơn.
|
||||
|
||||
Đồng ý rằng điều này không đáng mong chờ, nhưng hiện tại thì tiện ích hoạt động như vậy. Chúng tôi đang cố gắng cải thiện tần suất nhật số lượt đánh giá.
|
||||
|
||||
<br>
|
||||
|
||||
### **3. Cách thức hoạt động của tiện ích mở rộng này?** <!-- ### How does this work? -->
|
||||
|
||||
Tiện ích này thu thập ID của vi-đê-ô mà bạn đang xem, truy vấn đánh giá của vi-đê-ô (bao gồm số lượt "không thích", số lượt "thích", số lượt xem, v.v.) bằng API của chúng tôi. Nếu đây là đầu tiên diễn ra truy vấn tới API của chúng tôi đối với vi-đê-ô này, tiện ích sẽ sử dụng API của YouTube sẽ truy vấn các thông tin cần thiết, sau đó lưu trữ các thông tin này vào cơ sở dữ liệu cho mục đích truy vấn nhanh (trong vòng 2 đến 3 ngày) và cho mục đích lưu trữ và sẽ trả kết quả truy vấn về cho bạn. Lúc này, tiện ích sẽ hiện thị số lượt đánh giá "không thích" cho bạn xem.
|
||||
|
||||
<br>
|
||||
|
||||
### **4. Việc gì sẽ diễn ra sau khi API của YouTube ngừng cung cấp số lượt "không thích"?** <!-- ### What will happen after the YouTube API stops returning the dislike count? -->
|
||||
|
||||
Đầu sau của tiện ích sẽ chuyển sang sử dụng kết hợp dữ liệu đã được lưu trữ về số lượt đánh giá "không thích", những ước tính ngoại suy từ dữ liệu người dùng của tiện ích và những ước tính dựa trên tỉ lệ lượt xem/lượt "thích" đối với những vi-đê-ô chưa lưu được số lượt "không thích" và đối với các kho lưu trữ số lượt "không thích" lỗi thời.
|
||||
|
||||
<br>
|
||||
|
||||
### **5. Số lượt đánh giá "không thích" được tính như thế nào?** <!-- ### How is the dislike count calculated? -->
|
||||
|
||||
RYD sử dụng đánh giá từ người dùng để ngoại suy số lượt đánh giá "không thích".
|
||||
|
||||
- Nếu vi-đê-ô được đăng tải sau khi API của YouTube loại bỏ trường `dislike`:
|
||||
|
||||
$$ \textup{Số lượt "Không thích" của RYD} = \left( \frac{\textup{Số lượt "Không thích" từ Người dùng của RYD}}{\textup{Số lượt "Thích" từ Người dùng của RYD}} \right) \times \textup{Số lượt "Thích" từ API của YouTube} $$
|
||||
|
||||
- Nếu cơ sở dữ liệu của RYD có được số lượt đánh giá "thích" và "không thích" thực (được cung cấp bởi chủ vi-đê-ô hay từ kho lưu trữ), số lượt đánh giá "không thích" sẽ được tính dựa trên đồng thời (1) đánh giá từ người dùng của tiện ích và (2) số liệu được lưu trữ. Số liệu được lưu trữ sẽ càng ngày càng kém quan trọng trong việc tính toán số lượt đánh giá.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
Vi-đê-ô thông tin về Return YouTube Dislike:
|
||||
|
||||
[Giải thích về Return YouTube Dislike (Tiếng Anh)](https://www.youtube.com/watch?v=GSmmtv-0yYQ)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Tôi có thắc mắc về tính bảo mật / quyền riêng tư <!-- ## I have security / privacy concerns -->
|
||||
|
||||
Vui lòng tham khảo [trang này](SECURITY-FAQvi.md) để biết thêm thông tin.
|
||||
@@ -0,0 +1,320 @@
|
||||
**Mục lục**
|
||||
|
||||
- [Downloading](#downloading)
|
||||
- [Desktop (all OS supported by these browsers)](#desktop-all-os-supported-by-these-browsers)
|
||||
- [Chromium Based Browsers](#chromium-based-browsers)
|
||||
- [Firefox Based Browsers](#firefox-based-browsers)
|
||||
- [Mobile](#mobile)
|
||||
- [Android](#android)
|
||||
- [iOS](#ios)
|
||||
- [Userscript](#userscript)
|
||||
- [Installation](#installation)
|
||||
- [Desktop](#desktop)
|
||||
- [**Chromium based browsers**](#chromium-based-browsers-1)
|
||||
- [From Chrome Webstore](#from-chrome-webstore)
|
||||
- [From crx/zip file](#from-crxzip-file)
|
||||
- [From unzipped folder](#from-unzipped-folder)
|
||||
- [**Firefox Based Browsers**](#firefox-based-browsers-1)
|
||||
- [From addon store](#from-addon-store)
|
||||
- [From xpi/jar/zip file](#from-xpijarzip-file)
|
||||
- [Mobile](#mobile-1)
|
||||
- [Android](#android-1)
|
||||
- [App from Play Store](#app-from-play-store)
|
||||
- [On Firefox](#on-firefox)
|
||||
- [iOS](#ios-1)
|
||||
- [Using](#using)
|
||||
- [Updating](#updating)
|
||||
- [Extension / Addon](#extension--addon)
|
||||
- [Miscellaneous](#miscellaneous)
|
||||
- [Using YouTube website as an app with an extension](#using-youtube-website-as-an-app-with-an-extension)
|
||||
- [Desktop](#desktop-1)
|
||||
- [Chromium Based Browsers](#chromium-based-browsers-2)
|
||||
- [Firefox Based Browsers](#firefox-based-browsers-2)
|
||||
- [Mobile](#mobile-2)
|
||||
- [Firefox Based Browsers](#firefox-based-browsers-3)
|
||||
- [Chromium Based Browsers](#chromium-based-browsers-3)
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Downloading
|
||||
|
||||
### Desktop (all OS supported by these browsers)
|
||||
|
||||
<br>
|
||||
|
||||
#### [Chromium Based Browsers][4]
|
||||
|
||||
This extension has been tested to work on these browsers.
|
||||
|
||||
- [Google Chrome][1]
|
||||
- [Microsoft Edge][1]
|
||||
- [Brave][1]
|
||||
- [Opera][1]
|
||||
|
||||
It should be able to work on [all Chromium-based browsers (list here)][4]. But that isn't guaranteed.
|
||||
|
||||
<br>
|
||||
|
||||
#### [Firefox Based Browsers][5]
|
||||
|
||||
- [Firefox][2]
|
||||
- This addon should be able to run on most of the [Firefox-based browsers][5]. But isn't guaranteed.
|
||||
|
||||
<br>
|
||||
|
||||
### Mobile
|
||||
|
||||
#### Android
|
||||
|
||||
1. **F-Droid Store**
|
||||
|
||||
- [Show Youtube Dislikes](https://f-droid.org/en/packages/com.jesperh.showyoutubedislikes/)
|
||||
|
||||
[Download from here (Click here)](https://f-droid.org/en/packages/com.jesperh.showyoutubedislikes/)
|
||||
|
||||
The source code is available at [github.com/jesperbakhandskemager/view-youtube-dislike](https://github.com/jesperbakhandskemager/view-youtube-dislike)
|
||||
|
||||
You can download the apk file from
|
||||
[https://github.com/jesperbakhandskemager/view-youtube-dislike/releases/](https://github.com/jesperbakhandskemager/view-youtube-dislike/releases/)
|
||||
|
||||
**Note: This app is NOT made by the original author of the extension**.
|
||||
|
||||
2. [**Firefox Nightly**][2]
|
||||
|
||||
- This addon should be able to run on most of the [Firefox-based browsers][5]. But isn't guaranteed.
|
||||
|
||||
3. [**Chromium Based browsers**][4]
|
||||
|
||||
Most [**Chromium Based browsers**][4] **don't support extensions on Android or iOS**
|
||||
|
||||
However, [Kiwi Browser](https://kiwibrowser.com/) does. You can refer to this video - [Google Chrome Extensions on Android with Kiwi Browser!](https://youtu.be/T6J0T_-oim4)
|
||||
|
||||
<br>
|
||||
|
||||
#### iOS
|
||||
|
||||
No Support on Firefox
|
||||
|
||||
You can have a look at these pages for more information (the reason why it's not available on Firefox):
|
||||
|
||||
- [https://support.mozilla.org/en-US/kb/add-ons-firefox-ios]
|
||||
- [https://support.mozilla.org/en-US/questions/1101350]
|
||||
|
||||
For now, you can try this
|
||||
|
||||
- [For Jailbroken iOS - **WE TAKE NO RESPONSIBILITY. USE AT YOUR OWN RISK**](https://chariz.com/get/return-youtube-dislike)
|
||||
|
||||
This is an iOS port [**developed by a separate team**](https://github.com/PoomSmart/Return-YouTube-Dislikes) not related to the owner of github.com/Anarios/return-youtube-dislike
|
||||
|
||||
#### Userscript
|
||||
|
||||
[Download from here](https://returnyoutubedislike.com/install)
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Installation
|
||||
|
||||
### Desktop
|
||||
|
||||
<br>
|
||||
|
||||
#### [**Chromium based browsers**][4]
|
||||
|
||||
<br>
|
||||
|
||||
##### From Chrome Webstore
|
||||
|
||||
1. [Go to website (click here)][1]
|
||||
2. Click install
|
||||
3. Wait for the extension to download and
|
||||
4. 🙂 Installed !!
|
||||
|
||||
<br>
|
||||
|
||||
##### From crx/zip file
|
||||
|
||||
1. Download the crx/zip file.
|
||||
2. Type [`chrome://extensions`][6] in address bar
|
||||
3. Search for the "Developer Mode" switch and turn it on.
|
||||
4. Open the folder and the browser side by side.
|
||||
5. Drag and drop the crx/zip file in [chrome://extensions][6] tab
|
||||
6. Click on "Add extension"
|
||||
7. Installation Completed 🎉
|
||||
8. Remember to **turn off the "developer mode"** if not needed
|
||||
|
||||
<br>
|
||||
|
||||
##### From unzipped folder
|
||||
|
||||
1. Download the crx/zip file.
|
||||
2. Go to [`chrome://extensions`][6]
|
||||
3. Search for the "Developer Mode" switch and turn it on.
|
||||
4. Click on "Load Unpacked"
|
||||
5. Navigate to the folder & select it
|
||||
6. The extension should be installed 🎉
|
||||
7. Remember to **turn off the "developer mode"** if not needed
|
||||
|
||||
<br>
|
||||
|
||||
#### [**Firefox Based Browsers**][5]
|
||||
|
||||
##### From addon store
|
||||
|
||||
1. Go to the addon store and find the extension or [click here][2].
|
||||
2. Click on `Add to Firefox`
|
||||
3. Done 🎉 The extension should be installed.
|
||||
|
||||
<br>
|
||||
|
||||
##### From xpi/jar/zip file
|
||||
|
||||
1. Type `about:addons` in the address bar and press "Enter". Alternatively, You can use
|
||||
`Ctrl` + `Shift` + `A` on Windows and
|
||||
`Cmd` + `Shift` + `A` on Mac and
|
||||
2. Click on the Setting Gear icon `⚙`.
|
||||
3. Click on `Install Add-on From File...`
|
||||
4. Locate and click on the .xpi/.jar/.zip file.
|
||||
5. Select and click to open it.
|
||||
6. Done 🎉 The extension should be installed.
|
||||
|
||||
<br>
|
||||
|
||||
### Mobile
|
||||
|
||||
#### Android
|
||||
|
||||
##### App from Play Store
|
||||
|
||||
- Although app(s) which use this API exist, they aren't official.
|
||||
|
||||
##### On Firefox
|
||||
|
||||
1. Install Firefox Nightly. Refer to this blog post for the procedure: [Expanded extension support in Firefox for Android Nightly](https://blog.mozilla.org/addons/2020/09/29/expanded-extension-support-in-firefox-for-android-nightly/)
|
||||
2. Install the extension in it from [addon store][2]
|
||||
3. If you want - install the site as an app. For that procedure [go here](#firefox-based-browsers-3).
|
||||
|
||||
#### iOS
|
||||
|
||||
- [For iOS click here](#ios)
|
||||
- Only available on Firefox-based browsers because Chromium-based browsers for the Android platform don't support installing extensions.
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Using
|
||||
|
||||
No more steps are required, it should start working right away.
|
||||
|
||||
If required you can restart your browser.
|
||||
|
||||
**🎉 🎊 Congratulations** 🥳 🎊
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Updating
|
||||
|
||||
### Extension / Addon
|
||||
|
||||
On [Chromium based browsers][4]
|
||||
|
||||
- [from webstore][1]
|
||||
- sideloading from [crx/zip file](#from-crxzip-file) or [unzipped folder](#from-unzipped-folder)
|
||||
|
||||
On [Firefox based browsers][5]
|
||||
|
||||
- [from addon store][2]
|
||||
- [sideloading from xpi/jar/zip file](#from-xpijarzip-file)
|
||||
|
||||
On [Firefox based browsers][5] for mobile
|
||||
|
||||
- [from addon store][2]
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
### Using YouTube website as an app with an extension
|
||||
|
||||
#### Desktop
|
||||
|
||||
##### Chromium Based Browsers
|
||||
|
||||
0. Go to YouTube
|
||||
1. Tap on menu button (`☰` / `⋯` / `⋮`)
|
||||
2. Click on "Install YouTube"
|
||||
3. 🎉Done!
|
||||
|
||||
<br>
|
||||
|
||||
##### Firefox Based Browsers
|
||||
|
||||
**⚠ Doesn't work on newer versions.**
|
||||
|
||||
1. Type [`about:config`](about:config) in address bar & press `Enter` key.
|
||||
2. When warned `Proceed with Caution` click on `Accept the Risk and Continue`
|
||||
3. Locate and click on the search bar on the page.
|
||||
4. Type `browser.ssb.enable`.
|
||||
5. click on `⇋` and make sure the state is set to `true`.
|
||||
6. Restart the browser and Open YouTube.
|
||||
7. Click on `⋮` located in the upper right corner
|
||||
8. Click `Install this website as an app` or `Install this website as an app` which depends on the version you are using.
|
||||
9. Done! 🎉 You should be able to see the site's app in the start menu.
|
||||
|
||||
You can also refer here (for pictures):
|
||||
|
||||
[How to Enable the Site-Specific Browser (SSB) in Firefox - Make Tech Easier](https://www.maketecheasier.com/enable-site-specific-browser-firefox)
|
||||
|
||||
<br>
|
||||
|
||||
#### Mobile
|
||||
|
||||
##### Firefox Based Browsers
|
||||
|
||||
0. Install the extension
|
||||
1. Go to YouTube
|
||||
2. Tap on the three dots `⋮` located at the bottom right corner of the screen.
|
||||
3. Turn on "Desktop Site".
|
||||
4. Refresh the page.
|
||||
5. Wait for it to completely load.
|
||||
6. Tap again on the three dots `⋮` located at the bottom right corner of the screen.
|
||||
7. You will see an option as `Install`. Tap on it.
|
||||
8. Tap - `Add` when asked `Add to Home screen?`
|
||||
9. Enjoy the YT site as a Firefox nightly app !!
|
||||
|
||||
<br>
|
||||
|
||||
##### [Chromium Based Browsers][4]
|
||||
|
||||
But still - here are the steps:
|
||||
|
||||
1. Go to YouTube
|
||||
2. Tap on the three dots `⋮` located at the bottom right corner of the screen.
|
||||
3. Turn on "Desktop Site".
|
||||
4. Refresh the page and wait for it to completely load.
|
||||
5. Tap again on the three dots `⋮` located at the bottom right corner of the screen.
|
||||
6. You will see an option as `Install`. Tap on it.
|
||||
7. When asked `Install App` tap on `Yes`
|
||||
8. Tap - `Add` when asked `Add to Home screen?`
|
||||
9. Enjoy the YT site as an app !!
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
<!-- links -->
|
||||
|
||||
[1]: https://chrome.google.com/webstore/detail/return-youtube-dislike/gebbhagfogifgggkldgodflihgfeippi
|
||||
[2]: https://addons.mozilla.org/en-US/firefox/addon/return-youtube-dislikes/
|
||||
[3]: https://github.com/Anarios/return-youtube-dislike/raw/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js
|
||||
[4]: https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium
|
||||
[5]: https://en.wikipedia.org/wiki/Category:Web_browsers_based_on_Firefox
|
||||
[6]: chrome://extensions
|
||||
@@ -0,0 +1,184 @@
|
||||
Hướng dẫn Khắc phục Sự cố
|
||||
|
||||
**Mục lục**
|
||||
|
||||
- [Extension](#extension)
|
||||
- [Basic checks](#basic-checks)
|
||||
- [Check API status](#check-api-status)
|
||||
- [Install certificates](#install-certificates)
|
||||
- [Check for logs in the console](#check-for-logs-in-the-console)
|
||||
- [In Chromium Based browsers](#in-chromium-based-browsers)
|
||||
- [In Firefox Based Browsers](#in-firefox-based-browsers)
|
||||
- [Check for conflicting extensions](#check-for-conflicting-extensions)
|
||||
- [Known conflicts](#known-conflicts)
|
||||
- [iOS app](#ios-app)
|
||||
- [YouTube Vanced app](#youtube-vanced--app)
|
||||
- [Contact in Discord Server](#contact-in-discord-server)
|
||||
- [Useful Links](#useful-links)
|
||||
|
||||
<br>
|
||||
|
||||
## Extension
|
||||
|
||||
(for Windows & Macs)
|
||||
|
||||
### Basic checks
|
||||
|
||||
1. Make sure you have the latest version of the extension installed. ([Click Here to check](https://chrome.google.com/webstore/detail/return-youtube-dislike/gebbhagfogifgggkldgodflihgfeippi#:~:text=Report%20abuse-,Version,-2.0.0.3))
|
||||
2. Close all the tabs & restart your browser
|
||||
3. Reinstall the extension.
|
||||
4. [Check API status](#check-api-status)
|
||||
5. [Check Cloudflare status](https://www.cloudflarestatus.com/)
|
||||
6. [If you are on Windows 7 read this](#install-certificates)
|
||||
|
||||
<br>
|
||||
|
||||
### Check API status
|
||||
|
||||
If the basic checks didn't resolve anything
|
||||
|
||||
[See if you get any response from this link (click here)](https://returnyoutubedislikeapi.com/votes?videoId=QOFEgexls14)
|
||||
|
||||
- If you **don't see something like** this, then the **API is down** and **everything is fine on your side**.
|
||||
`{"id":"QOFEgexls14","dateCreated":"2021-12-28T02:53:20.995329Z","likes":2968,"dislikes":204,"rating":4.725047080979285,"viewCount":29157,"deleted":false}`
|
||||
- If you see some responses but not in the above format (with likes and dislikes) then probably you are being rate-limited. It is done to prevent bot attacks and database vandalization. It depends on IP (its hash - which is never stored in non-volatile storage) for its countermeasures. If many people are accessing the server from the same IP (as in the case of public/institutional Wi-Fi) then it's possible that the IP is being rate-limited. If that's the case, there's no way for us to differentiate you from a bot/attacker.
|
||||
|
||||
**If you see "Certificate error" and [if you are on Windows 7 (or earlier) read this](#install-certificates)**
|
||||
|
||||
<br><br>
|
||||
|
||||
### Install certificates
|
||||
|
||||
**Applies for Windows 7 (and earlier) only**
|
||||
|
||||
and only for [Chromium Based Browsers][1]
|
||||
|
||||
- [Chromium-based browsers][1] don't have their own certificate manager.
|
||||
- They use Windows' certificates manager.
|
||||
- Microsoft has officially dropped the support for Windows 7
|
||||
|
||||
You will have to install the latest certificates for that.
|
||||
|
||||
You can follow this guide:
|
||||
|
||||
[Fix error NET::ERR CERT DATE INVALID - Your connection is not private - Windows 7 - 2021](https://youtu.be/JYZLxP2Z8G4)
|
||||
|
||||
If you don't want to install the certificate from Google Drive
|
||||
|
||||
- Here is the official link to the certificate [**x1.i.lencr.org**](http://x1.i.lencr.org/).
|
||||
- **You will have to close all the tabs** before downloading this certificate.
|
||||
|
||||
**The thumbprint of real certificate is `cabd2a79a1076a31f21d253635cb039d4329a5e8`**
|
||||
|
||||
**To make sure that you have installed the correct certificate, you should consider checking if the thumbprints match.** To do this you can follow this guide: [How to check a certificate's thumbprint](https://knowledge.digicert.com/solution/SO9840.html)
|
||||
|
||||
<br>
|
||||
|
||||
### Check for logs in the console
|
||||
|
||||
#### [In Chromium Based browsers][1]
|
||||
|
||||
1. In Developer tools, go to [`console` panel](https://developer.chrome.com/docs/devtools/open/#console).
|
||||
- For Windows press `Ctrl` + `Shift` + `J` all at once
|
||||
- For Mac press `Cmd` + `Option` + `J` all at once
|
||||
2. Find `filter` box in the newly appeared window.
|
||||
3. Type `Return`.
|
||||
4. Check the [Check API Status](#check-api-status) and see if you get similar responses.
|
||||
5. If you see any errors in red [please contact us][4] and report them in our [discord server][3]
|
||||
|
||||
<!-- If ever needed
|
||||
- For Android refer to this article: [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/) -->
|
||||
|
||||
<br>
|
||||
|
||||
#### [In Firefox Based Browsers][2]
|
||||
|
||||
1. Open Browser Console
|
||||
- For standard keyboard layout press `Ctrl` + `Shift` + `K` all at once
|
||||
- For Mac keyboard layout press `Cmd` + `Option` + `K` all at once
|
||||
- For Android refer to this article: [Remotely debugging Firefox <36 for Android](https://developer.mozilla.org/en-US/docs/Tools/Remote_Debugging/Firefox_for_Android)
|
||||
2. Find `Filter Output` box in the newly appeared window.
|
||||
3. Type `Return`.
|
||||
4. If you see any errors in red [please contact us][4] and report them in our [discord server][3]
|
||||
|
||||
<br>
|
||||
|
||||
### Check for conflicting extensions
|
||||
|
||||
Some privacy and/or security-focused extensions such as ad- or script-blockers, as well as YouTube customization plugins might prevent the extension from working correctly.
|
||||
Try to disable all other extensions and test whether the extension works.
|
||||
If it does, find the extension(s) preventing RYD from working correctly and re-configure them in a way that'd stop them from interfering.
|
||||
|
||||
<br>
|
||||
|
||||
### Known conflicts
|
||||
|
||||
> ### scriptSafe
|
||||
>
|
||||
> **Solution:** Trust `returnyoutubedislikeapi.com` manually
|
||||
>
|
||||
> 
|
||||
|
||||
<br>
|
||||
|
||||
> ### uMatrix
|
||||
>
|
||||
> **Solution:** Allow XHR for `returnyoutubedislikeapi.com` manually
|
||||
>
|
||||
> 
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## iOS app
|
||||
|
||||
Coming soon. Please have patience.
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## YouTube Vanced app
|
||||
|
||||
Coming soon. Please have patience.
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Contact in Discord Server
|
||||
|
||||
**Only if nothing mentioned above helped and you still have a problem.**
|
||||
|
||||
[Discord server link: https://discord.gg/mYnESY4Md5][3]
|
||||
|
||||
0. Join the discord server if you haven't already
|
||||
1. Go to the #Bugs-and-problems channel
|
||||
2. There, thoroughly describe:
|
||||
- your problem
|
||||
- what you have tried and what didn't work
|
||||
- results of the troubleshooting steps
|
||||
|
||||
<!-- {
|
||||
"update_frequency" : "low"
|
||||
} -->
|
||||
|
||||
<br>
|
||||
|
||||
<br>
|
||||
|
||||
## Useful Links
|
||||
|
||||
[List of Chromium Based Browsers][1]
|
||||
|
||||
[List of Firefox Based Browsers][2]
|
||||
|
||||
[Return-YouTube-Dislike Discord Server][3]
|
||||
|
||||
<!-- links -->
|
||||
|
||||
[1]: https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium
|
||||
[2]: https://en.wikipedia.org/wiki/Category:Web_browsers_based_on_Firefox
|
||||
[3]: https://discord.gg/mYnESY4Md5
|
||||
[4]: #contact-in-discord-server
|
||||
+61
-4
@@ -1,7 +1,64 @@
|
||||
The only data collected from users is their likes and dislikes made while the extension is installed.
|
||||
# Return YouTube Dislike Privacy Policy
|
||||
|
||||
No personal info, account name or watch history is collected or saved.
|
||||
Last updated: September 7, 2026
|
||||
|
||||
Users are identified by a random user ID, which is not directly linked to any of their accounts, the only purpose of this user ID is to make voting process possible.
|
||||
This policy describes version 4.0.6 and its matching backend release.
|
||||
|
||||
None of the saved data is shared with any third parties,
|
||||
Return YouTube Dislike transmits only the data needed to provide its dislike-count, voting, abuse-prevention, and
|
||||
optional premium features. It does not sell personal data or use it for advertising.
|
||||
|
||||
## Data required for the extension to work
|
||||
|
||||
- On registration, the extension generates a persistent random installation identifier and sends it to the Return
|
||||
YouTube Dislike API. The identifier is not derived from or linked to a YouTube or Google account, but it is a
|
||||
persistent identifier and is treated as personal data.
|
||||
- When a YouTube video is opened, the extension sends that video's ID and, when available, its visible like count to
|
||||
retrieve and improve the dislike estimate.
|
||||
- When the user submits a Like, Dislike, or vote removal and vote submission is enabled, the extension sends the random
|
||||
installation identifier, video ID, and selected action.
|
||||
- API requests necessarily expose the network IP address and request time. The service uses this information for
|
||||
security, abuse prevention, aggregate analytics, and operation of the API.
|
||||
|
||||
The extension does not send a YouTube account name, comments, private YouTube data, or the contents of unrelated web
|
||||
pages. It does not maintain a separate local list of videos watched.
|
||||
|
||||
## Optional account and premium data
|
||||
|
||||
Patreon and GitHub sign-in are optional. The account flow starts only after the user selects a sign-in button and, in
|
||||
Firefox, grants the separate authentication-data permission. The selected provider and the Return YouTube Dislike API
|
||||
process the OAuth authorization response. The API returns the provider account ID, name, profile image,
|
||||
account-access status, access tier, and a Return YouTube Dislike session token as applicable to that provider. GitHub
|
||||
sign-in may also return an email address. Patreon sign-in does not request or return email, account-status fields,
|
||||
or connected social accounts; the name and profile image identify the signed-in account in the popup. To
|
||||
determine Patreon access, the matching backend uses currently entitled tier IDs. It does not request or use membership
|
||||
amounts, charge dates or status, lifetime payments, or patron status. The extension does not receive payment-card or
|
||||
bank-account details.
|
||||
|
||||
Those account details and the session token are stored in browser extension storage and used to authenticate premium
|
||||
analytics requests. The session token expires after 30 days. Signing out deletes the locally stored account details and
|
||||
session token. Revoking Firefox's authentication-data permission clears that local account session and prevents further
|
||||
account and premium requests. Pending responses cannot restore the session; the user must sign in again to resume it.
|
||||
|
||||
Patreon and GitHub process sign-in information under their own privacy policies. The extension never receives the
|
||||
user's Patreon or GitHub password.
|
||||
|
||||
## Storage, retention, and sharing
|
||||
|
||||
The identifier, settings, and optional account session use browser extension sync storage, which may be synchronized
|
||||
through the browser's sync service when the user enables it. The random installation identifier remains in extension storage until the extension data is cleared, registration is
|
||||
reset, or the extension is removed. Confirmed vote records contain the identifier, video ID, vote, timestamp, and the
|
||||
network address used for abuse controls. Registration and vote records are retained by the service while they are
|
||||
needed to calculate results, maintain vote integrity, and prevent abuse; no shorter automatic deletion period is
|
||||
promised.
|
||||
|
||||
Data is not sold or shared for advertising. It is processed by the Return YouTube Dislike service and its hosting and
|
||||
network infrastructure providers, and by Patreon or GitHub only when the user chooses the corresponding sign-in flow.
|
||||
|
||||
## User controls
|
||||
|
||||
Firefox displays its built-in consent prompt before the required data is transmitted. Refusing the required consent
|
||||
prevents installation or acceptance of the update. Vote submission can be disabled in the extension settings. Optional account access can be
|
||||
declined or revoked without disabling ordinary dislike counts. Firefox private-window access is disabled.
|
||||
|
||||
Users can clear extension storage or remove the extension to delete locally stored data. Requests concerning service
|
||||
data can be made through the project's support channels at https://returnyoutubedislike.com/help.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Dữ liệu duy nhất được thu nhập: số lượt đánh giá "thích" và "không thích" trong thời gian tiện ích mở rộng này được cài.
|
||||
|
||||
Dữ liệu không được thu thập cũng như không lưu trữ: thông tin cá nhân, tên tài khoản, lịch sử xem vi-đê-ô.
|
||||
|
||||
Người dùng được gán cho một ID người dùng ngẫu nhiên. ID người dùng này không có bất kì mối liên hệ nào tới những tài khoản của người dùng. Mục đích duy nhất của việc dùng ID người dùng là để thực hiện chức năng đánh giá vi-đê-ô.
|
||||
|
||||
Dữ liệu được lưu trữ không được chia sẻ với bất kì bên thứ ba nào.
|
||||
+24
-7
@@ -1,27 +1,44 @@
|
||||
Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md)
|
||||
|
||||
Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Security
|
||||
|
||||
### Are you tracking my viewing history?
|
||||
|
||||
No. The extension's code is public and you can see it for yourself. The only information being sent is the video ID, which is required to fetch the dislike count for the videos. There are no additional headers being sent. Over the communication layer, your public IP will be exposed to the server, as well as the time when the request was made. However, none of these are uniquely identifying you in any way. Assuming a zero-trust environment, the best we could get is a dynamic IP. Which, today is yours, tomorrow is your neighbor's. If you're really worried about your IP being traced, you probably already use a VPN.
|
||||
The extension sends the current video ID and, when available, its visible like count to the Return YouTube Dislike API
|
||||
because the server needs them to return and improve the dislike estimate. The request also exposes standard network
|
||||
metadata such as the public IP address and request time. The extension does not send your YouTube account name,
|
||||
comments, private YouTube data, or the contents of unrelated pages, and it does not keep a separate local watch-history
|
||||
list.
|
||||
|
||||
### Can you uniquely identify me if I dislike?
|
||||
|
||||
Yes. When you dislike a video, we create a randomly generated unique ID for you that is not tied to your Google account. This is done to prevent botting. But there is no way to tie this random Id to you or your personal YouTube account.
|
||||
The extension creates a persistent random ID for abuse-resistant vote submission. It is not derived from or linked to
|
||||
your Google or YouTube account, but Mozilla classifies persistent identifiers as personal data. The ID lets the service
|
||||
associate submitted votes with the same extension installation.
|
||||
|
||||
### What information do you have, exactly?
|
||||
|
||||
Just the video ID. Not your comments, not your username, not who you've shared the video with, not any additional metadata. Nothing. Just the video ID.
|
||||
For dislike-count requests: the current video ID and, when available, the visible like count. For registration and vote
|
||||
submission: the random extension user ID, video ID, selected vote, timestamp, and network address used for abuse
|
||||
controls. Optional premium features use the account ID, name, email address, profile image, membership information, and
|
||||
service session token returned by a Patreon or GitHub sign-in initiated by the user. For version 4.0.6 and its matching
|
||||
backend release, Patreon access is determined from currently entitled tier IDs rather than financial information.
|
||||
|
||||
### How is my IP stored?
|
||||
|
||||
The backend keeps unhashed IP addresses in volatile memory (RAM) only. These addresses aren't stored on a hard drive, and therefore aren't logged. We hash the IP addresses, and that's stored instead. This is done to prevent database vandalism.
|
||||
The API receives your network address with each request. Confirmed vote records retain the network address used for
|
||||
abuse prevention and aggregate analytics. It is not sold or used for advertising.
|
||||
|
||||
### I heard some discussion over OAuth, and access to my YouTube account!
|
||||
|
||||
This feature will be optional, and very much opt-in. If you are a YouTube creator, and would like to share your dislike stats with us, you can. The way [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) was structured, it's actually very secure. You can revoke access to your account at any time, and can give very specific permissions to us. We will not ask for any permissions that aren't required. We'll only ask for permissions to view your video stats.
|
||||
Premium analytics sign-in is optional and starts only after the user chooses Patreon or GitHub login in the extension.
|
||||
The OAuth flow returns account details and a Return YouTube Dislike session token used to authenticate premium API
|
||||
requests. In version 4.0.6 and its matching backend release, Patreon eligibility uses currently entitled tier IDs;
|
||||
the service does not request or use membership amounts, charge dates or status, lifetime payments, or patron status.
|
||||
Firefox asks for separate authentication-data consent before account traffic begins. Signing out removes
|
||||
the stored account details and session from the extension. Revoking Firefox's authentication-data permission also clears the
|
||||
account session and blocks further account traffic until you grant consent and sign in again. Browser sync may
|
||||
synchronize the installation identifier, settings, and account session when enabled.
|
||||
|
||||
### How can I trust this dislike count?
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
|
||||
|
||||
# Təhlükəsizlik
|
||||
|
||||
### Baxış tarixçəmi izləyirsiniz?
|
||||
|
||||
Xeyr. Artırmanın kodu ictimaiyyətə açıqdır və siz bunu özünüz görə bilərsiniz. Göndərilən yeganə məlumat videolar üçün bəyənməmə sayını almaq üçün tələb olunan video ID-dir. Göndərilən başqa əlavə başlıq yoxdur. Rabitə təbəqəsi vasitəsilə ictimai IP-niz serverə və sorğunun edildiyi vaxta məruz qalacaq. Bununla belə, bunların heç biri sizi heç bir şəkildə unikal şəkildə müəyyənləşdirmir. Sıfır etibar mühitini fərz etsək, əldə edə biləcəyimiz ən yaxşısı dinamik IP-dir. Bu İP bu gün sənin, sabah qonşunun ola bilər. Əgər həqiqətən IP-nizin izlənilməsindən narahatsınızsa, yəqin ki, artıq VPN-dən istifadə edirsiniz.
|
||||
|
||||
### Videonu bəyənməsəm, məni unikal şəkildə tanıya bilərsiniz??
|
||||
|
||||
Bəli. Videonu bəyənmədiyiniz zaman biz sizin üçün Google hesabınızla əlaqəli olmayan təsadüfi yaradılmış unikal ID yaradırıq. Bu, botlardan istifadənin qarşısını almaq üçün edilir. Bununla belə, bu təsadüfi identifikatoru sizə və ya şəxsi YouTube hesabınıza bağlamağın heç bir yolu yoxdur.
|
||||
|
||||
### Sizdə dəqiq olaraq hansı məlumat var??
|
||||
|
||||
Sadəcə video identifikatoru. Şərhləriniz, istifadəçi adınız, videonu kiminlə paylaşdığınız, heç bir əlavə metadata deyil. heç nə. Sadəcə video identifikatoru.
|
||||
|
||||
### Mənim IP ünvanım necə saxlanılır?
|
||||
|
||||
Backend yalnız müvəqqəti yaddaşda (RAM) haşiyələnməmiş IP ünvanlarını saxlayır. Bu ünvanlar sabit diskdə saxlanmır və buna görə də qeyd olunmur. Biz IP ünvanlarını hash edirik və əvəzinə onlar saxlanılır. Bu, verilənlər bazası vandalizminin qarşısını almaq üçün edilir.
|
||||
|
||||
### OAuth vasitəsilə YouTube hesabıma daxil olmaq haqqında bəzi mübahisələr eşitdim!
|
||||
|
||||
Bu xüsusiyyət istəyə bağlı olacaq və çox üstünlük təşkil edəcək. Əgər siz YouTube yaradıcısısınızsa və bəyənmədiyiniz statistikanı bizimlə bölüşmək istəyirsinizsə, bunu edə bilərsiniz. [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) Qurulma şəkli, əslində çox təhlükəsizdir. İstənilən vaxt hesabınıza girişi ləğv edə və bizə çox xüsusi icazələr verə bilərsiniz. Biz heç bir lazımsız icazə istəməyəcəyik. Biz yalnız video statistikanıza baxmaq üçün icazə istəyəcəyik.
|
||||
### Bu sayda bəyənməmələrə necə etibar edə bilərəm??
|
||||
|
||||
Biz bot hücumlarının qarşısını almaq üçün tədbirlər gördük və anti-bot sisteminin effektivliyini artırmaq üçün işi davam etdirəcəyik: bu, bəyənməmələrin sayını real rəqəmin yaxşı nümayəndəsi kimi saxlamağa kömək edəcək. Əlbəttə ki, bu, heç vaxt 100% dəqiq olmayacaq, ona görə də hesaba etibar edib etməmək sizə bağlıdır.
|
||||
|
||||
### Niyə backend kodunu paylaşmırsınız?
|
||||
Biz bunu nə vaxtsa paylaşacağıq - lakin hazırda paylaşmaq üçün həqiqətən heç bir səbəb yoxdur. Bu, yanlış təhlükəsizlik hissi verə bilər - çünki sıfır inamlı sistemdə biz bir versiyanı ifşa edə, digərini yerləşdirə bilərik. Kodu gizli saxlamağın bir çox səbəbi var, xüsusən də spamla necə mübarizə aparırıq. Spam emalı kodunu ört-basdır etmək/gizlətmək olduqca standart təcrübədir.
|
||||
@@ -0,0 +1,32 @@
|
||||
Прочетете това на други езици: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md)
|
||||
|
||||
|
||||
# Сигурност
|
||||
|
||||
### Проследявате ли историята на гледанията ми?
|
||||
|
||||
Не. Кода на разширението е публичен и може да го видите сами. Единствената информация, която се изпраща, е идентификаторът на видеото, който е необходим, за да вземе броя на дизлайковете за видеата. Няма допълнителни заглавия, изпращани. Чрез комуникационния слой вашите обществени IP адреси ще бъдат изложени на сървъра, както и времето, в което беше направен заявката. Обаче нито едно от тези не ви идентифицира уникално по никакъв начин. Предполагайки среда с нулево доверие, най-доброто, което можем да получим, е динамичен IP адрес. Който днес е вашият, утре е на вашия съсед. Ако наистина се притеснявате, че вашият IP адрес бива проследен, вероятно вече използвате VPN.
|
||||
|
||||
### Можете ли да ме идентифицирате уникално, ако дизлайкна?
|
||||
|
||||
Да. Когато дизлайкнете видео, създаваме случайно генериран уникален идентификатор за вас, който не е свързан с вашия Google акаунт. Това се прави, за да предотвратим бот атаки. Но няма начин този случаен идентификатор да бъде свързан с вас или с вашия личен акаунт на YouTube.
|
||||
|
||||
### Какви точно информация имате?
|
||||
|
||||
Само идентификатора на видеото. Нито вашите коментари, нито потребителското ви име, нито на кого сте споделили видеото, нито допълнителни метаданни. Нищо. Само идентификатора на видеото.
|
||||
|
||||
### Как се съхранява моят IP адрес?
|
||||
|
||||
Бекендът държи незакодирани IP адреси само в изменяемата памет (RAM). Те не се съхраняват на твърд диск и следователно не се записват. Ние кодираме IP адресите, и това се съхранява. Това се прави, за да се предотврати вандализмът върху базата данни.
|
||||
|
||||
### Чух за OAuth и достъп до акаунта ми на YouTube!
|
||||
|
||||
Тази функция ще бъде по избор и изключително от ваш избор. Ако сте създател на YouTube и искате да споделите статистиката за дизлайковете с нас, можете. Структурата на [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) е наистина много сигурна. Всеки път можете да отмените достъпа до акаунта си и да дадете много конкретни разрешения на нас. Няма да поискаме никакви разрешения, които не са необходими. Ще поискаме само разрешения за преглед на статистиката на вашите видеа.
|
||||
|
||||
### Как мога да се доверя на този брой дизлайкове?
|
||||
|
||||
Въведохме мерки за предотвратяване на атаки с ботове и ще продължим да работим по подобряване на ефективността на системата за предотвратяване на ботове: това ще ни помогне да поддържаме броя на дизлайковете като добър представител на реалния брой. Разбира се, той никога няма да бъде 100% точен, така че решението дали да вярвате на броя или не, зависи от вас.
|
||||
|
||||
### Защо не споделяте кода на бекенда?
|
||||
|
||||
Ще го споделим на някакъв етап - но наистина няма реална причина да го споделяме в момента. Това създава лъжливо чувство за сигурност - защото в среда с нулево доверие можем съвсем спокойно да разкрием една версия, но да развием друга. Има много причини да държим кода скрит, специално начина, по който се борим срещу спама. Скриването/затъмняването на кода за борба със спама е стандартна практика.
|
||||
@@ -0,0 +1,31 @@
|
||||
阅读其他语言版本:[русский](SECURITY-FAQru.md)、[Nederlands](SECURITY_FAQnl.md)、[Français](SECURITY-FAQfr.md)、[Türkçe](SECURITY-FAQtr.md)、[українська](SECURITY-FAQk.md)、[Polski](SECURITY-FAQpl.md)、[Deutsch](SECURITY-FAQde.md)
|
||||
|
||||
# 安全
|
||||
|
||||
### 您是否在跟踪我的观看历史记录?
|
||||
|
||||
否。扩展程序的代码是公开的,您可以自己查看。发送的唯一信息是视频 ID,这是获取视频的不喜欢计数所必需的。没有发送其他标头。在通信层上,您的公共 IP 以及发出请求的时间将暴露给服务器。但是,这些都不能以任何方式唯一地识别您。假设一个零信任环境,我们能得到的最好东西就是动态 IP。今天是你的,明天是你的邻居的。如果你真的担心你的 IP 被追踪,你可能已经使用了 VPN。
|
||||
|
||||
### 如果我不喜欢,你能唯一地识别我吗?
|
||||
|
||||
是。当你不喜欢某个视频时,我们会为你创建一个随机生成的唯一 ID,该 ID 不与你的 Google 帐户绑定。这样做是为了防止机器人。但是没有办法将这个随机 ID 绑定到你或你的个人 YouTube 帐户。
|
||||
|
||||
### 你到底有什么信息?
|
||||
|
||||
只有视频 ID。不是你的评论,不是你的用户名,不是你与谁分享了视频,也不是任何其他元数据。什么都没有。只有视频 ID。
|
||||
|
||||
### 我的 IP 如何存储?
|
||||
|
||||
后端仅将未散列的 IP 地址保存在易失性存储器 (RAM) 中。这些地址不存储在硬盘上,因此不会被记录。我们对 IP 地址进行散列,然后将其存储起来。这样做是为了防止数据库遭到破坏。
|
||||
|
||||
### 我听到一些关于 OAuth 和访问我的 YouTube 帐户的讨论!
|
||||
|
||||
此功能是可选的,并且非常可选择加入。如果您是 YouTube 创作者,并且想与我们分享您的不喜欢统计数据,您可以这样做。 [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) 的结构方式实际上非常安全。您可以随时撤销对您帐户的访问权限,并可以向我们授予非常具体的权限。我们不会要求任何不必要的权限。我们只会要求查看您的视频统计数据的权限。
|
||||
|
||||
### 我如何信任这个不喜欢计数?
|
||||
|
||||
我们已经实施了防止机器人攻击的措施,并将继续努力提高机器人预防系统的有效性:这将帮助我们将不喜欢计数保持为实际计数的良好代表。当然,它永远不会 100% 准确,因此由您决定是否信任计数。
|
||||
|
||||
### 您为什么不共享后端代码?
|
||||
|
||||
我们会在某个时候共享它 - 但现在真的没有真正的理由共享它。它给人一种虚假的安全感 - 因为在零信任系统中,我们也可以披露一个版本但部署另一个版本。隐藏代码的原因有很多,特别是我们如何对抗垃圾邮件。隐藏/混淆垃圾邮件处理代码是一种相当标准的做法。
|
||||
@@ -0,0 +1,32 @@
|
||||
Read this in other languages: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md)
|
||||
|
||||
|
||||
# Sicherheit
|
||||
|
||||
### Verfolgen Sie meine Anzeigehistorie?
|
||||
|
||||
Nein. Der Code der Erweiterung ist öffentlich und Sie können ihn selbst einsehen. Die einzige übermittelte Information ist die Video-ID, die benötigt wird, um die Anzahl der Dislikes für die Videos abzurufen. Es werden keine zusätzlichen Header gesendet. Über die Kommunikationsschicht wird Ihre öffentliche IP-Adresse an den Server übermittelt, sowie die Zeit, zu der die Anfrage gestellt wurde. Keine dieser Informationen identifiziert Sie auf eindeutige Weise. Unter der Annahme einer Zero-Trust-Umgebung ist das Beste, was wir bekommen können, eine dynamische IP. Die heute Ihnen gehört, gehört morgen Ihrem Nachbarn. Wenn Sie sich wirklich Sorgen machen, dass Ihre IP zurückverfolgt wird, verwenden Sie wahrscheinlich bereits ein VPN.
|
||||
|
||||
### Können Sie mich eindeutig identifizieren, wenn ich etwas dislike?
|
||||
|
||||
Ja. Wenn Sie ein Video nicht mögen, erstellen wir eine zufällig generierte eindeutige ID für Sie, die nicht mit Ihrem Google-Konto verknüpft ist. Dies geschieht, um das Botting zu verhindern. Es gibt jedoch keine Möglichkeit, diese zufällige ID mit Ihnen oder Ihrem persönlichen YouTube-Konto zu verknüpfen.
|
||||
|
||||
### Welche Informationen haben Sie genau?
|
||||
|
||||
Nur die Video-ID. Nicht Ihre Kommentare, nicht Ihren Benutzernamen, nicht mit wem Sie das Video geteilt haben, keine zusätzlichen Metadaten. Nichts. Nur die Video-ID.
|
||||
|
||||
### Wie wird meine IP gespeichert?
|
||||
|
||||
Der Backend speichert nicht gehashte IP-Adressen nur im flüchtigen Speicher (RAM). Diese Adressen werden nicht auf einer Festplatte gespeichert und daher nicht protokolliert. Wir hashen die IP-Adressen und speichern diese stattdessen. Dies geschieht, um Datenbankvandalismus zu verhindern.
|
||||
|
||||
### Ich habe etwas über OAuth gehört und den Zugriff auf mein YouTube-Konto!
|
||||
|
||||
Diese Funktion wird optional sein und sehr stark opt-in. Wenn Sie ein YouTube-Ersteller sind und Ihre Dislike-Statistiken mit uns teilen möchten, können Sie dies tun. Die Art und Weise, wie [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) strukturiert wurde, ist tatsächlich sehr sicher. Sie können den Zugriff auf Ihr Konto jederzeit widerrufen und uns sehr spezifische Berechtigungen erteilen. Wir werden nicht nach Berechtigungen fragen, die nicht erforderlich sind. Wir werden nur um Berechtigungen bitten, um Ihre Video-Statistiken anzeigen zu können.
|
||||
|
||||
### Wie kann ich diesem Dislike-Zähler vertrauen?
|
||||
|
||||
Wir haben Maßnahmen implementiert, um Bot-Angriffe zu verhindern, und werden weiterhin daran arbeiten, die Effektivität des Bot-Präventionssystems zu verbessern: Dies wird uns helfen, den Dislike-Zähler als gute Repräsentation der tatsächlichen Anzahl zu halten. Natürlich wird es nie zu 100% genau sein, also liegt es an Ihnen zu entscheiden, ob Sie dem Zähler vertrauen oder nicht.
|
||||
|
||||
### Warum teilen Sie den Backend-Code nicht?
|
||||
|
||||
Wir werden ihn irgendwann teilen - aber es gibt wirklich keinen echten Grund, ihn jetzt zu teilen. Es vermittelt ein falsches Sicherheitsgefühl - denn in einem Zero-Trust-System könnten wir genauso gut eine Version offenlegen, aber eine andere bereitstellen. Es gibt viele Gründe, den Code verborgen zu halten, insbesondere wie wir gegen Spam kämpfen. Das Verbergen/Verfremden des Spam-Behandlungscodes ist eine ziemlich standardmäßige Praxis.
|
||||
@@ -1,4 +1,4 @@
|
||||
Lisez ceci dans d'autres langues : [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), Nederlands](SECURITY_FAQnl.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md)
|
||||
Lisez ceci dans d'autres langues : [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Sécurité
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Baca ini dibahasa lain: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
|
||||
|
||||
# Keamanan
|
||||
|
||||
### Apakah kalian melacak riwayat video yang saya tonton?
|
||||
|
||||
Tidak, Kode extension bersifat publik dan kamu dapat melihatnya sendiri. Informasi yang kami ambil hanya id dari video, yang diperlukan untuk pengambilan jumlah dislike. Tidak ada tambahan data headers lainnya. Pada communication layer, IP Publik kamu akan terekspos ke server, bersamaan dengan waktu ketika requestnya dibuat. Meskipun begitu, data ini tidaklah bersifat unik atau mengidentifikasikan kamu dalam bentuk apapun. Asumsikan environment yang tidak terpercaya, hal terbaik yang dapat kami ambil adalah IP dinamis. Yang bisa saja hari ini adalah milikmu, besok milik tetanggamu. Jika kamu merasa khawati jika IP-mu dilacak, kamu mungkin telah menggunakan VPN.
|
||||
|
||||
### Bisakah kalian mengidentifikasi saya ketika saya dislike suatu video?
|
||||
|
||||
Iya. Ketika kamu dislike suatu video, kami membuat id yang digenerate secara acak untukmu yang tidak ada hubungannya dengan akun Google. Ini dilakukan untuk menghindari bot. Tapi tidak mungkin untuk mencari kamu atau akun Youtube-mu menggunakan id acak ini.
|
||||
|
||||
### Informasi apa saja yang kalian punya?
|
||||
|
||||
Hanya id dari video. Bukan komentarmu, bukan usernamemu, bukan siapa saja orang yang pernah kamu kirimkan video, bukan metadata tambahan. Tidak ada. Hanya id dari video.
|
||||
|
||||
### Bagaimana IP saya disimpan?
|
||||
|
||||
Backend menyimpan alamat IP yang tidak di hash hanya di dalam RAM. Alamat ini tidak disimpan di hard drive, sehingga tidak di log. Kami melakukan hash terhadap alamat IP dan menyimpan yang itu. Ini dilakukan untuk mencegah perusakan database.
|
||||
|
||||
### Saya pernah mendengar diskusi mengenai OAuth, dan akses ke akun Youtube saya!
|
||||
|
||||
Fitur ini bersifat opsional, dan banyak yang berpartisipasi. Jika kamu merupakan seorang Youtuber, dan mau membagikan data dislikenya kepada kami, kamu bisa melakukannya. Dengan cara [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) yang terstruktur, dan sangat aman. Kamu dapat menghapus akses ke akunmu kapanpun, dan bisa menentukan data mana yang mau dibagikan dan tidak dibagikan secara spesifik. Kami tidak akan meminta izin apapun yang bersifat tidak wajib. Kami hanya akan meminta izin untuk melihat data video kamu.
|
||||
|
||||
### Bagaimana bisa saya percaya jumlah dislike ini?
|
||||
|
||||
Kami telah melakukan tidakan pencegahan dari penyerangan bot dan akan terus mengimprovisasi keefektifan sistem dari pecegahan bot tersebut: ini akan membantu kami menjaga jumlah dislike agar tetap bisa menjadi representasi dari jumlah yang asli. Tentu jumlahnya tidak akan akurat 100% jadi pada akhirnya itu semua terserah padamu untuk percaya atau tidak dengan jumlah dislikenya.
|
||||
|
||||
### Mengapa kamu tidak memberikan kode backend?
|
||||
|
||||
Kami akan memberikannya nanti pada satu waktu - tapi tidak ada alasan khusus untuk memberikanny sekarang. Itu memberikan rasa keamanan yang salah - karena pada sistem yang tidak terpercaya, kami bisa saja memberikan versi yang berdeda dengan yang kami deploy. Ada beberapa alsan lain untuk tetap menyembunyikan kodenya, terutama untuk memerangi spam. Menyembunyikan/menyamarkan kode penanganan spam adalah praktik yang cukup standar dilakukan.
|
||||
+11
-11
@@ -1,30 +1,30 @@
|
||||
Lees dit in andere talen: [English](SECURITY_FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md)
|
||||
Lees dit in andere talen: [English](SECURITY_FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Veiligheid
|
||||
|
||||
### Houd je mijn kijkgeschiedenis bij?
|
||||
|
||||
Nee. De code van de extensie is openbaar en u kunt deze zelf zien. De enige informatie die wordt verzonden, is de video-ID, die
|
||||
nodig is om het aantal dislikes voor de video's op te halen. Er worden geen extra headers verzonden. Via de communicatielaag
|
||||
wordt uw openbare IP-adres zichtbaar voor de server, evenals het tijdstip waarop het verzoek is gedaan. Geen van deze
|
||||
identificeert u echter op enigerlei wijze uniek. Uitgaande van een zero-trust-omgeving, is het beste wat we kunnen krijgen een
|
||||
dynamisch IP-adres. Wat vandaag van jou is, morgen van je buurman. Als u zich echt zorgen maakt dat uw IP-adres wordt
|
||||
Nee. De code van de extensie is openbaar en u kunt deze zelf zien. De enige informatie die wordt verzonden, is de video-ID, die
|
||||
nodig is om het aantal dislikes voor de video's op te halen. Er worden geen extra headers verzonden. Via de communicatielaag
|
||||
wordt uw openbare IP-adres zichtbaar voor de server, evenals het tijdstip waarop het verzoek is gedaan. Geen van deze
|
||||
identificeert u echter op enigerlei wijze uniek. Uitgaande van een zero-trust-omgeving, is het beste wat we kunnen krijgen een
|
||||
dynamisch IP-adres. Wat vandaag van jou is, morgen van je buurman. Als u zich echt zorgen maakt dat uw IP-adres wordt
|
||||
getraceerd, gebruikt u waarschijnlijk al een VPN.
|
||||
|
||||
### Kun je me uniek identificeren als ik een hekel heb?
|
||||
|
||||
Ja. Als je een video niet leuk vindt, maken we een willekeurig gegenereerde unieke ID voor je die niet is gekoppeld aan je
|
||||
Google-account. Dit wordt gedaan om botting te voorkomen. Maar er is geen manier om deze willekeurige id aan jou of je
|
||||
Ja. Als je een video niet leuk vindt, maken we een willekeurig gegenereerde unieke ID voor je die niet is gekoppeld aan je
|
||||
Google-account. Dit wordt gedaan om botting te voorkomen. Maar er is geen manier om deze willekeurige id aan jou of je
|
||||
persoonlijke YouTube-account te koppelen.
|
||||
|
||||
### Welke informatie heb je precies?
|
||||
|
||||
Alleen de video-ID. Niet je opmerkingen, niet je gebruikersnaam, niet met wie je de video hebt gedeeld, geen aanvullende
|
||||
Alleen de video-ID. Niet je opmerkingen, niet je gebruikersnaam, niet met wie je de video hebt gedeeld, geen aanvullende
|
||||
metadata. Niks. Alleen de video-ID.
|
||||
|
||||
### Hoe wordt mijn IP opgeslagen?
|
||||
|
||||
De backend bewaart niet-gehashte IP-adressen alleen in vluchtig geheugen (RAM). Deze adressen worden niet opgeslagen op een
|
||||
De backend bewaart niet-gehashte IP-adressen alleen in vluchtig geheugen (RAM). Deze adressen worden niet opgeslagen op een
|
||||
harde schijf en worden daarom niet gelogd. We hashen de IP-adressen en dat wordt in plaats daarvan opgeslagen. Dit wordt gedaan
|
||||
om databasevandalisme te voorkomen.
|
||||
|
||||
@@ -38,4 +38,4 @@ We hebben maatregelen genomen om botaanvallen te voorkomen en zullen blijven wer
|
||||
|
||||
### Waarom deel je de backend-code niet?
|
||||
|
||||
We zullen het op een gegeven moment delen - maar er is echt geen echte reden om het nu te delen. Het geeft een vals gevoel van veiligheid - omdat we in een zero-trust-systeem net zo goed de ene versie kunnen onthullen, maar een andere kunnen implementeren. Er zijn tal van redenen om de code verborgen te houden, met name hoe we spam bestrijden. Het verbergen/verduisteren van de spamverwerkingscode is een vrij standaardpraktijk.
|
||||
We zullen het op een gegeven moment delen - maar er is echt geen echte reden om het nu te delen. Het geeft een vals gevoel van veiligheid - omdat we in een zero-trust-systeem net zo goed de ene versie kunnen onthullen, maar een andere kunnen implementeren. Er zijn tal van redenen om de code verborgen te houden, met name hoe we spam bestrijden. Het verbergen/verduisteren van de spamverwerkingscode is een vrij standaardpraktijk.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Security
|
||||
|
||||
### Czy śledzicie moją historię wyświetleń?
|
||||
|
||||
Nie. Kod rozszerzenia jest publiczny i można samemu to zobaczyć. Jedyne informacje, które są wysyłane, to ID filmu, które jest wymagane do pobrania liczby łapek w dół dla filmów. Nie są wysyłane żadne dodatkowe nagłówki. W warstwie sieciowej, Twój adres IP będzie jawny dla serwera wraz z czasem wykonania żądania. Jednakże, żadne z tych danych nie identyfikują Ciebie jednoznacznie w żaden sposób. Zakładając środowisko zerowego zaufania, najczulsze dane, jakie możemy otrzymać, jest dynamiczny adres IP, który dzisiaj jest Twój, a jutro Twojego sąsiada. Jeżeli boisz się śledzenia poprzez adres IP, pewnie już korzystasz z VPN.
|
||||
|
||||
### Czy możecie mnie jednoznacznie zidentyfikować, jeżeli zostawię łapkę w dół?
|
||||
|
||||
Tak. Kiedy zostawiasz łapkę w dół, tworzymy losowo generowane ID dla Ciebie, które nie jest związane z Twoim kontem Google. Powodem takiego rozwiązania jest zapobieganie botowaniu. Mimo to, nie ma sposobu powiązania tego losowego ID z Tobą lub Twoim osobistym kontem Google.
|
||||
|
||||
### Jakie informacje wy macie, konkretnie?
|
||||
|
||||
Tylko ID filmu. Komentarze - nie. Nazwa użytkownika - nie. Osoby, którym udostępniłeś film - nie. Jakiekolwiek dodatkowe metadane - nie. Nic. Tylko ID filmu.
|
||||
|
||||
### Jak mój adres IP jest przechowywany?
|
||||
|
||||
Backend trzyma niehashowane adresy IP tylko w pamięci zmiennej (RAM). Te adresy nie są przechowywane na dysku twardym, przez co nie są rejestrowane. Zamiast tego przechowujemy zhashowany adres IP. Jest to zrobione po to aby zapobiec wandalizmom.
|
||||
|
||||
### Słyszałem jakąś dyskusję o OAuth, i dostępie do mojego konta YouTube.
|
||||
|
||||
Ta funkcjonalność będzie opcjonalna i z całą pewnością wymagała ręcznego dołączenia. Jeżeli jesteś twórcą na YouTube i chcesz podzielić się z nami swoimi statystykami, to możesz. Sposób w jaki [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) został ustrukturyzowany jest właściwie bardzo bezpieczny. Możesz wycofać dostęp do konta w każdej chwili i dać nam specyficzne uprawnienia. Nie będziemy prosić o żadne uprawnienia, które nie są wymagane. Poprosimy tylko o możliwość wyświetlenia statystyk filmów.
|
||||
|
||||
### Jak bardzo mogę ufać licznikowi łapek w dół?
|
||||
|
||||
Zaimplementowaliśmy środki zapobiegające atakom botów i będziemy kontynuować pracę nad systemem zapobiegającym botom: to pomoże nam utrzymać licznik łapek w dół jako dobrą reprezentację prawdziwej wartości. Oczywiście, wartość ta nigdy nie będzie w 100% dokładna, więc to czy zaufasz tej liczbie zależy tylko od Ciebie.
|
||||
|
||||
### Dlaczego nie udostępnicie kodu backendu?
|
||||
|
||||
Kiedyś go udostępnimy - ale nie ma za bardzo sensu robić to teraz. Wprowadziłoby to fałszywe poczucie bezpieczeństwa - bo w systemie zerowego zaufania równie dobrze moglibyśmy przedstawić jeden system, a uruchomić inny. Jest dużo powodów do ukrywania kodu, specyficznie do walki ze spamem. Ukrywanie/Obfuskacja kodu jest dość standardową praktyką.
|
||||
@@ -0,0 +1,31 @@
|
||||
Leia isso em outros idiomas: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md)
|
||||
|
||||
# Segurança
|
||||
|
||||
### Are you tracking my viewing history?
|
||||
|
||||
No. The extension's code is public and you can see it for yourself. The only information being sent is the video ID, which is required to fetch the dislike count for the videos. There are no additional headers being sent. Over the communication layer, your public IP will be exposed to the server, as well as the time when the request was made. However, none of these are uniquely identifying you in any way. Assuming a zero-trust environment, the best we could get is a dynamic IP. Which, today is yours, tomorrow is your neighbor's. If you're really worried about your IP being traced, you probably already use a VPN.
|
||||
|
||||
### Can you uniquely identify me if I dislike?
|
||||
|
||||
Yes. When you dislike a video, we create a randomly generated unique ID for you that is not tied to your Google account. This is done to prevent botting. But there is no way to tie this random Id to you or your personal YouTube account.
|
||||
|
||||
### Quais as informações que você tem, exatamente?
|
||||
|
||||
Somente seu video ID. Não temos seus comentarios, seu nome de usuario, ou qualquer outro Not your comments, not your username, not who you've shared the video with, not any additional metadata. NADA. Apenas seu Video ID.
|
||||
|
||||
### How is my IP stored?
|
||||
|
||||
The backend keeps unhashed IP addresses in volatile memory (RAM) only. These addresses aren't stored on a hard drive, and therefore aren't logged. We hash the IP addresses, and that's stored instead. This is done to prevent database vandalism.
|
||||
|
||||
### I heard some discussion over OAuth, and access to my YouTube account!
|
||||
|
||||
This feature will be optional, and very much opt-in. If you are a YouTube creator, and would like to share your dislike stats with us, you can. The way [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) was structured, it's actually very secure. You can revoke access to your account at any time, and can give very specific permissions to us. We will not ask for any permissions that aren't required. We'll only ask for permissions to view your video stats.
|
||||
|
||||
### How can I trust this dislike count?
|
||||
|
||||
We have implemented measures to prevent bot attacks and are gonna continue to work on improving the effectiveness of the bot prevention system: this will help us keep the dislike count as a good representative of the actual count. Of course it will never be 100% accurate so it's up to you to decide whether you trust the count or not.
|
||||
|
||||
### Why don't you share the backend code?
|
||||
|
||||
We will share it at some point - but there's really no real reason to share it right now. It gives a false sense of security - because in a zero-trust system, we could just as well disclose one version but deploy another. There are plenty of reasons to keep the code hidden, specifically, how we battle spam. Hiding/Obfuscating the spam handling code is a fairly standard practice.
|
||||
@@ -1,4 +1,4 @@
|
||||
Прочитать на других языках: [English](SECURITY-FAQ.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md)
|
||||
Прочитать на других языках: [English](SECURITY-FAQ.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Безопасность
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [українська](SECURITY-FAQuk.md)
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Güvenlik
|
||||
|
||||
|
||||
+31
-31
@@ -1,31 +1,31 @@
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md)
|
||||
|
||||
# Безпека
|
||||
|
||||
### Ви відстежуєте мою історію переглядів?
|
||||
|
||||
Ні. Код розширення знаходиться у відкритому доступі, і ви можете ознайомитись з ним самостійно. Єдина інформація, що передається - це ID відео, який необхідний для отримання даних про кількість відміток. Жодних додаткових заголовків не передається. На комунікаційному рівні серверу буде передано вашу публічну IP-адресу, а також час, коли було зроблено запит. Однак ніщо з цього ніяк не здатно вас ідентифіквати. Припускаючи середовище з нульовою довірою, найцінніше, що ми можемо отримати це динамічний IP. Який сьогодні ваш, а завтра – вашого сусіда. Якщо ви дійсно турбуєтеся про те, що ваш IP може бути відстежений, ви, ймовірно, вже використовуєте якийсь VPN.
|
||||
|
||||
### Чи можете ви однозначно ідентифікувати мене, коли я залишаю відмітку «Не подобається»?
|
||||
|
||||
Так. Коли ви залишаєте відео позначку «Не подобається», ми створюємо для вас випадковий унікальний ID, який не прив'язаний до вашого облікового запису Google. Це робиться для уникнення атаки ботів. Але немає жодного способу зв'язати цей випадковий ID до вас або вашого особистого облікового запису YouTube.
|
||||
|
||||
### Якою саме інформацією ви володієте?
|
||||
|
||||
Лише ID відео. Ні ваших коментарів, ні вашого імені користувача, ні того, з ким ви поділилися відео, ні будь-яких інших додаткових метаданих. Нічого. Лише ID відео.
|
||||
|
||||
### Як зберігається моя IP-адреса?
|
||||
|
||||
Внутрішня частина нашого сервера зберігає нехешовані IP-адреси лише в енергозалежній пам'яті (ОЗП). Ці адреси не зберігаються на жорсткому диску і тому ніде не записані. Ми хешуємо IP-адреси, і вони зберігаються замість інших. Це зроблено для запобігання вандалізму у базі даних.
|
||||
|
||||
### Я чув дискусію щодо OAuth і доступу до мого облікового запису YouTube!
|
||||
|
||||
Ця функція буде необов'язковою та дуже необхідною. Якщо ви творець YouTube і хочете поділитися з нами кількістю ваших відміток «Не подобається», ви зможете це зробити. За структурою [OAuth](https://uk.wikipedia.org/wiki/OAuth#:~:text=%D0%B1%D0%B5%D0%B7%20%D0%BD%D0%B5%D0%BE%D0%B1%D1%85%D1%96%D0%B4%D0%BD%D0%BE%D1%81%D1%82%D1%96%20%D0%B2%D0%B2%D0%BE%D0%B4%D1%83%20%D1%96%D0%BC%D0%B5%D0%BD%D1%96%20%D0%BA%D0%BE%D1%80%D0%B8%D1%81%D1%82%D1%83%D0%B2%D0%B0%D1%87%D0%B0%20%D1%82%D0%B0%20%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8E) надбезпечний. Ви зможете відкликати доступ до свого облікового запису в будь-яку мить, і зможете дати нам обмежені дозволи. Ми не будемо вимагати жодних дозволів, крім необжідних. Ми будемо вимагати лише дозволи перегляду статистики ваших відео.
|
||||
|
||||
### Наскільки достовірна ця кількость відміток «Не подобається»?
|
||||
|
||||
Ми вжили заходів щодо запобігання атакам ботів і збираємося продовжувати працювати над підвищенням ефективності цієї сисеми надалі: це допоможе нам зберегти підрахунок відміток «Не подобається» як хороше представлення фактичної кількості. Звичайно, він ніколи не буде точним на всі 100%, тож ви самі вирішуєте, довіряти йому чи ні.
|
||||
|
||||
### Чому б вам не поділитися внутрішнім кодом вашого сервера?
|
||||
|
||||
Ми поділимося ним у якийсь момент – але зараз немає жодних причин це робити. Це дасть хибне почуття безпеки - адже в системі з нульовою довірою ми можемо з таким самим успіхом поділитися однією версією, але розгорнути іншу. Є багато причин тримати код у таємниці, зокрема, як ми боремося зі спамом. Приховування та затьмарення коду обробки спаму є доволі стандартною практикою.
|
||||
Read this in other languages: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md), [العربية](SECURITY-FAQar.md), [Bahasa Indonesia](SECURITY-FAQid.md), [中文](SECURITY-FAQcn.md), [български](SECURITY-FAQbg.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
# Безпека
|
||||
|
||||
### Ви відстежуєте мою історію переглядів?
|
||||
|
||||
Ні. Код розширення знаходиться у відкритому доступі, і ви можете ознайомитись з ним самостійно. Єдина інформація, що передається - це ID відео, який необхідний для отримання даних про кількість відміток. Жодних додаткових заголовків не передається. На комунікаційному рівні серверу буде передано вашу публічну IP-адресу, а також час, коли було зроблено запит. Однак ніщо з цього ніяк не здатно вас ідентифіквати. Припускаючи середовище з нульовою довірою, найцінніше, що ми можемо отримати це динамічний IP. Який сьогодні ваш, а завтра – вашого сусіда. Якщо ви дійсно турбуєтеся про те, що ваш IP може бути відстежений, ви, ймовірно, вже використовуєте якийсь VPN.
|
||||
|
||||
### Чи можете ви однозначно ідентифікувати мене, коли я залишаю відмітку «Не подобається»?
|
||||
|
||||
Так. Коли ви залишаєте відео позначку «Не подобається», ми створюємо для вас випадковий унікальний ID, який не прив'язаний до вашого облікового запису Google. Це робиться для уникнення атаки ботів. Але немає жодного способу зв'язати цей випадковий ID до вас або вашого особистого облікового запису YouTube.
|
||||
|
||||
### Якою саме інформацією ви володієте?
|
||||
|
||||
Лише ID відео. Ні ваших коментарів, ні вашого імені користувача, ні того, з ким ви поділилися відео, ні будь-яких інших додаткових метаданих. Нічого. Лише ID відео.
|
||||
|
||||
### Як зберігається моя IP-адреса?
|
||||
|
||||
Внутрішня частина нашого сервера зберігає нехешовані IP-адреси лише в енергозалежній пам'яті (ОЗП). Ці адреси не зберігаються на жорсткому диску і тому ніде не записані. Ми хешуємо IP-адреси, і вони зберігаються замість інших. Це зроблено для запобігання вандалізму у базі даних.
|
||||
|
||||
### Я чув дискусію щодо OAuth і доступу до мого облікового запису YouTube!
|
||||
|
||||
Ця функція буде необов'язковою та дуже необхідною. Якщо ви творець YouTube і хочете поділитися з нами кількістю ваших відміток «Не подобається», ви зможете це зробити. За структурою [OAuth](https://uk.wikipedia.org/wiki/OAuth#:~:text=%D0%B1%D0%B5%D0%B7%20%D0%BD%D0%B5%D0%BE%D0%B1%D1%85%D1%96%D0%B4%D0%BD%D0%BE%D1%81%D1%82%D1%96%20%D0%B2%D0%B2%D0%BE%D0%B4%D1%83%20%D1%96%D0%BC%D0%B5%D0%BD%D1%96%20%D0%BA%D0%BE%D1%80%D0%B8%D1%81%D1%82%D1%83%D0%B2%D0%B0%D1%87%D0%B0%20%D1%82%D0%B0%20%D0%BF%D0%B0%D1%80%D0%BE%D0%BB%D1%8E) надбезпечний. Ви зможете відкликати доступ до свого облікового запису в будь-яку мить, і зможете дати нам обмежені дозволи. Ми не будемо вимагати жодних дозволів, крім необжідних. Ми будемо вимагати лише дозволи перегляду статистики ваших відео.
|
||||
|
||||
### Наскільки достовірна ця кількость відміток «Не подобається»?
|
||||
|
||||
Ми вжили заходів щодо запобігання атакам ботів і збираємося продовжувати працювати над підвищенням ефективності цієї сисеми надалі: це допоможе нам зберегти підрахунок відміток «Не подобається» як хороше представлення фактичної кількості. Звичайно, він ніколи не буде точним на всі 100%, тож ви самі вирішуєте, довіряти йому чи ні.
|
||||
|
||||
### Чому б вам не поділитися внутрішнім кодом вашого сервера?
|
||||
|
||||
Ми поділимося ним у якийсь момент – але зараз немає жодних причин це робити. Це дасть хибне почуття безпеки - адже в системі з нульовою довірою ми можемо з таким самим успіхом поділитися однією версією, але розгорнути іншу. Є багато причин тримати код у таємниці, зокрема, як ми боремося зі спамом. Приховування та затьмарення коду обробки спаму є доволі стандартною практикою.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
Đọc bằng các ngôn ngữ khác: [English](SECURITY-FAQ.md), [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Tiếng Việt](SECURITY-FAQvi.md)
|
||||
|
||||
|
||||
# Tính Bảo mật
|
||||
|
||||
### Tiện ích mở rộng này có theo dõi lịch sử xem vi-đê-ô của tôi không?
|
||||
|
||||
Không. Mã nguồn của tiện ích mở rộng này được cung cấp công khai và bạn có thể kiểm tra mã nguồn nếu muốn. Thông tin duy nhất được tiện ích này gửi đi là ID của vi-đê-ô vì việc truy vấn số lượt đánh giá "không thích" cần ID của vi-đê-ô. Không thông tin nào khác được gửi đi. Ở lớp truyền tải, máy chủ sẽ biết được địa chỉ IP của bạn cũng như thời điểm mà truy vấn được tạo. Tuy nhiên, những thông tin này không thể định danh chính xác bạn. Giả sử với môi trường bất tín, chúng tôi cùng lắm chỉ có thể biết được địa chỉ IP động. Địa chỉ IP động là của bạn ngày hôm nay, nhưng sẽ là của người khác vào ngày mai. Nếu bạn lo lắng rằng địa chỉ IP của mình bị theo dõi, bạn có thể sử dụng mạng riêng ảo.
|
||||
|
||||
### Nhóm phát triển có thể định danh chính xác tôi không nếu như tôi có đánh giá "không thích"?
|
||||
|
||||
Có. Khi bạn đưa ra đánh giá "không thích" với một vi-đê-ô, chúng tội sẽ tạo ra một số định danh duy nhất ngẫu nhiên cho bạn, nhưng số định danh này không có bất kì mối liên hệ nào với tài khoản Google của bạn. Việc tạo số định danh là nhằm tránh người dùng bot. Không có bất kì cách nào để liên hệ số định danh này với bạn hay với tài khoản YouTube của bạn.
|
||||
|
||||
### Chính xác thì thông tin nào được thu thập?
|
||||
|
||||
Chỉ có ID của vi-đê-ô sẽ được thu thập. Bình luận của bạn, không. Tên người dùng của bạn, không. Thông tin của người mà bạn chia sẻ vi-đê-ô, không. Siêu dữ liệu khác, không. Không có bất kì thông tin nào khác được thu thập. Chỉ duy nhất ID của vi-đê-ô.
|
||||
|
||||
### Địa chỉ IP của tôi được lưu trữ ra sao?
|
||||
|
||||
Đầu sau chỉ giữ địa chỉ IP không được băm trong bộ nhớ khả biến (RAM). Những địa chỉ này không được lưu vào bộ nhớ bất biến (như ổ đĩa cứng) và như vậy không bị ghi chép lại. Chúng tôi băm địa chỉ IP rồi mới lưu lại những địa chỉ đã băm. Việc lưu địa chỉ đã băm là nhằm tránh việc phá hoại cơ sở dữ liệu.
|
||||
|
||||
### Tôi có bắt gặp một số cuộc thảo luận về OAuth cũng như về việc truy cập tài khoản YouTube của tôi!
|
||||
|
||||
Tính năng sắp có này là tùy chọn. Nếu bạn là nhà sáng tạo nội dung trên YouTube và bạn mong muốn chia sẽ số liệu đánh giá "không thích" với chúng tôi, chúng tôi rất hoan nghênh. Cách thức hoạt động của [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) giúp đảm bảo tính bảo mật. Bạn có thể hủy bỏ các truy cập vào tài khoản của bạn bất cứ lúc nào bạn muốn và cũng như có thể đưa ra bất kì sự cho phép cụ thể nào. Chúng tôi sẽ không đòi hỏi bất kì sự cho phép nào không cần thiết. Chúng tôi chỉ xin được cấp phép để xem số liệu về vi-đê-ô của bạn.
|
||||
|
||||
### Số lượt đánh giá "không thích" có đáng tin cậy không?
|
||||
|
||||
Chúng tôi đã và đang thực hiện nhiều biện pháp phòng chống việc tấn công từ người dùng bot và sẽ tiếp duy trì việc tăng cường tính hiệu quả của hệ thống phòng chống bot: Điều này sẽ giúp chúng tôi đảm bảo rằng số lượt đánh giá "không thích" của tiện ích phản ánh đúng số lượt đánh giá thực. Tất nhiên là không thể đảm bảo chính xác hoàn toàn, cho nên tùy thuộc vào bạn có muốn tin hay không.
|
||||
|
||||
### Tại sao mã nguồn của đầu sau không được chia sẻ?
|
||||
|
||||
Chúng tôi sẽ chia sẻ mã nguồn của đầu sau trong tương lại. Nhưng không có lí do gì thực sự cấp thiết để phải chia sẻ mã nguồn vào thời điểm hiện nay. Việc chia sẻ mã nguồn sẽ tạo ra sự an tâm hão huyền vì trong môi trường bất tín, chúng tôi có thể chia sẻ mã nguồn này nhưng lại dùng mã nguồn khác. Có nhiều lí do để giữ bí mật mã nguồn, trong đó có việc chống tương tác rác. Che giấu/Làm rối mã xử lý tương tác rác là biện pháp tiêu chuẩn.
|
||||
@@ -0,0 +1,31 @@
|
||||
اقرأ هذا بلغات أخرى: [русский](SECURITY-FAQru.md), [Nederlands](SECURITY_FAQnl.md), [Français](SECURITY-FAQfr.md), [Türkçe](SECURITY-FAQtr.md), [українська](SECURITY-FAQuk.md), [Polski](SECURITY-FAQpl.md), [Deutsch](SECURITY-FAQde.md)
|
||||
|
||||
# الأمان
|
||||
|
||||
### هل تقومون بتتبع سجل المشاهدة الخاص بي؟
|
||||
|
||||
لا. كود الإضافة عام ويمكنك رؤيته بنفسك. المعلومات الوحيدة التي يتم إرسالها هي معرف الفيديو، وهو مطلوب لجلب عدد عدم الإعجاب للفيديوهات. لا يتم إرسال أي رؤوس إضافية. عبر طبقة الاتصال، سيتم كشف عنوان IP العام الخاص بك للخادم، وكذلك الوقت الذي تم فيه الطلب. ومع ذلك، لا يمكن لأي من هذه المعلومات تحديد هويتك بشكل فريد بأي طريقة. بافتراض بيئة عدم الثقة، أفضل ما يمكننا الحصول عليه هو عنوان IP ديناميكي. والذي، اليوم هو لك، وغدًا هو لجارك. إذا كنت قلقًا حقًا بشأن تتبع عنوان IP الخاص بك، فمن المحتمل أنك تستخدم VPN بالفعل.
|
||||
|
||||
### هل يمكنكم تحديد هويتي بشكل فريد إذا قمت بعدم الإعجاب؟
|
||||
|
||||
نعم. عندما تقوم بعدم الإعجاب بفيديو، نقوم بإنشاء معرف فريد عشوائي لك غير مرتبط بحساب Google الخاص بك. يتم ذلك لمنع التلاعب. ولكن لا توجد طريقة لربط هذا المعرف العشوائي بك أو بحساب YouTube الشخصي الخاص بك.
|
||||
|
||||
### ما هي المعلومات التي لديكم بالضبط؟
|
||||
|
||||
فقط معرف الفيديو. ليس تعليقاتك، وليس اسم المستخدم الخاص بك، وليس من شاركت الفيديو معه، وليس أي بيانات إضافية. لا شيء. فقط معرف الفيديو.
|
||||
|
||||
### كيف يتم تخزين عنوان IP الخاص بي؟
|
||||
|
||||
يحتفظ الخلفية بعناوين IP غير المشفرة في الذاكرة المتطايرة (RAM) فقط. هذه العناوين لا يتم تخزينها على القرص الصلب، وبالتالي لا يتم تسجيلها. نقوم بتشفير عناوين IP، ويتم تخزين هذا التشفير بدلاً من ذلك. يتم ذلك لمنع تخريب قاعدة البيانات.
|
||||
|
||||
### سمعت بعض النقاش حول OAuth والوصول إلى حساب YouTube الخاص بي!
|
||||
|
||||
هذه الميزة ستكون اختيارية، وبشكل كبير تعتمد على الموافقة. إذا كنت منشئ محتوى على YouTube، وترغب في مشاركة إحصائيات عدم الإعجاب الخاصة بك معنا، يمكنك ذلك. الطريقة التي تم بها هيكلة [OAuth](https://en.wikipedia.org/wiki/OAuth#:~:text=but%20without%20giving%20them%20the%20passwords.) آمنة جدًا في الواقع. يمكنك إلغاء الوصول إلى حسابك في أي وقت، ويمكنك منحنا أذونات محددة جدًا. لن نطلب أي أذونات غير مطلوبة. سنطلب فقط الأذونات لعرض إحصائيات الفيديو الخاصة بك.
|
||||
|
||||
### كيف يمكنني الوثوق بعدد عدم الإعجاب هذا؟
|
||||
|
||||
لقد قمنا بتنفيذ تدابير لمنع هجمات الروبوتات وسنواصل العمل على تحسين فعالية نظام منع الروبوتات: سيساعدنا ذلك في الحفاظ على عدد عدم الإعجاب كممثل جيد للعدد الفعلي. بالطبع لن يكون دقيقًا بنسبة 100% لذا الأمر متروك لك لتقرر ما إذا كنت تثق في العدد أم لا.
|
||||
|
||||
### لماذا لا تشاركون كود الخلفية؟
|
||||
|
||||
سنشاركه في وقت ما - ولكن لا يوجد سبب حقيقي لمشاركته الآن. إنه يعطي إحساسًا زائفًا بالأمان - لأنه في نظام عدم الثقة، يمكننا الكشف عن إصدار واحد ولكن نشر آخر. هناك العديد من الأسباب للحفاظ على الكود مخفيًا، تحديدًا، كيفية مكافحة البريد العشوائي. إخفاء/تشويش كود معالجة البريد العشوائي هو ممارسة قياسية إلى حد ما.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Read this in other languages: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](READMEtr.md)
|
||||
Read this in other languages: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
|
||||
|
||||
**Contents**
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Read this in other languages: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md)
|
||||
|
||||
**İçerikler**
|
||||
|
||||
- [Bələdçilər](#rehberler)
|
||||
- [Tez-tez verilən suallar](#sss'ler)
|
||||
<!-- - [Tez-tez verilən suallar](#sss'ler)
|
||||
- [Digər Siyahılar](#diğer-listeler) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Bələdçilər
|
||||
|
||||
- [Yükləyin, quraşdırın və istifadə edin](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
|
||||
- [Problemlərin aradan qaldırılması](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
|
||||
<!-- - [Tez-tez verilən suallar](FAQaz.md)
|
||||
- [Bugları nə vaxt və necə bildirmək olar](Guide__Bug_Reporting.md)
|
||||
- [Töhfə](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTINGtr.md) -->
|
||||
<!-- - [Vikini necə yeniləmək olar](/) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Tez-tez verilən suallar
|
||||
|
||||
- [Ümumi](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQaz.md)
|
||||
- [Təhlükəsizlik](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQaz.md)
|
||||
|
||||
<!-- - [Təhlükəsizlik](FAQ_Privacy.md)
|
||||
- [Texniki](FAQ_Technical.md)
|
||||
- [İstehsalçılar](FAQ_Creators.md)
|
||||
|
||||
<br>
|
||||
|
||||
## Digər Siyahılar
|
||||
|
||||
- [Common Problems](Common_Problems.md)
|
||||
- [Repeated Questions](Repeated_Questions.md)
|
||||
- [Repeated Feature requests](Repeated_Feature_requests.md)
|
||||
- [Repeated Issues](Repeated_Issues.md) -->
|
||||
@@ -0,0 +1,39 @@
|
||||
阅读其他语言版本:[English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
|
||||
|
||||
**目录**
|
||||
|
||||
- [指南](#guides)
|
||||
- [常见问题](#faqs)
|
||||
<!-- - [常见问题](#faqs)
|
||||
- [其他列表](#other-lists) -->
|
||||
|
||||
<br>
|
||||
|
||||
## 指南
|
||||
|
||||
- [下载、安装和使用](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
|
||||
- [故障排除](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
|
||||
<!-- - [常见问题](FAQ.md)
|
||||
- [何时及如何报告错误](Guide__Bug_Reporting.md)
|
||||
- [贡献](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
|
||||
<!-- - [如何更新 wiki](/) -->
|
||||
|
||||
<br>
|
||||
|
||||
## 常见问题
|
||||
|
||||
- [常规](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQcn.md)
|
||||
- [安全](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQcn.md)
|
||||
|
||||
<!-- - [隐私](FAQ_Privacy.md)
|
||||
- [技术](FAQ_Technical.md)
|
||||
- [创作者](FAQ_Creators.md)
|
||||
|
||||
<br>
|
||||
|
||||
## 其他列表
|
||||
|
||||
- [常见问题](Common_Problems.md)
|
||||
- [重复的问题](Repeated_Questions.md)
|
||||
- [重复的功能请求](Repeated_Feature_requests.md)
|
||||
- [重复的问题](Repeated_Issues.md) -->
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Lisez ceci dans d'autres langues : [English](readme.md), [Nederlands](readmenl.md), [Türkçe](READMEtr.md)
|
||||
Lisez ceci dans d'autres langues : [English](readme.md), [Nederlands](readmenl.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
|
||||
|
||||
**Contenu**
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Baca ini dibahasa lain: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
|
||||
|
||||
**Isi**
|
||||
|
||||
- [Petunjuk](#petunjuk)
|
||||
- [Pertanyaan yang Sering Ditanyakan](#pertanyaan-yang-sering-ditanyakan)
|
||||
<!-- - [FAQs](#faqs)
|
||||
- [Other Lists](#other-lists) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Petunjuk
|
||||
|
||||
- [Mengunduh, Menginstall, dan Menggunakan](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
|
||||
- [Troubleshooting](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
|
||||
<!-- - [FAQ](FAQ.md)
|
||||
- [When & How to Report Bugs](Guide__Bug_Reporting.md)
|
||||
- [Contributing](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
|
||||
<!-- - [How to update wiki](/) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Pertanyaan yang Sering Ditanyakan
|
||||
|
||||
- [Umum](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQ.md)
|
||||
- [Keamanan](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md)
|
||||
|
||||
<!-- - [Privacy](FAQ_Privacy.md)
|
||||
- [Technical](FAQ_Technical.md)
|
||||
- [Creators](FAQ_Creators.md)
|
||||
|
||||
<br>
|
||||
|
||||
## Other Lists
|
||||
|
||||
- [Common Problems](Common_Problems.md)
|
||||
- [Repeated Questions](Repeated_Questions.md)
|
||||
- [Repeated Feature requests](Repeated_Feature_requests.md)
|
||||
- [Repeated Issues](Repeated_Issues.md) -->
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Read this in other languages: [English](readme.md), [Français](readmefr.md), [Türkçe](READMEtr.md)
|
||||
Read this in other languages: [English](readme.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
|
||||
|
||||
**Inhoud**
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Leia isso em outros idiomas: [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md)
|
||||
|
||||
**Contents**
|
||||
|
||||
- [Guides](#guides)
|
||||
- [FAQs](#faqs)
|
||||
<!-- - [FAQs](#faqs)
|
||||
- [Other Lists](#other-lists) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Guides
|
||||
|
||||
- [Downloading, Installing & Using](https://github.com/Anarios/return-youtube-dislike/wiki/Downloading,-Installing-&-Using)
|
||||
- [Troubleshooting](https://github.com/Anarios/return-youtube-dislike/wiki/Troubleshooting-Guide)
|
||||
<!-- - [FAQ](FAQ.md)
|
||||
- [When & How to Report Bugs](Guide__Bug_Reporting.md)
|
||||
- [Contributing](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTING.md) -->
|
||||
<!-- - [How to update wiki](/) -->
|
||||
|
||||
<br>
|
||||
|
||||
## FAQs
|
||||
|
||||
- [General](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQ.md)
|
||||
- [Security](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md)
|
||||
|
||||
<!-- - [Privacy](FAQ_Privacy.md)
|
||||
- [Technical](FAQ_Technical.md)
|
||||
- [Creators](FAQ_Creators.md)
|
||||
|
||||
<br>
|
||||
|
||||
## Other Lists
|
||||
|
||||
- [Common Problems](Common_Problems.md)
|
||||
- [Repeated Questions](Repeated_Questions.md)
|
||||
- [Repeated Feature requests](Repeated_Feature_requests.md)
|
||||
- [Repeated Issues](Repeated_Issues.md) -->
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Read this in other languages: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md)
|
||||
Read this in other languages: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Bahasa Indonesia](readmeid.md), [中文](readmecn.md), [Tiếng Việt](readmevi.md)
|
||||
|
||||
**İçerikler**
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
Đọc bằng các ngôn ngữ khác: [English](readme.md), [Nederlands](readmenl.md), [Français](readmefr.md), [Türkçe](readmetr.md)
|
||||
|
||||
**Mục lục** <!-- Contents -->
|
||||
|
||||
- [Hướng dẫn](#guides)
|
||||
- [Hỏi-Đáp](#faqs)
|
||||
<!-- - [FAQs](#faqs)
|
||||
- [Nội dung Khác](#other-lists) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Hướng dẫn <!-- ## Guides -->
|
||||
|
||||
- [Tải, Cài đặt & Sử dụng](https://github.com/Anarios/return-youtube-dislike/wiki/Tải,-Cài-đặt-&-Sử-dụng)
|
||||
- [Khắc phục Sự cố](https://github.com/Anarios/return-youtube-dislike/wiki/Hướng-dẫn-Khắc-phục-Sự-cố)
|
||||
<!-- - [Hỏi-Đáp](FAQvi.md)
|
||||
- [Khi nào và Làm sao Báo cáo Lỗi](Guide__Bug_Reportingvi.md)
|
||||
- [Đóng góp](https://github.com/Anarios/return-youtube-dislike/blob/main/CONTRIBUTINGvi.md) -->
|
||||
<!-- - [Cách cập nhật wiki](/) -->
|
||||
|
||||
<br>
|
||||
|
||||
## Hỏi-Đáp <!-- ## FAQs -->
|
||||
|
||||
- [Tổng quát](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/FAQvi.md)
|
||||
- [Bảo mật](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQvi.md)
|
||||
|
||||
<!-- - [Quyền Riêng tư](FAQ_Privacyvi.md)
|
||||
- [Kỹ thuật](FAQ_Technicalvi.md)
|
||||
- [Nhà Sáng tạo Nội dung](FAQ_Creatorsvi.md)
|
||||
|
||||
<br>
|
||||
|
||||
## Nội dung Khác [!-- ## Other Lists --]
|
||||
|
||||
- [Vấn đề Thường gặp](Common_Problemsvi.md)
|
||||
- [Câu hỏi Lặp đi lặp lại](Repeated_Questionsvi.md)
|
||||
- [Yêu cầu tính năng Lặp đi lặp lại](Repeated_Feature_requestsvi.md)
|
||||
- [Vấn đề Lặp đi lặp lại](Repeated_Issuesvi.md) -->
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,883 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript navigation browser-test fixture</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 768px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#fixture-account,
|
||||
#fixture-navigation,
|
||||
#fixture-page {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#fixture-navigation a,
|
||||
#fixture-page > a {
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
min-height: 24px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
/* Model YouTube's overlaid Shorts next control without causing a document scroll on activation. */
|
||||
#short-next {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
ytd-watch-flexy,
|
||||
ytd-menu-renderer,
|
||||
segmented-like-dislike-button-view-model,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model,
|
||||
ytd-shorts,
|
||||
ytd-reel-video-renderer,
|
||||
ytm-reel-video-renderer,
|
||||
ytm-reel-player-overlay-renderer,
|
||||
reel-action-bar-view-model,
|
||||
ytm-like-button-renderer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#top-row,
|
||||
ytd-menu-renderer.ytd-watch-metadata > div {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model {
|
||||
display: block;
|
||||
min-height: 48px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model > yt-smartimation,
|
||||
[data-fixture-smartimation-content-shell] {
|
||||
display: block;
|
||||
min-height: 48px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
[data-fixture-smartimation-content] {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
[data-fixture-watch-icon] {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
[data-fixture-touch-feedback] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer {
|
||||
display: block;
|
||||
min-height: 640px;
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.reel-video-in-sequence-new,
|
||||
.reel-video-in-sequence-thumbnail {
|
||||
display: block;
|
||||
min-height: 1px;
|
||||
min-width: 1px;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ytm-reel-video-renderer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slim-video-action-bar-actions,
|
||||
.segmented-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
ytm-like-button-renderer button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytwReelActionBarViewModelHostDesktopActionButton,
|
||||
reel-action-bar-view-model > button-view-model {
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelHost {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model button {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeNextIcon,
|
||||
.ytSpecButtonShapeNextIcon svg {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelLabel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font:
|
||||
12px/18px Arial,
|
||||
sans-serif;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fixture-account"><button id="avatar-btn" aria-label="Account menu">Account</button></div>
|
||||
<nav id="fixture-navigation" aria-label="Fixture navigation"></nav>
|
||||
<main id="fixture-page"></main>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const firstVideoId = "__VIDEO_ID__";
|
||||
const secondVideoId = "__SECOND_VIDEO_ID__";
|
||||
const nativeLikesByVideo = new Map([
|
||||
[firstVideoId, Number("__VIDEO_LIKES__")],
|
||||
[secondVideoId, Number("__SECOND_VIDEO_LIKES__")],
|
||||
]);
|
||||
const marker = "rydNavigationFixture=1";
|
||||
const navigation = document.getElementById("fixture-navigation");
|
||||
const page = document.getElementById("fixture-page");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
const fixtureBaseline = {
|
||||
renderedDesktopShortsNativeDislikes: null,
|
||||
renderedMobileShortsNativeDislikes: null,
|
||||
renderedWatchNativeDislikeTextCount: null,
|
||||
};
|
||||
const nativeReactionStats = {
|
||||
activations: 0,
|
||||
dislikeActivations: 0,
|
||||
likeActivations: 0,
|
||||
};
|
||||
globalThis.__navigationFixtureBaseline = fixtureBaseline;
|
||||
let pendingDelayedRender = null;
|
||||
let pendingStartedWatchRender = null;
|
||||
|
||||
if (!customElements.get("like-button-view-model")) {
|
||||
customElements.define(
|
||||
"like-button-view-model",
|
||||
class extends HTMLElement {
|
||||
get className() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const hrefFor = (pageKind, videoId) => {
|
||||
if (pageKind === "shorts") return `/shorts/${videoId}?${marker}`;
|
||||
if (pageKind === "watch") return `/watch?v=${videoId}&${marker}`;
|
||||
return `/@FixtureChannel?${marker}`;
|
||||
};
|
||||
|
||||
const nativeLikes = (videoId) => nativeLikesByVideo.get(videoId) ?? 100;
|
||||
|
||||
function setNativeLikeCount(root, videoId) {
|
||||
const count = nativeLikes(videoId);
|
||||
const like = root?.querySelector('[data-fixture-role="like"]');
|
||||
like?.querySelector("button")?.setAttribute("aria-label", `${count} likes`);
|
||||
const text = like?.querySelector("#text, [role='text']");
|
||||
if (text) text.textContent = String(count);
|
||||
}
|
||||
|
||||
function setNativeReactionPressed(control, pressed) {
|
||||
control?.classList.toggle("style-default-active", pressed);
|
||||
control?.classList.toggle("style-text", !pressed);
|
||||
control?.querySelector("button")?.setAttribute("aria-pressed", String(pressed));
|
||||
}
|
||||
|
||||
function adjustNativeLikeCount(like, delta) {
|
||||
const text = like?.querySelector("#text, [role='text']");
|
||||
const count = Number.parseInt(text?.textContent, 10);
|
||||
if (!text || !Number.isFinite(count)) return;
|
||||
text.textContent = String(Math.max(0, count + delta));
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const nativeButton = event.target.closest("button");
|
||||
const control = nativeButton?.closest('[data-fixture-role="like"], [data-fixture-role="dislike"]');
|
||||
if (!control || control.querySelector("button") !== nativeButton) return;
|
||||
|
||||
const controls = control.closest('[data-fixture-role="buttons"]');
|
||||
const role = control.getAttribute("data-fixture-role");
|
||||
const like = controls?.querySelector('[data-fixture-role="like"]');
|
||||
const dislike = controls?.querySelector('[data-fixture-role="dislike"]');
|
||||
const wasPressed = nativeButton.getAttribute("aria-pressed") === "true";
|
||||
const counterpart = role === "like" ? dislike : like;
|
||||
const counterpartWasPressed = counterpart?.querySelector("button")?.getAttribute("aria-pressed") === "true";
|
||||
|
||||
nativeReactionStats.activations += 1;
|
||||
nativeReactionStats[`${role}Activations`] += 1;
|
||||
|
||||
setNativeReactionPressed(control, !wasPressed);
|
||||
if (!wasPressed) setNativeReactionPressed(counterpart, false);
|
||||
if (role === "like") adjustNativeLikeCount(like, wasPressed ? -1 : 1);
|
||||
if (role === "dislike" && !wasPressed && counterpartWasPressed) adjustNativeLikeCount(like, -1);
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const linkMarkup = (id, label, pageKind, videoId, options = {}) => `
|
||||
<a
|
||||
id="${id}"
|
||||
href="${hrefFor(pageKind, videoId)}"
|
||||
data-fixture-page-kind="${pageKind}"
|
||||
data-fixture-video-id="${videoId || ""}"
|
||||
data-fixture-control-delay="${options.controlDelay || 0}"
|
||||
>${label}</a>
|
||||
`;
|
||||
|
||||
function channelMarkup() {
|
||||
const decoyControls = isMobile
|
||||
? `
|
||||
<div class="slim-video-action-bar-actions" data-fixture-decoy-controls>
|
||||
<div class="segmented-buttons" data-fixture-role="buttons">
|
||||
<like-button-view-model class="style-text" data-fixture-role="like">
|
||||
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
|
||||
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<ytd-menu-renderer class="ytd-watch-metadata" data-fixture-decoy-controls>
|
||||
<div id="top-level-buttons-computed">
|
||||
<segmented-like-dislike-button-view-model data-fixture-role="buttons">
|
||||
<like-button-view-model class="style-text" data-fixture-role="like">
|
||||
<button type="button" aria-label="Channel preview likes" aria-pressed="false">
|
||||
<span id="text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
|
||||
<button type="button" aria-label="Channel preview dislike" aria-pressed="false">
|
||||
<span id="text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
</div>
|
||||
</ytd-menu-renderer>
|
||||
`;
|
||||
return `
|
||||
<section data-fixture-page-kind="channel">
|
||||
<h1>Fixture channel</h1>
|
||||
${decoyControls}
|
||||
${linkMarkup("channel-short", "Open Short", "shorts", firstVideoId, { controlDelay: 600 })}
|
||||
${linkMarkup("channel-watch", "Open video", "watch", firstVideoId, { controlDelay: 200 })}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function watchMarkup(videoId) {
|
||||
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
|
||||
const likeCount = nativeLikes(videoId);
|
||||
return `
|
||||
<section data-fixture-page-kind="watch" data-fixture-video-id="${videoId}">
|
||||
<ytd-watch-flexy video-id="${videoId}">
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<div id="player" loading="false"></div>
|
||||
<div id="top-row">
|
||||
<ytd-menu-renderer class="ytd-watch-metadata">
|
||||
<div id="top-level-buttons-computed">
|
||||
<segmented-like-dislike-button-view-model
|
||||
data-fixture-role="buttons"
|
||||
data-fixture-control-video-id="${videoId}"
|
||||
>
|
||||
<yt-smartimation>
|
||||
<div id="content" data-fixture-smartimation-content-shell>
|
||||
<div id="wrapper" data-fixture-smartimation-content>
|
||||
<like-button-view-model class="style-text" data-fixture-role="like">
|
||||
<toggle-button-view-model>
|
||||
<button-view-model>
|
||||
<button type="button" aria-label="${likeCount} likes" aria-pressed="false">
|
||||
<svg data-fixture-watch-icon="like" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
|
||||
</svg>
|
||||
<div class="ytSpecButtonShapeNextButtonTextContent" data-fixture-provided-native-text>
|
||||
<span id="text" role="text">${likeCount}</span>
|
||||
</div>
|
||||
<span data-fixture-touch-feedback aria-hidden="true"></span>
|
||||
</button>
|
||||
</button-view-model>
|
||||
</toggle-button-view-model>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
|
||||
<toggle-button-view-model>
|
||||
<button-view-model>
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<svg data-fixture-watch-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
|
||||
</svg>
|
||||
<span data-fixture-touch-feedback aria-hidden="true"></span>
|
||||
</button>
|
||||
</button-view-model>
|
||||
</toggle-button-view-model>
|
||||
</dislike-button-view-model>
|
||||
</div>
|
||||
</div>
|
||||
</yt-smartimation>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
</div>
|
||||
</ytd-menu-renderer>
|
||||
</div>
|
||||
</ytd-watch-flexy>
|
||||
${linkMarkup("watch-to-short", "Open Short", "shorts", nextVideoId, { controlDelay: 200 })}
|
||||
${linkMarkup("watch-related", "Open related video", "watch", nextVideoId)}
|
||||
${linkMarkup("watch-next", "Play next video", "watch", nextVideoId)}
|
||||
<video id="fixture-media" data-fixture-autoplay-kind="watch" data-fixture-autoplay-id="${nextVideoId}"></video>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function shortRendererMarkup(videoId, active) {
|
||||
const likeCount = nativeLikes(videoId);
|
||||
return `
|
||||
<div class="reel-video-in-sequence-new" data-fixture-sequence-video-id="${videoId}">
|
||||
<div class="reel-video-in-sequence-thumbnail"></div>
|
||||
<ytd-reel-video-renderer video-id="${videoId}" ${active ? "is-active" : "hidden"}>
|
||||
<div id="experiment-overlay"><span>Ready</span></div>
|
||||
<a class="ytp-title-link" href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<reel-action-bar-view-model data-fixture-role="buttons">
|
||||
<like-button-view-model
|
||||
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
|
||||
data-fixture-role="like"
|
||||
>
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="${likeCount} likes" aria-pressed="false">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span id="text" role="text">${likeCount}</span></div>
|
||||
</label>
|
||||
</like-button-view-model>
|
||||
<button-view-model data-fixture-control="comments">
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button type="button" aria-label="View comments">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 4h18v14H8l-5 3Z"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">12</span></div>
|
||||
</label>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="share">
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button type="button" aria-label="Share">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 4l7 7-7 7v-4H3V8h11Z"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Share</span></div>
|
||||
</label>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="remix">
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button type="button" aria-label="Remix">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 7h10l-2-2 2-2 6 6-6 6-2-2 2-2H4Z"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Remix</span></div>
|
||||
</label>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="sound">
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button type="button" aria-label="Sound">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4Zm12.5 3a3.5 3.5 0 0 0-2-3.16v6.32a3.5 3.5 0 0 0 2-3.16Z"></path></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel"><span role="text">Sound</span></div>
|
||||
</label>
|
||||
</button-view-model>
|
||||
</reel-action-bar-view-model>
|
||||
</ytd-reel-video-renderer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function mobileShortRendererMarkup(videoId, active) {
|
||||
const likeCount = nativeLikes(videoId);
|
||||
return `
|
||||
<ytm-reel-video-renderer
|
||||
data-fixture-mobile-short="${videoId}"
|
||||
video-id="${videoId}"
|
||||
${active ? "is-active" : "hidden"}
|
||||
>
|
||||
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<ytm-reel-player-overlay-renderer>
|
||||
<div id="like-button">
|
||||
<ytm-like-button-renderer data-fixture-role="buttons">
|
||||
<like-button-view-model class="style-text" data-fixture-role="like">
|
||||
<button type="button" aria-label="${likeCount} likes" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text">${likeCount}</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-fixture-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</ytm-like-button-renderer>
|
||||
</div>
|
||||
</ytm-reel-player-overlay-renderer>
|
||||
</ytm-reel-video-renderer>
|
||||
`;
|
||||
}
|
||||
|
||||
function shortsMarkup(videoId) {
|
||||
const nextVideoId = videoId === firstVideoId ? secondVideoId : firstVideoId;
|
||||
return `
|
||||
<section data-fixture-page-kind="shorts" data-fixture-video-id="${videoId}">
|
||||
${
|
||||
isMobile
|
||||
? `<div id="fixture-shorts-reels">
|
||||
${mobileShortRendererMarkup(videoId, true)}
|
||||
${mobileShortRendererMarkup(nextVideoId, false)}
|
||||
</div>`
|
||||
: `<ytd-shorts>
|
||||
<div id="fixture-shorts-reels">
|
||||
${shortRendererMarkup(videoId, true)}
|
||||
${shortRendererMarkup(nextVideoId, false)}
|
||||
</div>
|
||||
</ytd-shorts>`
|
||||
}
|
||||
${linkMarkup("short-to-watch", "Open video", "watch", nextVideoId, { controlDelay: 200 })}
|
||||
${linkMarkup("short-next", "Play next Short", "shorts", nextVideoId)}
|
||||
<video id="fixture-media" data-fixture-autoplay-kind="shorts" data-fixture-autoplay-id="${nextVideoId}"></video>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function pageMarkup(pageKind, videoId) {
|
||||
if (pageKind === "shorts") return shortsMarkup(videoId);
|
||||
if (pageKind === "watch") return watchMarkup(videoId);
|
||||
return channelMarkup();
|
||||
}
|
||||
|
||||
function dispatchNavigation() {
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
function render(pageKind, videoId) {
|
||||
page.innerHTML = pageMarkup(pageKind, videoId);
|
||||
fixtureBaseline.renderedWatchNativeDislikeTextCount =
|
||||
pageKind === "watch"
|
||||
? page.querySelectorAll(
|
||||
'[data-fixture-page-kind="watch"] [data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent',
|
||||
).length
|
||||
: null;
|
||||
fixtureBaseline.renderedDesktopShortsNativeDislikes =
|
||||
pageKind === "shorts" && !isMobile
|
||||
? page.querySelectorAll(
|
||||
"ytd-reel-video-renderer[is-active] > reel-action-bar-view-model > dislike-button-view-model",
|
||||
).length
|
||||
: null;
|
||||
fixtureBaseline.renderedMobileShortsNativeDislikes =
|
||||
pageKind === "shorts" && isMobile
|
||||
? page.querySelectorAll(
|
||||
"ytm-reel-video-renderer[is-active] ytm-like-button-renderer > dislike-button-view-model",
|
||||
).length
|
||||
: null;
|
||||
navigation.innerHTML =
|
||||
pageKind === "channel"
|
||||
? ""
|
||||
: linkMarkup("return-to-channel", "Return to channel", "channel", "", { controlDelay: 20 });
|
||||
}
|
||||
|
||||
function navigate(pageKind, videoId, { controlDelay = 0, dispatchEvent = true } = {}) {
|
||||
history.pushState({}, "", hrefFor(pageKind, videoId));
|
||||
if (controlDelay > 0) {
|
||||
page.dataset.fixtureTransitionPending = pageKind;
|
||||
if (dispatchEvent) dispatchNavigation();
|
||||
setTimeout(() => {
|
||||
delete page.dataset.fixtureTransitionPending;
|
||||
render(pageKind, videoId);
|
||||
}, controlDelay);
|
||||
return;
|
||||
}
|
||||
render(pageKind, videoId);
|
||||
if (dispatchEvent) dispatchNavigation();
|
||||
}
|
||||
|
||||
function recycleShort(videoId) {
|
||||
const section = page.querySelector('[data-fixture-page-kind="shorts"]');
|
||||
const activeRenderer = section?.querySelector(
|
||||
isMobile ? "ytm-reel-video-renderer[is-active]" : "ytd-reel-video-renderer[is-active]",
|
||||
);
|
||||
const nextRenderer = section?.querySelector(
|
||||
isMobile
|
||||
? `ytm-reel-video-renderer[video-id="${videoId}"]`
|
||||
: `ytd-reel-video-renderer[video-id="${videoId}"]`,
|
||||
);
|
||||
if (!section || !activeRenderer || !nextRenderer) return;
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
section.dataset.fixtureVideoId = videoId;
|
||||
activeRenderer.removeAttribute("is-active");
|
||||
activeRenderer.hidden = true;
|
||||
nextRenderer.hidden = false;
|
||||
nextRenderer.setAttribute("is-active", "");
|
||||
const nextLink = section.querySelector("#short-next");
|
||||
nextLink?.setAttribute("data-fixture-video-id", firstVideoId);
|
||||
nextLink?.setAttribute("href", hrefFor("shorts", firstVideoId));
|
||||
section.querySelector("#fixture-media")?.setAttribute("data-fixture-autoplay-id", firstVideoId);
|
||||
}
|
||||
|
||||
function churnInactiveDesktopShortIdentity(sequence) {
|
||||
const renderer = page.querySelector("ytd-reel-video-renderer:not([is-active])");
|
||||
if (!renderer) return;
|
||||
const churnVideoId = sequence % 2 === 0 ? secondVideoId : "CCCCCCCCCCC";
|
||||
renderer.setAttribute("video-id", churnVideoId);
|
||||
renderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${churnVideoId}`);
|
||||
}
|
||||
|
||||
function queueDelayedRender(pageKind, videoId) {
|
||||
page.dataset.fixtureTransitionPending = pageKind;
|
||||
pendingDelayedRender = () => {
|
||||
delete page.dataset.fixtureTransitionPending;
|
||||
render(pageKind, videoId);
|
||||
};
|
||||
}
|
||||
|
||||
function finishDelayedNavigation() {
|
||||
if (!pendingDelayedRender) throw new Error("No delayed fixture navigation is pending");
|
||||
const finish = pendingDelayedRender;
|
||||
pendingDelayedRender = null;
|
||||
finish();
|
||||
}
|
||||
|
||||
function navigateDelayedShort(videoId) {
|
||||
const rendererTag = isMobile ? "ytm-reel-video-renderer" : "ytd-reel-video-renderer";
|
||||
const activeRenderer = page.querySelector(`${rendererTag}[is-active]`);
|
||||
for (const renderer of page.querySelectorAll(`${rendererTag}:not([is-active])`)) {
|
||||
renderer.remove();
|
||||
}
|
||||
if (!activeRenderer) return;
|
||||
activeRenderer.querySelector('a[href*="/shorts/"]')?.setAttribute("href", `/shorts/${videoId}`);
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
queueDelayedRender("shorts", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function anonymizeActiveDesktopShort() {
|
||||
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
|
||||
if (!activeRenderer) return;
|
||||
activeRenderer.removeAttribute("video-id");
|
||||
activeRenderer.querySelector('a[href*="/shorts/"]')?.removeAttribute("href");
|
||||
}
|
||||
|
||||
function navigateDelayedAnonymousShort(videoId) {
|
||||
const activeRenderer = page.querySelector("ytd-reel-video-renderer[is-active]");
|
||||
for (const renderer of page.querySelectorAll("ytd-reel-video-renderer:not([is-active])")) {
|
||||
renderer.remove();
|
||||
}
|
||||
if (!activeRenderer) return;
|
||||
anonymizeActiveDesktopShort();
|
||||
history.pushState({}, "", hrefFor("shorts", videoId));
|
||||
queueDelayedRender("shorts", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function navigateDelayedWatch(videoId) {
|
||||
const watch = page.querySelector("ytd-watch-flexy");
|
||||
if (!watch) return;
|
||||
history.pushState({}, "", hrefFor("watch", videoId));
|
||||
watch.setAttribute("video-id", videoId);
|
||||
queueDelayedRender("watch", videoId);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function navigateWatchAfterStartWithoutFinish(videoId) {
|
||||
const watchPage = page.querySelector('[data-fixture-page-kind="watch"]');
|
||||
const watch = watchPage?.querySelector("ytd-watch-flexy");
|
||||
if (!watchPage || !watch) return;
|
||||
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
|
||||
history.pushState({}, "", hrefFor("watch", videoId));
|
||||
watchPage.setAttribute("data-fixture-video-id", videoId);
|
||||
watch.setAttribute("video-id", videoId);
|
||||
page.dataset.fixtureTransitionPending = "watch-start-without-finish";
|
||||
pendingStartedWatchRender = () => {
|
||||
delete page.dataset.fixtureTransitionPending;
|
||||
render("watch", videoId);
|
||||
};
|
||||
}
|
||||
|
||||
function finishWatchAfterStartWithoutFinish() {
|
||||
if (!pendingStartedWatchRender) throw new Error("No start-only Watch navigation is pending");
|
||||
const finish = pendingStartedWatchRender;
|
||||
pendingStartedWatchRender = null;
|
||||
finish();
|
||||
}
|
||||
|
||||
function beginSameNodeWatchNavigation(videoId) {
|
||||
const watch = page.querySelector("ytd-watch-flexy");
|
||||
if (!watch) return;
|
||||
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
|
||||
history.pushState({}, "", hrefFor("watch", videoId));
|
||||
watch.setAttribute("video-id", videoId);
|
||||
}
|
||||
|
||||
function finishSameNodeWatchNavigation() {
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
function mutateOutgoingWatchDescendant() {
|
||||
const dislike = page.querySelector('[data-fixture-page-kind="watch"] [data-fixture-role="dislike"]');
|
||||
if (!dislike) return;
|
||||
const countMarker = document.createElement("span");
|
||||
countMarker.hidden = true;
|
||||
countMarker.setAttribute("data-fixture-irrelevant-watch-count-mutation", "true");
|
||||
dislike.querySelector("#text")?.appendChild(countMarker);
|
||||
|
||||
const tooltipMarker = document.createElement("span");
|
||||
tooltipMarker.hidden = true;
|
||||
tooltipMarker.setAttribute("data-fixture-irrelevant-watch-tooltip-mutation", "true");
|
||||
page.querySelector(".ryd-tooltip")?.appendChild(tooltipMarker);
|
||||
}
|
||||
|
||||
function replaceDelayedWatchControl(role) {
|
||||
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
|
||||
if (!control) return;
|
||||
const replacement = control.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-watch-replacement", role);
|
||||
replacement.classList.remove("style-default-active");
|
||||
replacement.classList.add("style-text");
|
||||
replacement.querySelector("button")?.setAttribute("aria-pressed", "false");
|
||||
if (role === "dislike") {
|
||||
const count = replacement.querySelector("#text");
|
||||
if (count) count.textContent = "";
|
||||
}
|
||||
control.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function replaceCurrentWatchActions({ nativeHydrationDelay = 0, retainOutgoing = false } = {}) {
|
||||
const section = page.querySelector('[data-fixture-page-kind="watch"]');
|
||||
const currentActions = section?.querySelector("#top-level-buttons-computed");
|
||||
const videoId = section?.dataset.fixtureVideoId;
|
||||
if (!section || !currentActions || !videoId) return false;
|
||||
|
||||
const replacement = currentActions.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-watch-actions-replacement", videoId);
|
||||
replacement.querySelectorAll(".ryd-tooltip").forEach((tooltip) => tooltip.remove());
|
||||
|
||||
const controls = replacement.querySelector('[data-fixture-role="buttons"]');
|
||||
controls?.setAttribute("data-fixture-control-video-id", videoId);
|
||||
for (const role of ["like", "dislike"]) {
|
||||
const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
|
||||
control?.classList.remove("style-default-active");
|
||||
control?.classList.add("style-text");
|
||||
const button = control?.querySelector("button");
|
||||
button?.setAttribute("aria-pressed", "false");
|
||||
button?.removeAttribute("disabled");
|
||||
button?.setAttribute("aria-disabled", "false");
|
||||
}
|
||||
controls
|
||||
?.querySelectorAll(
|
||||
'[data-fixture-role="dislike"] .ytSpecButtonShapeNextButtonTextContent, [data-fixture-role="dislike"] #text',
|
||||
)
|
||||
.forEach((element) => element.remove());
|
||||
|
||||
const delayedNativeButtons = [];
|
||||
if (nativeHydrationDelay > 0) {
|
||||
for (const role of ["like", "dislike"]) {
|
||||
const control = controls?.querySelector(`[data-fixture-role="${role}"]`);
|
||||
const button = control?.querySelector("button");
|
||||
if (control && button) {
|
||||
control.setAttribute("data-fixture-native-hydration-pending", role);
|
||||
delayedNativeButtons.push({ button, control });
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentActions.replaceWith(replacement);
|
||||
globalThis.__fixtureReplacedWatchActions = currentActions;
|
||||
if (retainOutgoing) {
|
||||
const retained = document.createElement("div");
|
||||
retained.hidden = true;
|
||||
retained.setAttribute("data-fixture-retained-settling-watch-actions", videoId);
|
||||
retained.appendChild(currentActions);
|
||||
document.body.appendChild(retained);
|
||||
}
|
||||
if (delayedNativeButtons.length > 0) {
|
||||
setTimeout(() => {
|
||||
for (const { button, control } of delayedNativeButtons) {
|
||||
if (replacement.isConnected && control.isConnected && !control.querySelector("button")) {
|
||||
control.appendChild(button);
|
||||
control.removeAttribute("data-fixture-native-hydration-pending");
|
||||
}
|
||||
}
|
||||
}, nativeHydrationDelay);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshReusedWatchControl(role, videoId) {
|
||||
const control = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"]`);
|
||||
if (!control) return;
|
||||
setNativeReactionPressed(control, false);
|
||||
control.querySelector("button")?.setAttribute("aria-label", `${role} refreshed for ${videoId}`);
|
||||
}
|
||||
|
||||
function driftWatchControlBeforeNavigation(role, videoId) {
|
||||
const button = page.querySelector(`[data-fixture-page-kind="watch"] [data-fixture-role="${role}"] button`);
|
||||
if (!button) return;
|
||||
button.setAttribute("aria-label", `${role} drift while ${videoId}`);
|
||||
button.setAttribute("title", `${role} title drift while ${videoId}`);
|
||||
}
|
||||
|
||||
function replaceActiveMobileOverlay() {
|
||||
const activeRenderer = page.querySelector("ytm-reel-video-renderer[is-active]");
|
||||
const overlay = activeRenderer?.querySelector("ytm-reel-player-overlay-renderer");
|
||||
if (!overlay) return;
|
||||
const replacement = overlay.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-replacement", "true");
|
||||
const dislike = replacement.querySelector('[data-fixture-role="dislike"]');
|
||||
dislike?.classList.remove("style-default-active");
|
||||
dislike?.classList.add("style-text");
|
||||
dislike?.querySelector("button")?.setAttribute("aria-pressed", "false");
|
||||
const dislikeCount = dislike?.querySelector("#text");
|
||||
if (dislikeCount) dislikeCount.textContent = "";
|
||||
overlay.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function navigateToShortsDescendant(videoId) {
|
||||
history.pushState({}, "", `/shorts/${videoId}/extra?${marker}`);
|
||||
dispatchNavigation();
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[data-fixture-page-kind]");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
if (link.id === "short-next") {
|
||||
recycleShort(link.dataset.fixtureVideoId);
|
||||
dispatchNavigation();
|
||||
return;
|
||||
}
|
||||
navigate(link.dataset.fixturePageKind, link.dataset.fixtureVideoId, {
|
||||
controlDelay: Number(link.dataset.fixtureControlDelay || 0),
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"ended",
|
||||
(event) => {
|
||||
const media = event.target.closest("#fixture-media");
|
||||
if (!media) return;
|
||||
if (media.dataset.fixtureAutoplayKind === "shorts") {
|
||||
recycleShort(media.dataset.fixtureAutoplayId);
|
||||
return;
|
||||
}
|
||||
navigate(media.dataset.fixtureAutoplayKind, media.dataset.fixtureAutoplayId, {
|
||||
dispatchEvent: false,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__navigationFixture = {
|
||||
anonymizeActiveDesktopShort,
|
||||
beginSameNodeWatchNavigation,
|
||||
churnInactiveDesktopShortIdentity,
|
||||
dispatchEnded() {
|
||||
page.querySelector("#fixture-media")?.dispatchEvent(new Event("ended"));
|
||||
},
|
||||
finishDelayedNavigation,
|
||||
finishSameNodeWatchNavigation,
|
||||
finishWatchAfterStartWithoutFinish,
|
||||
driftWatchControlBeforeNavigation,
|
||||
navigate,
|
||||
navigateDelayedAnonymousShort,
|
||||
navigateDelayedShort,
|
||||
navigateDelayedWatch,
|
||||
navigateToShortsDescendant,
|
||||
navigateWatchAfterStartWithoutFinish,
|
||||
mutateOutgoingWatchDescendant,
|
||||
nativeReactionSnapshot() {
|
||||
return { ...nativeReactionStats };
|
||||
},
|
||||
recycleShort,
|
||||
replaceActiveMobileOverlay,
|
||||
replaceCurrentWatchActions,
|
||||
replaceDelayedWatchControl,
|
||||
refreshReusedWatchControl,
|
||||
setNativeLikeCount,
|
||||
};
|
||||
|
||||
render("__INITIAL_PAGE_KIND__", "__VIDEO_ID__");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript Shorts browser-test fixture</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 768px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#shorts-root,
|
||||
[data-short-video],
|
||||
ytd-reel-video-renderer,
|
||||
reel-action-bar-view-model,
|
||||
ytd-like-button-renderer,
|
||||
ytm-like-button-renderer,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-short-video][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ytd-like-button-renderer,
|
||||
ytm-like-button-renderer {
|
||||
width: 240px;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
ytd-reel-video-renderer {
|
||||
display: block;
|
||||
width: 420px;
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 48px;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.ytwReelActionBarViewModelHostDesktopActionButton,
|
||||
reel-action-bar-view-model > button-view-model {
|
||||
box-sizing: content-box;
|
||||
display: block;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0 0 8px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelHost {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
reel-action-bar-view-model button {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
like-button-view-model .ytSpecButtonShapeNextIcon {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
like-button-view-model .ytSpecButtonShapeNextIcon svg {
|
||||
display: block;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.ytSpecButtonShapeWithLabelLabel {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
height: 22px;
|
||||
justify-content: center;
|
||||
line-height: 18px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
ytm-like-button-renderer button,
|
||||
ytd-like-button-renderer button {
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="fixture-account"></div>
|
||||
<div id="shorts-root"></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById("shorts-root");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
|
||||
if (!customElements.get("like-button-view-model")) {
|
||||
customElements.define(
|
||||
"like-button-view-model",
|
||||
class extends HTMLElement {
|
||||
get className() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
document.getElementById("fixture-account").innerHTML = __SIGNED_IN__
|
||||
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
|
||||
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
|
||||
|
||||
function controlsMarkup(videoId) {
|
||||
if (!isMobile) {
|
||||
return `
|
||||
<section data-short-video="${videoId}" hidden>
|
||||
<ytd-reel-video-renderer video-id="${videoId}">
|
||||
<a href="/shorts/${videoId}" aria-label="Short ${videoId}"></a>
|
||||
<reel-action-bar-view-model data-ryd-role="buttons">
|
||||
<like-button-view-model
|
||||
class="ytLikeButtonViewModelHost ytwReelActionBarViewModelHostDesktopActionButton"
|
||||
data-ryd-role="like"
|
||||
>
|
||||
<label class="ytSpecButtonShapeWithLabelHost">
|
||||
<button class="ytSpecButtonShapeNextHost" type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span class="ytSpecButtonShapeNextIcon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21"></path></svg></span>
|
||||
</button>
|
||||
<div class="ytSpecButtonShapeWithLabelLabel">
|
||||
<span
|
||||
id="text"
|
||||
class="ytAttributedStringHost ytAttributedStringTextAlignmentCenter"
|
||||
role="text"
|
||||
>100</span>
|
||||
</div>
|
||||
</label>
|
||||
</like-button-view-model>
|
||||
<button-view-model data-fixture-control="comments">
|
||||
<button type="button" aria-label="View comments"><span role="text">12</span></button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="share">
|
||||
<button type="button" aria-label="Share"><span role="text">Share</span></button>
|
||||
</button-view-model>
|
||||
<button-view-model data-fixture-control="remix">
|
||||
<button type="button" aria-label="Remix"><span role="text">Remix</span></button>
|
||||
</button-view-model>
|
||||
</reel-action-bar-view-model>
|
||||
</ytd-reel-video-renderer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<section data-short-video="${videoId}" hidden>
|
||||
<div id="like-button">
|
||||
<__SHORTS_RENDERER_TAG__ data-ryd-role="buttons">
|
||||
<like-button-view-model class="style-text" data-ryd-role="like">
|
||||
<button type="button" aria-label="100 likes" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text">100</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" data-ryd-role="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<span id="text" class="button-renderer-text" role="text"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
</__SHORTS_RENDERER_TAG__>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function applyNativeState(section, state) {
|
||||
const like = section.querySelector('[data-ryd-role="like"]');
|
||||
const dislike = section.querySelector('[data-ryd-role="dislike"]');
|
||||
if (isMobile) {
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
like.classList.toggle("style-text", state !== "liked");
|
||||
dislike?.classList.toggle("style-default-active", state === "disliked");
|
||||
dislike?.classList.toggle("style-text", state !== "disliked");
|
||||
} else {
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
dislike?.classList.toggle("style-default-active", state === "disliked");
|
||||
}
|
||||
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
|
||||
dislike?.querySelector("button")?.setAttribute("aria-pressed", String(state === "disliked"));
|
||||
section.dataset.nativeState = state;
|
||||
}
|
||||
|
||||
function ensureVideo(videoId) {
|
||||
let section = root.querySelector(`[data-short-video="${videoId}"]`);
|
||||
if (!section) {
|
||||
root.insertAdjacentHTML("beforeend", controlsMarkup(videoId));
|
||||
section = root.querySelector(`[data-short-video="${videoId}"]`);
|
||||
applyNativeState(section, "neutral");
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function activate(videoId, { dispatchNavigation = true, state = "neutral" } = {}) {
|
||||
const active = ensureVideo(videoId);
|
||||
for (const section of root.querySelectorAll("[data-short-video]")) {
|
||||
section.hidden = section !== active;
|
||||
}
|
||||
applyNativeState(active, state);
|
||||
history.pushState({}, "", `/shorts/${videoId}`);
|
||||
if (dispatchNavigation) {
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function activeSection() {
|
||||
return root.querySelector("[data-short-video]:not([hidden])");
|
||||
}
|
||||
|
||||
function removeSyntheticDislike() {
|
||||
activeSection()?.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
|
||||
}
|
||||
|
||||
function replaceActionBar() {
|
||||
const section = activeSection();
|
||||
const actionBar = section?.querySelector("reel-action-bar-view-model");
|
||||
if (!actionBar) return;
|
||||
const replacement = actionBar.cloneNode(true);
|
||||
replacement.querySelector("[data-ryd-synthetic-shorts-dislike]")?.remove();
|
||||
actionBar.replaceWith(replacement);
|
||||
applyNativeState(section, section.dataset.nativeState || "neutral");
|
||||
}
|
||||
|
||||
function installNativeDislike() {
|
||||
const section = activeSection();
|
||||
const actionBar = section?.querySelector("reel-action-bar-view-model");
|
||||
if (!actionBar || actionBar.querySelector("dislike-button-view-model")) return;
|
||||
|
||||
const nativeDislike = document.createElement("dislike-button-view-model");
|
||||
nativeDislike.className = "style-text";
|
||||
nativeDislike.setAttribute("data-ryd-role", "dislike");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-label", "Dislike this video");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
const text = document.createElement("span");
|
||||
text.id = "text";
|
||||
text.setAttribute("role", "text");
|
||||
button.appendChild(text);
|
||||
nativeDislike.appendChild(button);
|
||||
actionBar.insertBefore(nativeDislike, actionBar.children[1] ?? null);
|
||||
}
|
||||
|
||||
function removeNativeDislike() {
|
||||
activeSection()?.querySelector("dislike-button-view-model")?.remove();
|
||||
}
|
||||
|
||||
function replaceInnerButton(role) {
|
||||
const control = activeSection()?.querySelector(`[data-ryd-role="${role}"]`);
|
||||
const button = control?.querySelector("button");
|
||||
if (!button) return;
|
||||
const replacement = button.cloneNode(true);
|
||||
if (control.matches("[data-ryd-synthetic-shorts-dislike]")) {
|
||||
replacement.disabled = true;
|
||||
replacement.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
button.replaceWith(replacement);
|
||||
}
|
||||
|
||||
function recycleActiveRenderer(videoId, { state = "neutral" } = {}) {
|
||||
const section = activeSection();
|
||||
const renderer = section?.querySelector("ytd-reel-video-renderer");
|
||||
if (!section || !renderer) return;
|
||||
section.setAttribute("data-short-video", videoId);
|
||||
renderer.setAttribute("video-id", videoId);
|
||||
const link = renderer.querySelector('a[href*="/shorts/"]');
|
||||
link?.setAttribute("href", `/shorts/${videoId}`);
|
||||
applyNativeState(section, state);
|
||||
history.pushState({}, "", `/shorts/${videoId}`);
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
function setClassOnlyLiked() {
|
||||
const section = activeSection();
|
||||
if (!section) return;
|
||||
applyNativeState(section, "liked");
|
||||
section.querySelector('[data-ryd-role="like"]').classList.add("style-default-active");
|
||||
section.querySelector('[data-ryd-role="like"] button').setAttribute("aria-pressed", "false");
|
||||
}
|
||||
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const actionButton = event.target.closest("button");
|
||||
if (!actionButton) return;
|
||||
const section = event.target.closest("[data-short-video]");
|
||||
if (!section) return;
|
||||
const current = section.dataset.nativeState || "neutral";
|
||||
if (actionButton.closest('[data-ryd-role="like"]')) {
|
||||
applyNativeState(section, current === "liked" ? "neutral" : "liked");
|
||||
}
|
||||
if (
|
||||
actionButton.closest('[data-ryd-role="dislike"]') &&
|
||||
!actionButton.closest("[data-ryd-synthetic-shorts-dislike]")
|
||||
) {
|
||||
applyNativeState(section, current === "disliked" ? "neutral" : "disliked");
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__shortsFixture = {
|
||||
activate,
|
||||
activeSection,
|
||||
ensureVideo,
|
||||
installNativeDislike,
|
||||
recycleActiveRenderer,
|
||||
replaceInnerButton,
|
||||
removeNativeDislike,
|
||||
removeSyntheticDislike,
|
||||
replaceActionBar,
|
||||
setClassOnlyLiked,
|
||||
};
|
||||
activate("__VIDEO_ID__", { dispatchNavigation: false, state: "__INITIAL_STATE__" });
|
||||
ensureVideo("__SECOND_VIDEO_ID__");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,370 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Userscript browser-test fixture</title>
|
||||
<style>
|
||||
:root {
|
||||
--yt-spec-10-percent-layer: rgba(255, 255, 255, 0.1);
|
||||
--yt-spec-base-background: rgb(15, 15, 15);
|
||||
--yt-spec-icon-disabled: rgba(255, 255, 255, 0.3);
|
||||
--yt-spec-text-primary: rgb(241, 241, 241);
|
||||
--yt-spec-text-secondary: rgb(170, 170, 170);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: rgb(15, 15, 15);
|
||||
width: 100%;
|
||||
min-height: 768px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ytd-watch-flexy,
|
||||
ytd-menu-renderer,
|
||||
.slim-video-action-bar-actions,
|
||||
.segmented-buttons,
|
||||
segmented-like-dislike-button-view-model,
|
||||
yt-smartimation,
|
||||
toggle-button-view-model,
|
||||
button-view-model,
|
||||
like-button-view-model,
|
||||
dislike-button-view-model {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ytd-menu-renderer.ytd-watch-metadata > div {
|
||||
display: block;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#top-row {
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
ytd-menu-renderer.ytd-watch-metadata {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
#top-level-buttons-computed {
|
||||
position: relative;
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
segmented-like-dislike-button-view-model {
|
||||
display: block;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-fixture-smartimation-content] {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.segmented-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 320px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
[data-fixture-shell][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__="like"],
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] {
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 96px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__="like"] button,
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] button {
|
||||
align-items: center;
|
||||
background: rgb(39, 39, 39);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
box-sizing: border-box;
|
||||
color: rgb(241, 241, 241);
|
||||
display: inline-flex;
|
||||
font:
|
||||
500 14px/20px Arial,
|
||||
sans-serif;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
min-width: 96px;
|
||||
padding: 0 12px;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__].style-default-active button {
|
||||
background: rgb(241, 241, 241);
|
||||
color: rgb(15, 15, 15);
|
||||
}
|
||||
|
||||
[data-fixture-icon] {
|
||||
display: block;
|
||||
flex: 0 0 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__] #text,
|
||||
[__FIXTURE_ROLE_ATTRIBUTE__] .ytSpecButtonShapeNextButtonTextContent {
|
||||
display: block;
|
||||
font:
|
||||
500 14px/20px Arial,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#flexible-item-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
width: 172px;
|
||||
}
|
||||
|
||||
#flexible-item-buttons button {
|
||||
background: rgb(39, 39, 39);
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
color: rgb(241, 241, 241);
|
||||
height: 36px;
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
#fixture-comment-action-surfaces #top-level-buttons-computed {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
[data-fixture-touch-feedback] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- A hidden menu-container selects the desktop metadata layout used by the real page. -->
|
||||
<div id="menu-container" style="display: none"></div>
|
||||
<div id="fixture-account"></div>
|
||||
<div id="player" loading="false"></div>
|
||||
<ytd-watch-flexy id="fixture-watch" video-id="__VIDEO_ID__">
|
||||
<div id="top-row" data-fixture-shell="desktop">
|
||||
<ytd-menu-renderer class="ytd-watch-metadata">
|
||||
<div id="top-level-buttons-computed"></div>
|
||||
<div id="flexible-item-buttons" data-fixture-unrelated-actions>
|
||||
<button-view-model
|
||||
><button type="button"><span role="text">Save</span></button></button-view-model
|
||||
>
|
||||
<button-view-model
|
||||
><button type="button"><span role="text">Download</span></button></button-view-model
|
||||
>
|
||||
</div>
|
||||
</ytd-menu-renderer>
|
||||
</div>
|
||||
</ytd-watch-flexy>
|
||||
<div id="fixture-comment-action-surfaces"></div>
|
||||
<div class="slim-video-action-bar-actions" data-fixture-shell="mobile">
|
||||
<div id="fixture-mobile-buttons-host" class="segmented-buttons"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const account = document.getElementById("fixture-account");
|
||||
const watch = document.getElementById("fixture-watch");
|
||||
const isMobile = location.hostname === "m.youtube.com";
|
||||
const desktopHost = document.getElementById("top-level-buttons-computed");
|
||||
const mobileHost = document.getElementById("fixture-mobile-buttons-host");
|
||||
const host = isMobile ? mobileHost : desktopHost;
|
||||
|
||||
document.querySelector('[data-fixture-shell="desktop"]').hidden = isMobile;
|
||||
document.querySelector('[data-fixture-shell="mobile"]').hidden = !isMobile;
|
||||
|
||||
function setSignedIn(signedIn) {
|
||||
account.innerHTML = signedIn
|
||||
? '<button id="avatar-btn" aria-label="Account menu">Account</button>'
|
||||
: '<a id="sign-in" href="https://accounts.google.com/ServiceLogin">Sign in</a>';
|
||||
}
|
||||
|
||||
function desktopButtonMarkup() {
|
||||
return `
|
||||
<segmented-like-dislike-button-view-model __FIXTURE_ROLE_ATTRIBUTE__="buttons">
|
||||
<yt-smartimation>
|
||||
<div id="content" data-fixture-smartimation-content-shell>
|
||||
<div id="wrapper" data-fixture-smartimation-content>
|
||||
<like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
|
||||
<toggle-button-view-model>
|
||||
<button-view-model>
|
||||
<button type="button" aria-label="__VIDEO_LIKES__ likes" aria-pressed="false">
|
||||
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
|
||||
</svg>
|
||||
<div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" role="text">__VIDEO_LIKES__</span></div>
|
||||
<span data-fixture-touch-feedback aria-hidden="true"></span>
|
||||
</button>
|
||||
</button-view-model>
|
||||
</toggle-button-view-model>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="dislike">
|
||||
<toggle-button-view-model>
|
||||
<button-view-model>
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
|
||||
</svg>
|
||||
__DISLIKE_TEXT_MARKUP__
|
||||
<span data-fixture-touch-feedback aria-hidden="true"></span>
|
||||
</button>
|
||||
</button-view-model>
|
||||
</toggle-button-view-model>
|
||||
</dislike-button-view-model>
|
||||
</div>
|
||||
</div>
|
||||
</yt-smartimation>
|
||||
</segmented-like-dislike-button-view-model>
|
||||
`;
|
||||
}
|
||||
|
||||
function mobileButtonMarkup() {
|
||||
return `
|
||||
<like-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="like">
|
||||
<button type="button" aria-label="__VIDEO_LIKES__ likes" aria-pressed="false">
|
||||
<svg data-fixture-icon="like" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M8 21H5V9h3v12Zm2 0V9l4-7 2 1v5h5v5l-3 8h-8Z"></path>
|
||||
</svg>
|
||||
<div class="ytSpecButtonShapeNextButtonTextContent"><span id="text" class="button-renderer-text" role="text">__VIDEO_LIKES__</span></div>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model class="style-text" __FIXTURE_ROLE_ATTRIBUTE__="dislike">
|
||||
<button type="button" aria-label="Dislike this video" aria-pressed="false">
|
||||
<svg data-fixture-icon="dislike" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M16 3h3v12h-3V3Zm-2 0v12l-4 7-2-1v-5H3v-5l3-8h8Z"></path>
|
||||
</svg>
|
||||
__DISLIKE_TEXT_MARKUP__
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
`;
|
||||
}
|
||||
|
||||
function applyNativeState(state) {
|
||||
const like = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
|
||||
const dislike = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
|
||||
if (!like || !dislike) return;
|
||||
|
||||
like.classList.toggle("style-default-active", state === "liked");
|
||||
like.classList.toggle("style-text", state !== "liked");
|
||||
dislike.classList.toggle("style-default-active", state === "disliked");
|
||||
dislike.classList.toggle("style-text", state !== "disliked");
|
||||
like.querySelector("button").setAttribute("aria-pressed", String(state === "liked"));
|
||||
dislike.querySelector("button").setAttribute("aria-pressed", String(state === "disliked"));
|
||||
host.dataset.nativeState = state;
|
||||
}
|
||||
|
||||
function applyNativeLikeDelta(delta) {
|
||||
const button = host.querySelector('[__FIXTURE_ROLE_ATTRIBUTE__="like"] button');
|
||||
const text = button?.querySelector("#text, [role='text']");
|
||||
if (!button || !text) return;
|
||||
|
||||
const current = Number(text.textContent.replace(/[^\d]/g, "")) || 0;
|
||||
const next = Math.max(0, current + delta);
|
||||
text.textContent = String(next);
|
||||
button.setAttribute("aria-label", `${next} likes`);
|
||||
}
|
||||
|
||||
function insertButtons(state = "neutral") {
|
||||
host.innerHTML = isMobile ? mobileButtonMarkup() : desktopButtonMarkup();
|
||||
applyNativeState(state);
|
||||
}
|
||||
|
||||
function removeButtons() {
|
||||
host.replaceChildren();
|
||||
host.dataset.nativeState = "neutral";
|
||||
}
|
||||
|
||||
function installCommentActionSurfaces() {
|
||||
const comments = document.getElementById("fixture-comment-action-surfaces");
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const duplicate = document.createElement("div");
|
||||
duplicate.id = "top-level-buttons-computed";
|
||||
duplicate.setAttribute("data-fixture-comment-action-surface", String(index));
|
||||
duplicate.innerHTML = `
|
||||
<like-button-view-model data-fixture-comment-like>
|
||||
<button type="button" aria-label="Like comment" aria-pressed="false">
|
||||
<span role="text">1</span>
|
||||
</button>
|
||||
</like-button-view-model>
|
||||
<dislike-button-view-model data-fixture-comment-dislike>
|
||||
<button type="button" aria-label="Dislike comment" aria-pressed="false">
|
||||
<span data-fixture-touch-feedback aria-hidden="true"></span>
|
||||
</button>
|
||||
</dislike-button-view-model>
|
||||
`;
|
||||
comments.appendChild(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(videoId, { state = "neutral", buttons = true } = {}) {
|
||||
history.pushState({}, "", `/watch?v=${videoId}`);
|
||||
watch.setAttribute("video-id", videoId);
|
||||
if (buttons) insertButtons(state);
|
||||
else removeButtons();
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}
|
||||
|
||||
// Model the native YouTube toggle before userscript listeners observe the bubbled click.
|
||||
host.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const like = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="like"]');
|
||||
const dislike = event.target.closest('[__FIXTURE_ROLE_ATTRIBUTE__="dislike"]');
|
||||
const current = host.dataset.nativeState || "neutral";
|
||||
const next = like
|
||||
? current === "liked"
|
||||
? "neutral"
|
||||
: "liked"
|
||||
: dislike
|
||||
? current === "disliked"
|
||||
? "neutral"
|
||||
: "disliked"
|
||||
: current;
|
||||
if (next !== current) {
|
||||
applyNativeLikeDelta(Number(next === "liked") - Number(current === "liked"));
|
||||
applyNativeState(next);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
window.__youtubeFixture = {
|
||||
applyNativeState,
|
||||
insertButtons,
|
||||
navigate,
|
||||
removeButtons,
|
||||
setSignedIn,
|
||||
};
|
||||
|
||||
setSignedIn(__SIGNED_IN__);
|
||||
installCommentActionSurfaces();
|
||||
if (__INITIAL_BUTTONS__) insertButtons("__INITIAL_STATE__");
|
||||
window.__youtubeFixture.initialDislikeTextContainers = host.querySelectorAll(
|
||||
'[__FIXTURE_ROLE_ATTRIBUTE__="dislike"] #text, [__FIXTURE_ROLE_ATTRIBUTE__="dislike"] .ytSpecButtonShapeNextButtonTextContent',
|
||||
).length;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,419 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { isAllowedApiPreflight } = require("../../e2e/hermetic-api-contract");
|
||||
const { resolveRequestedUserscriptArtifact } = require("../../e2e/verify-userscript-artifact");
|
||||
|
||||
const REPOSITORY_ROOT = path.resolve(__dirname, "../../..");
|
||||
const GENERATED_USERSCRIPT = resolveRequestedUserscriptArtifact();
|
||||
const WATCH_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "watch-page.html"), "utf8");
|
||||
const SHORTS_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "shorts-page.html"), "utf8");
|
||||
const NAVIGATION_FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "navigation-page.html"), "utf8");
|
||||
|
||||
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
||||
const CREDENTIAL_KEY = "rydVoteCredentials";
|
||||
const VIDEO_A = "abcdefghijk";
|
||||
const VIDEO_B = "zyxwvutsrqp";
|
||||
const ZERO_DIFFICULTY_PUZZLE = {
|
||||
challenge: Buffer.alloc(16).toString("base64"),
|
||||
difficulty: 0,
|
||||
};
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
function requestKey(method, pathname) {
|
||||
return `${method.toUpperCase()} ${pathname}`;
|
||||
}
|
||||
|
||||
function parseRequestBody(request) {
|
||||
const text = request.postData();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonHeaders() {
|
||||
return {
|
||||
"access-control-allow-headers": "Accept, Content-Type",
|
||||
"access-control-allow-methods": "GET, POST, OPTIONS",
|
||||
"access-control-allow-origin": "*",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeBackend({ countsByVideo = {}, countDelayByVideo = {}, fixture = {} } = {}) {
|
||||
const blockedRequests = [];
|
||||
const preflightRequests = [];
|
||||
const requests = [];
|
||||
const responsePlans = new Map();
|
||||
const fixtureOptions = {
|
||||
initialButtons: fixture.initialButtons !== false,
|
||||
initialState: fixture.initialState || "neutral",
|
||||
nativeDislikeText: fixture.nativeDislikeText !== false,
|
||||
roleAttribute: fixture.roleAttribute || "data-ryd-role",
|
||||
signedIn: fixture.signedIn !== false,
|
||||
};
|
||||
|
||||
function enqueue(method, pathname, response) {
|
||||
const key = requestKey(method, pathname);
|
||||
const queue = responsePlans.get(key) || [];
|
||||
queue.push(response);
|
||||
responsePlans.set(key, queue);
|
||||
}
|
||||
|
||||
function defer(method, pathname) {
|
||||
let releaseResponse;
|
||||
let resolveSeen;
|
||||
let released = false;
|
||||
const seen = new Promise((resolve) => {
|
||||
resolveSeen = resolve;
|
||||
});
|
||||
|
||||
enqueue(method, pathname, (record) => {
|
||||
resolveSeen(record);
|
||||
return new Promise((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get released() {
|
||||
return released;
|
||||
},
|
||||
release(response) {
|
||||
if (released) return;
|
||||
if (!releaseResponse) {
|
||||
throw new Error(`Cannot release ${requestKey(method, pathname)} before its request is seen`);
|
||||
}
|
||||
released = true;
|
||||
releaseResponse(response);
|
||||
},
|
||||
seen,
|
||||
};
|
||||
}
|
||||
|
||||
function requestsFor(method, pathname) {
|
||||
return requests.filter((entry) => entry.method === method.toUpperCase() && entry.pathname === pathname);
|
||||
}
|
||||
|
||||
function takePlannedResponse(record) {
|
||||
const queue = responsePlans.get(requestKey(record.method, record.pathname));
|
||||
if (!queue?.length) return null;
|
||||
const planned = queue.shift();
|
||||
return typeof planned === "function" ? planned(record) : planned;
|
||||
}
|
||||
|
||||
function defaultApiResponse(record) {
|
||||
if (record.method === "GET" && record.pathname === "/configs/selectors") {
|
||||
return { body: {} };
|
||||
}
|
||||
|
||||
if (record.method === "GET" && record.pathname === "/votes") {
|
||||
const videoId = record.query.videoId;
|
||||
const counts = countsByVideo[videoId] || { dislikes: 25, likes: 100 };
|
||||
return {
|
||||
body: { ...counts, rating: 4.5 },
|
||||
delayMs: countDelayByVideo[videoId] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (record.method === "GET" && record.pathname === "/puzzle/registration") {
|
||||
return { body: ZERO_DIFFICULTY_PUZZLE };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/puzzle/registration") {
|
||||
return { body: true };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/interact/vote") {
|
||||
return { body: ZERO_DIFFICULTY_PUZZLE };
|
||||
}
|
||||
|
||||
if (record.method === "POST" && record.pathname === "/interact/confirmVote") {
|
||||
return { body: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handle(route) {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (["www.youtube.com", "m.youtube.com"].includes(url.hostname) && request.resourceType() === "document") {
|
||||
const isShorts = url.pathname.startsWith("/shorts/");
|
||||
const videoId = isShorts ? url.pathname.slice(8) || VIDEO_A : url.searchParams.get("v") || VIDEO_A;
|
||||
const isNavigationFixture = url.searchParams.get("rydNavigationFixture") === "1";
|
||||
const fixtureTemplate = isNavigationFixture ? NAVIGATION_FIXTURE : isShorts ? SHORTS_FIXTURE : WATCH_FIXTURE;
|
||||
const html = fixtureTemplate
|
||||
.replaceAll("__VIDEO_ID__", videoId)
|
||||
.replaceAll("__SECOND_VIDEO_ID__", VIDEO_B)
|
||||
.replaceAll("__VIDEO_LIKES__", String(countsByVideo[videoId]?.likes ?? 100))
|
||||
.replaceAll("__SECOND_VIDEO_LIKES__", String(countsByVideo[VIDEO_B]?.likes ?? 100))
|
||||
.replaceAll(
|
||||
"__DISLIKE_TEXT_MARKUP__",
|
||||
fixtureOptions.nativeDislikeText ? '<span id="text" role="text"></span>' : "",
|
||||
)
|
||||
.replaceAll("__FIXTURE_ROLE_ATTRIBUTE__", fixtureOptions.roleAttribute)
|
||||
.replaceAll("__INITIAL_PAGE_KIND__", isShorts ? "shorts" : url.pathname === "/watch" ? "watch" : "channel")
|
||||
.replaceAll(
|
||||
"__SHORTS_RENDERER_TAG__",
|
||||
url.hostname === "m.youtube.com" ? "ytm-like-button-renderer" : "ytd-like-button-renderer",
|
||||
)
|
||||
.replaceAll("__SIGNED_IN__", String(fixtureOptions.signedIn))
|
||||
.replaceAll("__INITIAL_BUTTONS__", String(fixtureOptions.initialButtons))
|
||||
.replaceAll("__INITIAL_STATE__", fixtureOptions.initialState);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: html,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.origin !== API_ORIGIN) {
|
||||
blockedRequests.push({
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
url: request.url(),
|
||||
});
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "OPTIONS") {
|
||||
const requestedMethod = request.headers()["access-control-request-method"];
|
||||
const preflight = {
|
||||
method: request.method(),
|
||||
pathname: url.pathname,
|
||||
requestedMethod: requestedMethod ?? null,
|
||||
url: url.toString(),
|
||||
};
|
||||
if (!isAllowedApiPreflight(url.pathname, requestedMethod)) {
|
||||
blockedRequests.push({
|
||||
...preflight,
|
||||
resourceType: request.resourceType(),
|
||||
});
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
preflightRequests.push(preflight);
|
||||
await route.fulfill({ status: 204, headers: jsonHeaders(), body: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const record = {
|
||||
at: Date.now(),
|
||||
body: parseRequestBody(request),
|
||||
method: request.method(),
|
||||
pathname: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
url: url.toString(),
|
||||
};
|
||||
requests.push(record);
|
||||
|
||||
const plannedResponse = takePlannedResponse(record);
|
||||
const response = (plannedResponse ? await plannedResponse : plannedResponse) || defaultApiResponse(record);
|
||||
if (!response) {
|
||||
blockedRequests.push({
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
url: request.url(),
|
||||
});
|
||||
await route.abort("blockedbyclient");
|
||||
return;
|
||||
}
|
||||
if (response.delayMs) await delay(response.delayMs);
|
||||
|
||||
const body = response.body === undefined ? null : response.body;
|
||||
const responseStatus = response.status || 200;
|
||||
await route.fulfill({
|
||||
status: responseStatus,
|
||||
headers: { ...jsonHeaders(), ...(response.headers || {}) },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
record.responseBody = body;
|
||||
record.responseStatus = responseStatus;
|
||||
record.respondedAt = Date.now();
|
||||
}
|
||||
|
||||
return {
|
||||
blockedRequests,
|
||||
defer,
|
||||
enqueue,
|
||||
handle,
|
||||
preflightRequests,
|
||||
requests,
|
||||
requestsFor,
|
||||
};
|
||||
}
|
||||
|
||||
async function installGmEnvironment(context, initialValues = {}) {
|
||||
await context.addInitScript(
|
||||
({ initialValues: seededValues, storagePrefix }) => {
|
||||
if (!location.hostname.endsWith("youtube.com")) return;
|
||||
|
||||
const storageKey = (key) => `${storagePrefix}${key}`;
|
||||
const read = (key, fallbackValue) => {
|
||||
const stored = localStorage.getItem(storageKey(key));
|
||||
if (stored === null) return fallbackValue;
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
const write = (key, value) => {
|
||||
if (value === undefined) localStorage.removeItem(storageKey(key));
|
||||
else localStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(seededValues)) {
|
||||
if (localStorage.getItem(storageKey(key)) === null) write(key, value);
|
||||
}
|
||||
|
||||
globalThis.__gmCalls = [];
|
||||
const getValue = async (key, fallbackValue) => {
|
||||
globalThis.__gmCalls.push({ operation: "get", key });
|
||||
return read(key, fallbackValue);
|
||||
};
|
||||
const setValue = async (key, value) => {
|
||||
globalThis.__gmCalls.push({ operation: "set", key, value });
|
||||
write(key, value);
|
||||
};
|
||||
const deleteValue = async (key) => {
|
||||
globalThis.__gmCalls.push({ operation: "delete", key });
|
||||
localStorage.removeItem(storageKey(key));
|
||||
};
|
||||
const addStyle = (css) => {
|
||||
const style = document.createElement("style");
|
||||
style.dataset.rydGmStyle = "true";
|
||||
style.textContent = css;
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
return style;
|
||||
};
|
||||
|
||||
globalThis.GM = { getValue, setValue, deleteValue, addStyle };
|
||||
globalThis.GM_getValue = getValue;
|
||||
globalThis.GM_setValue = setValue;
|
||||
globalThis.GM_deleteValue = deleteValue;
|
||||
globalThis.GM_addStyle = addStyle;
|
||||
},
|
||||
{
|
||||
initialValues,
|
||||
storagePrefix: "ryd-e2e-gm:",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function installHermeticRoutes(context, backend) {
|
||||
await context.route("**/*", (route) => backend.handle(route));
|
||||
}
|
||||
|
||||
async function openWatchFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
||||
await page.goto(`https://${hostname}/watch?v=${videoId}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function openShortsFixture(page, videoId = VIDEO_A, { hostname = "www.youtube.com" } = {}) {
|
||||
await page.goto(`https://${hostname}/shorts/${videoId}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function openNavigationFixture(
|
||||
page,
|
||||
{ hostname = "www.youtube.com", pageKind = "channel", videoId = VIDEO_A } = {},
|
||||
) {
|
||||
const marker = "rydNavigationFixture=1";
|
||||
const path =
|
||||
pageKind === "shorts"
|
||||
? `/shorts/${videoId}?${marker}`
|
||||
: pageKind === "watch"
|
||||
? `/watch?v=${videoId}&${marker}`
|
||||
: `/@FixtureChannel?${marker}`;
|
||||
await page.goto(`https://${hostname}${path}`, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
|
||||
async function forbidUnsafeHtmlSinks(page) {
|
||||
await page.evaluate(() => {
|
||||
const innerHtmlDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
|
||||
if (!innerHtmlDescriptor?.get || !innerHtmlDescriptor?.set) {
|
||||
throw new Error("Element.innerHTML descriptor is unavailable");
|
||||
}
|
||||
|
||||
globalThis.__rydUnsafeHtmlSinkCalls = [];
|
||||
const reject = (sink) => {
|
||||
globalThis.__rydUnsafeHtmlSinkCalls.push(sink);
|
||||
throw new TypeError(`${sink} is forbidden by the Trusted Types fixture`);
|
||||
};
|
||||
|
||||
Object.defineProperty(Element.prototype, "innerHTML", {
|
||||
configurable: innerHtmlDescriptor.configurable,
|
||||
enumerable: innerHtmlDescriptor.enumerable,
|
||||
get: innerHtmlDescriptor.get,
|
||||
set() {
|
||||
reject("Element.innerHTML");
|
||||
},
|
||||
});
|
||||
Element.prototype.insertAdjacentHTML = function () {
|
||||
reject("Element.insertAdjacentHTML");
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function overrideBooleanOption(source, optionName, value) {
|
||||
const optionPattern = new RegExp(`${optionName}:\\s*(?:true|false)`, "g");
|
||||
const matches = source.match(optionPattern) || [];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Expected one ${optionName} option in generated userscript, found ${matches.length}`);
|
||||
}
|
||||
return source.replace(optionPattern, `${optionName}: ${value}`);
|
||||
}
|
||||
|
||||
async function injectGeneratedUserscript(
|
||||
page,
|
||||
{ artifactPath = GENERATED_USERSCRIPT, coloredThumbs, disableVoteSubmission = false, rateBarEnabled } = {},
|
||||
) {
|
||||
if (!fs.existsSync(artifactPath)) {
|
||||
throw new Error(`Generated userscript is missing: ${artifactPath}`);
|
||||
}
|
||||
|
||||
if (!disableVoteSubmission && rateBarEnabled === undefined && coloredThumbs === undefined) {
|
||||
await page.addScriptTag({ path: artifactPath });
|
||||
return;
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(artifactPath, "utf8");
|
||||
if (disableVoteSubmission) {
|
||||
source = overrideBooleanOption(source, "disableVoteSubmission", true);
|
||||
}
|
||||
if (rateBarEnabled !== undefined) {
|
||||
source = overrideBooleanOption(source, "rateBarEnabled", rateBarEnabled);
|
||||
}
|
||||
if (coloredThumbs !== undefined) {
|
||||
source = overrideBooleanOption(source, "coloredThumbs", coloredThumbs);
|
||||
}
|
||||
await page.addScriptTag({ content: source });
|
||||
}
|
||||
|
||||
async function readGmValue(page, key) {
|
||||
return page.evaluate((storageKey) => globalThis.GM.getValue(storageKey, null), key);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_ORIGIN,
|
||||
CREDENTIAL_KEY,
|
||||
GENERATED_USERSCRIPT,
|
||||
VIDEO_A,
|
||||
VIDEO_B,
|
||||
ZERO_DIFFICULTY_PUZZLE,
|
||||
createFakeBackend,
|
||||
forbidUnsafeHtmlSinks,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openNavigationFixture,
|
||||
openShortsFixture,
|
||||
openWatchFixture,
|
||||
readGmValue,
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
# Live YouTube smoke
|
||||
|
||||
This suite attaches over CDP to an already-running Chrome, Brave, or compatible Chromium profile. It is deliberately
|
||||
separate from `test:all` and CI.
|
||||
The same page-level checks run in either userscript or extension mode; installation, storage, and protocol failure
|
||||
coverage remain in the hermetic suites.
|
||||
|
||||
## Browser preparation
|
||||
|
||||
1. Open the target browser's remote-debugging page (for example, `chrome://inspect/#remote-debugging` or
|
||||
`brave://inspect/#remote-debugging`) and enable **Allow remote debugging for this browser instance**. If the page
|
||||
displays only a server such as `127.0.0.1:60011`, set `RYD_CDP_ENDPOINT` to that exact host and port. Current Chromium
|
||||
builds can return 404 from `/json/version` in this mode; the launcher reads the full browser WebSocket path from the
|
||||
matching `DevToolsActivePort` file in the standard Chrome, Brave, Edge, or Chromium user-data root. Explicit
|
||||
`ws://...` endpoints and legacy `http://127.0.0.1:9222` endpoints remain supported unchanged. Set the endpoint to the
|
||||
browser alias `chrome`, `brave`, `edge`, or `chromium` to read that browser's standard active-port file directly.
|
||||
For a custom or portable profile, set either `RYD_CDP_USER_DATA_DIR` to its user-data root or
|
||||
`RYD_CDP_ACTIVE_PORT_FILE` to the exact file; do not set both overrides. The launcher waits 120 seconds for the
|
||||
browser's **Allow remote debugging** confirmation. `RYD_CDP_CONNECT_TIMEOUT_MS` can override that wait from 15000 to
|
||||
300000 milliseconds.
|
||||
2. Use a dedicated two-item playlist containing two allowlisted public or unlisted test videos. Both must be accessible
|
||||
to the signed-in profile and must have different rendered dislike counts.
|
||||
3. Enable exactly one runtime:
|
||||
- `userscript`: disable the browser extension and every other Return YouTube Dislike userscript. A read-only run
|
||||
requires `npm run build:live:userscript` followed by importing
|
||||
`test-results/live-build/userscript/Return Youtube Dislike.user.js` into Tampermonkey. A full run performs that
|
||||
build itself, stops before browser attachment, and requires `INSTALLED` after the freshly generated file has been
|
||||
reimported.
|
||||
- `extension`: disable every Return YouTube Dislike userscript and any store-installed copy of the extension. A
|
||||
read-only run requires `npm run build:live:extension` followed by `npm run reload:live:extension`. A full run
|
||||
performs the fresh build and reload itself. The already-enabled unpacked extension must point at
|
||||
`Extensions/combined/dist/chrome`. Keep an authenticated ordinary
|
||||
YouTube page open in the target profile. Before reloading anything, the runner scans every attached browser
|
||||
context and page, ignores internal and non-YouTube tabs, and verifies the exact public
|
||||
`RYD_LIVE_EXPECTED_CHANNEL` handle from YouTube's account menu. Exactly one context may match. It reuses that
|
||||
existing page for the CDP session and the worker's own identity check; it never reads cookies or storage and fails
|
||||
closed on an absent, unverifiable, or ambiguous profile.
|
||||
Every live build receives a fresh random build ID even when its semantic version is unchanged. Import or reload the
|
||||
runtime **after the final live build command**; rebuilding again invalidates the installed copy for this smoke.
|
||||
Hard-reload any already-open YouTube tab before ad hoc testing after an unpacked-extension reload. Existing documents
|
||||
retain their previously injected content script; a rendered count or control in such a tab does not prove that the
|
||||
newly built bundle is running. The automated suite avoids this ambiguity by creating a fresh tab and checking the
|
||||
exact live-build marker before every scenario.
|
||||
4. Keep the browser window open and do not interact with the tab created by the suite. The suite creates, uses, and
|
||||
closes only its own tab.
|
||||
|
||||
Only the explicit live-test builds expose page markers containing their runtime, version, and exact build ID. Normal
|
||||
production builds do not expose them. The suite reads the expected ID from the generated `live-build.json`, verifies
|
||||
that the installed runtime exposes that exact ID and expected version, and verifies that the other runtime marker is
|
||||
absent before every scenario. A stale installed script or extension therefore fails even when it has the same version.
|
||||
Before connecting to the browser it also verifies a build receipt against every current userscript or extension source
|
||||
input and against the generated artifact bytes. Editing source after a build, changing generated output, or reusing a
|
||||
receipt from another build fails closed. There is no environment override for the build ID or receipt.
|
||||
|
||||
The read-only smoke still loads real YouTube and allows the installed runtime to read from the production RYD API,
|
||||
including eager registration when the selected runtime has no confirmed identity. It does not mock API responses, but
|
||||
it blocks production `POST /interact/*` requests before transmission as described below.
|
||||
|
||||
## Non-voting smoke
|
||||
|
||||
Set the following in PowerShell. Video IDs are the 11-character values from YouTube URLs, and the playlist URL must
|
||||
start on `RYD_LIVE_WATCH_A`.
|
||||
|
||||
```powershell
|
||||
$env:RYD_LIVE_YOUTUBE="1"
|
||||
$env:RYD_LIVE_PRODUCTION_API="1"
|
||||
$env:RYD_CDP_ENDPOINT="127.0.0.1:60011" # Copy the current value shown by the browser.
|
||||
# Optional for a custom or portable profile (choose only one):
|
||||
# $env:RYD_CDP_USER_DATA_DIR="C:\path\to\User Data"
|
||||
# $env:RYD_CDP_ACTIVE_PORT_FILE="C:\path\to\User Data\DevToolsActivePort"
|
||||
# $env:RYD_CDP_CONNECT_TIMEOUT_MS="180000"
|
||||
$env:RYD_LIVE_RUNTIME="userscript"
|
||||
$env:RYD_LIVE_EXPECTED_CHANNEL="@your-test-channel"
|
||||
$env:RYD_LIVE_WATCH_A="AAAAAAAAAAA"
|
||||
$env:RYD_LIVE_WATCH_B="BBBBBBBBBBB"
|
||||
$env:RYD_LIVE_SHORT="CCCCCCCCCCC"
|
||||
# Optional: use a different allowlisted Short whose exact count visibly changes by one for reaction validation.
|
||||
# Defaults to RYD_LIVE_SHORT.
|
||||
# $env:RYD_LIVE_REACTION_SHORT="EEEEEEEEEEE"
|
||||
$env:RYD_LIVE_PLAYLIST_URL="https://www.youtube.com/watch?v=AAAAAAAAAAA&list=PLAYLIST_ID"
|
||||
# Optional overrides for the cold channel-navigation smoke. These are the defaults:
|
||||
$env:RYD_LIVE_NAV_CHANNEL_URL="https://www.youtube.com/@MrBeast"
|
||||
$env:RYD_LIVE_NAV_SHORT="5mU6SRS2Bxo"
|
||||
$env:RYD_LIVE_NAV_WATCH="Qtl8lJwbd4g" # Exact visible Watch link on the configured channel page.
|
||||
# Optional; the Shorts stress targets 10 successful Next samples and accepts 10 through 25.
|
||||
$env:RYD_LIVE_SHORTS_NEXT_HOPS="10"
|
||||
# Optional; the consecutive watch-sidebar stress defaults to three hops and accepts 1 through 10.
|
||||
$env:RYD_LIVE_SIDEBAR_HOPS="3"
|
||||
npm run test:live:youtube:read-only
|
||||
```
|
||||
|
||||
The default channel fixture was last verified in authenticated Chrome on September 4, 2026. Its exact first Short was
|
||||
visible on the channel shelf, and ten consecutive visible **Next video** controls reached ten distinct Shorts. Reverify
|
||||
the exact channel cards before a later release run because YouTube channel ordering changes over time.
|
||||
|
||||
The navigation smoke derives the channel's `/shorts` and `/videos` tabs from `RYD_LIVE_NAV_CHANNEL_URL`, performs a
|
||||
real hard load and reload of the relevant tab, finds the configured exact visible target, and activates that link. If
|
||||
YouTube ignores the first hit-tested pointer activation, the driver re-resolves the same exact target and makes one
|
||||
hit-tested keyboard activation attempt. It fails rather than falling back to direct navigation when the relevant
|
||||
channel tab no longer contains the exact card. It proves the channel-to-Short transition reused the same document, verifies
|
||||
that the selected runtime initialized the current Short, then keeps pressing YouTube's visible **Next video** control
|
||||
until it has audited at least 10 successful, distinct samples. `RYD_LIVE_SHORTS_NEXT_HOPS` is the successful-sample
|
||||
target, not the raw number of presses; the runner makes at most twice that many Next attempts. The initial transition
|
||||
and every attempt baseline the driver's request IDs before navigation and reject any stale `/votes` request. A valid
|
||||
sample must have at least one visible native YouTube action, an HTTP 200 response whose JSON `id` equals the exact
|
||||
current 11-character video ID, and the authoritative dislike count rendered in one of the runtime's supported localized
|
||||
number formats. Every valid Next sample must emit exactly one request. During the initial transition, the extension may
|
||||
instead emit the source-supported sequence of one aggregate request followed by one request enriched with YouTube's
|
||||
newly available numeric `likeCount`; the second response is authoritative. A repeated aggregate request, repeated
|
||||
enrichment, reversed order, third request, or invalid `likeCount` still fails as duplicate traffic. Every attempt must
|
||||
also keep the same document and produce a video ID that has not appeared earlier in the sequence. The current
|
||||
synthetic dislike control must have no visible stale or duplicate controls and must preserve its count and ownership
|
||||
through a bounded two-second stability soak. The suite also rechecks the runtime build marker and the complete Shorts
|
||||
action-stack geometry after every hop. Page-owned requests must belong to the suite tab. Frame-less requests are
|
||||
accepted only from the selected extension's `ryd.background.js` service worker, and the hop fails as ambiguous if
|
||||
another browser tab has the destination video open. The channel-to-Short check runs first in the fresh suite tab so
|
||||
earlier watch or Shorts visits cannot warm the page lifecycle it is
|
||||
meant to exercise. Cropped evidence for the initial Short and every hop is written under
|
||||
`test-results/live-youtube/shorts-navigation/` for both runtimes.
|
||||
|
||||
YouTube itself sometimes leaves a selected reel with no visible native action controls. The runner samples the whole
|
||||
visible Shorts rail across a bounded 20-second observation window. Each synchronous DOM sample also returns the exact
|
||||
current reel's visible video box and viewport, which the runner uses to periodically pulse the real browser pointer so
|
||||
an idle auto-hidden rail gets a fair chance to appear. It does not walk retained video locators or wait for animation
|
||||
frames in this polling path. Every DOM probe and pointer operation has its own two-second watchdog; a stalled browser
|
||||
operation invalidates and fails the stage instead of producing blank evidence. Native buttons are evaluated individually
|
||||
instead of requiring their whole action-bar host to fit in the viewport. Before a sample is finally recorded as blank,
|
||||
the runner captures its evidence, waits two animation frames, and remeasures once; a rail revealed at that boundary
|
||||
follows the normal strict runtime-control assertions. Only a sample with zero visible native actions for that complete window is recorded
|
||||
as `shorts-sample.skipped`, including its video ID, observation duration, reason, attempt number, and whether any
|
||||
`/votes` request was seen. Any request seen during a blank sample is still required to target that exact current ID and
|
||||
return a valid successful response; stale traffic is never forgiven. If even one meaningful native action is visible,
|
||||
a missing runtime Dislike or missing `/votes` request remains a hard failure. The run passes only after reaching the
|
||||
configured successful-sample target and only when valid samples strictly outnumber blank YouTube samples.
|
||||
|
||||
YouTube can occasionally focus the visible **Next video** button without acting on its first trusted click. On each
|
||||
attempt, the live driver waits five seconds for that first click, prints
|
||||
`LIVE_CHECKPOINT shorts-next-control.retrying`, and makes exactly one more trusted click with the remaining 25-second
|
||||
navigation budget. If the URL still does not advance, the scenario fails; it never loops indefinitely or clicks a
|
||||
reaction control as part of this retry.
|
||||
|
||||
After each Shorts URL advances, the driver deliberately leaves playback running while YouTube hydrates the new reel,
|
||||
the production `/votes` response arrives, and the current dislike control and geometry render. It pauses only after all
|
||||
configured successful samples pass, so newly selected Shorts may play briefly before the `LIVE_CHECKPOINT
|
||||
playback.paused` message appears.
|
||||
|
||||
The watch-sidebar stress starts with the allowlisted `RYD_LIVE_WATCH_A`, then takes the first eligible visible
|
||||
`#related` watch link on each page for the configured number of consecutive SPA hops. Previously visited IDs are
|
||||
skipped. Every hop applies the same new-request baseline and exact request/HTTP/body-ID oracle, compares the rendered
|
||||
dislike count with that response,
|
||||
requires exactly one visible ratio bar for the selected runtime with valid reaction-control geometry, and samples that
|
||||
same bar and count for four seconds to catch delayed YouTube pruning. It never clicks Like or Dislike. Deterministic
|
||||
evidence paths are overwritten on each run at
|
||||
`test-results/live-youtube/sidebar-stress/{runtime}-sidebar-hop-{01..N}.png`. The same scenario runs in userscript and
|
||||
extension mode.
|
||||
|
||||
The direct Watch, reload, playlist SPA, and cold channel-to-Watch scenarios use that same exact network oracle.
|
||||
The userscript must issue one request. The extension may issue either one request or the single ordered aggregate-to-
|
||||
`likeCount` refinement described above, matching the intentional richer-refresh behavior in its shared count client.
|
||||
After each response they require one current-video ratio bar and fill, reject a visible stale or duplicate bar, compare
|
||||
the rendered localized count with the final authoritative API response, and repeatedly sample the current video and
|
||||
unchanged result for a bounded stability interval.
|
||||
|
||||
Every non-voting live scenario installs a BrowserContext deny route before it starts and aborts every production
|
||||
`POST /interact/*` request. The accompanying request observer intentionally does not require a page frame, so attempts
|
||||
from an extension or service worker also fail the scenario. The guard applies to the entire attached browser context;
|
||||
do not use another tab in that Brave profile to react to a video while the smoke is running.
|
||||
|
||||
Automatic media-ended transitions for both watch pages and Shorts stay in the deterministic hermetic Playwright suite.
|
||||
This production smoke intentionally exercises at least 10 valid Shorts **Next video** transitions; fully blank native
|
||||
YouTube rails do not reduce that total. Live autoplay timing, recommendation queues, ads, and account experiments are
|
||||
not stable enough to make an exact production transition a reliable gate. The existing two-item playlist smoke
|
||||
continues to cover an explicit watch-page SPA transition.
|
||||
|
||||
The default channel dataset can drift as its public page changes. Override the channel URL and both target IDs with a
|
||||
channel whose `/shorts` and `/videos` tabs deliberately retain those exact cards. The channel URL is restricted to a
|
||||
plain HTTPS `youtube.com/@handle` page or its `featured`, `shorts`, or `videos` tab; either way the driver selects the
|
||||
kind-specific tab. Each target must be an 11-character video ID.
|
||||
|
||||
The cold channel-to-Watch scenario is required. `RYD_LIVE_NAV_WATCH` must identify an exact visible Watch link on the
|
||||
configured channel page; an absent or stale target fails the run and cannot count as completed coverage.
|
||||
|
||||
`npm run test:live:youtube:read-only` is the deliberately non-voting command. Its completion record is classified as
|
||||
`read-only` with `releaseReady: false`; it cannot be reported as full validation. For a full run, use
|
||||
`npm run test:live:youtube` (or its `:full` alias). It builds the selected runtime first, runs every read-only scenario,
|
||||
then waits for the production-reaction approval described below. Entering `SKIP` fails the command as incomplete.
|
||||
|
||||
To keep one approved browser-debugging connection alive across complete retries, opt into the persistent interactive
|
||||
session before starting the full runner:
|
||||
|
||||
```powershell
|
||||
$env:RYD_LIVE_KEEP_CDP_SESSION="1"
|
||||
npm run test:live:youtube
|
||||
```
|
||||
|
||||
After each passed or failed attempt it archives the current live evidence under
|
||||
`test-results/live-youtube/attempts/`, prints `READY_FOR_LIVE_RERUN_OR_EXIT`, and waits for exactly `RERUN` or `EXIT`.
|
||||
`RERUN` rebuilds the selected runtime, reloads the unpacked extension when applicable, and executes the complete
|
||||
read-only and reaction flow again through the same top-level browser connection instead of making a new attachment.
|
||||
Each rerun still requires a fresh reaction approval token, and a userscript rerun still requires a fresh install
|
||||
acknowledgement. The endpoint, authenticated handle, and runtime cannot change while reusing the connection. `EXIT`
|
||||
closes the connection and returns the latest attempt's status. Leaving
|
||||
`RYD_LIVE_KEEP_CDP_SESSION` unset preserves the normal single-attempt behavior.
|
||||
|
||||
The visual pass checks the watch-page ratio bar at widths 1280, 768, and 390. For both runtimes it also checks the
|
||||
modern Shorts synthetic dislike control, its geometry, its rendered count, and its position beside the active reel's
|
||||
native Like control at those widths: exact action-host, button, and icon sizes; typography; spacing; common reel
|
||||
ownership; ordering; and viewport containment. Cropped evidence images are written
|
||||
under `test-results/live-youtube/responsive/`. If YouTube's native Like/Dislike pill is horizontally clipped by its own
|
||||
mobile page overflow, the ratio bar may share only that same native left/right footprint; any extra overflow introduced
|
||||
by the RYD bar still fails.
|
||||
|
||||
The interactive runner prints `LIVE_STAGE_START`, `LIVE_STAGE_COMPLETE`, and `LIVE_CHECKPOINT` records while it works.
|
||||
In particular, `LIVE_CHECKPOINT playback.paused` means the runner deliberately paused the current YouTube video while
|
||||
validating it; a stationary frame after that message is expected and is not evidence that Brave froze. Navigation,
|
||||
account, runtime, control, and production `/votes` waits have their own checkpoints, so the last line identifies the
|
||||
pending operation. Every read-only stage also reports when it starts and completes removal of its production-interaction
|
||||
deny route. That cleanup and the final test-tab close each have a ten-second watchdog: a stalled Chrome target fails the
|
||||
attempt and returns control to the persistent runner instead of waiting indefinitely. A cleanup watchdog failure never
|
||||
permits the runner to continue into production reactions.
|
||||
|
||||
Both live runners treat a page error, unhandled promise rejection, unexpected `console.error`, or error from the
|
||||
selected extension's service worker as a test failure even when the DOM and network assertions otherwise pass. A
|
||||
benign message can be ignored only by adding an explicit rule that matches its exact signal type, full message, and
|
||||
full source URL; there are no wildcard or callback suppressions. The Playwright runner uses a fresh page, driver, and
|
||||
diagnostic guard for each scenario and one worker, so one failed read-only scenario does not suppress the remaining
|
||||
independent scenarios. Its production-reaction test is gated on every required read-only scenario completing in the
|
||||
same worker; a failure-driven worker restart therefore cannot accidentally carry a green gate forward. The interactive
|
||||
runner also completes all independent read-only stages, then returns an aggregate failure and never reaches reaction
|
||||
approval if any failed.
|
||||
|
||||
If a scenario fails, the runner records those browser signals and the latest RYD API request outcomes before closing
|
||||
its test tab. It writes a JSON snapshot under
|
||||
`test-results/live-youtube/diagnostics/` and prints its absolute path as `LIVE_FAILURE_SNAPSHOT`. The snapshot includes
|
||||
the URL, runtime markers, Shorts renderer IDs and links, action-bar and synthetic-control ownership, video paused state,
|
||||
and recent API paths/statuses. Anonymous identity and proof-related query values are redacted. Preserve this file when
|
||||
reporting a live-only failure; ordinary direct interactive runs otherwise have no Playwright trace.
|
||||
|
||||
Use `RYD_LIVE_RUNTIME="extension"` after manually switching the enabled runtime to execute the same smoke against the
|
||||
extension. The expected version defaults to the local userscript candidate version or root package version; set
|
||||
`RYD_LIVE_EXPECTED_VERSION` only when deliberately validating another installed build.
|
||||
|
||||
## Full production reaction validation
|
||||
|
||||
The full command cannot succeed without this phase. Its first check opens `RYD_LIVE_WATCH_A`, clicks the exact
|
||||
`RYD_LIVE_WATCH_B` playlist link, proves that YouTube kept the same document, and only then clicks Dislike on B. It
|
||||
records before navigation and fails if navigation itself emits any `/interact/*` request. The reaction and its cleanup
|
||||
must each send one logical handshake for B's exact video ID, and the cleanup must restore B's initial YouTube reaction
|
||||
state. This is the production stale-video-ID gate shared by the userscript and extension.
|
||||
|
||||
The same approved reaction phase then covers all six Like/Dislike state transitions on `RYD_LIVE_WATCH_B` and the
|
||||
allowlisted `RYD_LIVE_REACTION_SHORT` (defaulting to `RYD_LIVE_SHORT`), asserts one logical production handshake for
|
||||
every transition, and returns each video to its initial
|
||||
reaction state. A handshake contains one to three matching `/interact/vote` puzzle requests followed by exactly one
|
||||
matching successful `/interact/confirmVote`. Every request must receive a 2xx response; confirmation must return the
|
||||
literal JSON value `true`. Any fourth vote request, changed identity/video/value, extra confirmation, or other
|
||||
interaction traffic fails the run. It does not retry cleanup blindly if the state cannot be verified.
|
||||
If click dispatch, the post-click state wait, or a handshake fails after a vote attempt while the UI already appears to
|
||||
be back at its initial state, cleanup does not trust the UI alone: it confirms an away-and-back reaction round trip with
|
||||
the same anonymous identity. A failed cleanup confirmation or identity mismatch reports the exact video URL for manual
|
||||
restoration.
|
||||
|
||||
The matrix also captures the initial state and every post-transition state, for seven watch images and seven Shorts
|
||||
images. Before each screenshot it reads both `aria-pressed` values, verifies the exact expected mutually-exclusive
|
||||
state, and requires a numeric dislike count. Watch captures additionally require a visible, non-overlapping ratio bar
|
||||
with sane geometry. Userscript Shorts captures reuse the strict native-vs-synthetic control geometry checks, including
|
||||
the 48x78 action host, 24x24 icon, typography, spacing, active-reel ownership, and duplicate-control checks. Evidence is
|
||||
written with deterministic names under
|
||||
`test-results/live-youtube/reactions/{runtime}/{watch|short}-{0..6}-{neutral|liked|disliked}.png`. A failed visual check
|
||||
still enters the same verified restoration path before the scenario reports the failure.
|
||||
|
||||
The exact live-build marker proves that the freshly generated test build is present and that the opposite runtime is
|
||||
absent. It cannot detect an additional normal build of the same runtime, so the manual single-runtime preparation above
|
||||
is mandatory.
|
||||
|
||||
Only after the full runner prints `READY_FOR_REACTION_APPROVAL`, create a runtime-and-video-specific token in a second
|
||||
PowerShell window and paste it into the waiting runner. It expires after two minutes and is consumed once locally at the
|
||||
first post-navigation reaction. That
|
||||
single approval gates the contiguous post-navigation check and reaction matrix, so the userscript token cannot silently
|
||||
authorize a later extension run.
|
||||
|
||||
```powershell
|
||||
$runtime="extension" # Or "userscript", matching the waiting run.
|
||||
$watchB="BBBBBBBBBBB"
|
||||
"$runtime`:$watchB`:$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the copied value into the waiting full-run terminal. The command exits successfully only after both
|
||||
`post-navigation-vote` and `reaction-matrix` complete, then emits `LIVE_VALIDATION_COMPLETE` with
|
||||
`productionReactionsCompleted: true` and `releaseReady: true`.
|
||||
|
||||
After the live run, turn off **Allow remote debugging for this browser instance** or restart the test browser profile.
|
||||
@@ -0,0 +1,43 @@
|
||||
const path = require("node:path");
|
||||
const {
|
||||
verifyBuildReceipt,
|
||||
verifyMirroredJavaScript,
|
||||
verifyProductionJavaScript,
|
||||
verifyStandaloneMv3Artifact,
|
||||
} = require("../../../e2e/verify-extension-artifact");
|
||||
const { verifyGeneratedUserscriptArtifact } = require("../../../e2e/verify-userscript-artifact");
|
||||
|
||||
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const EXTENSION_DIST_ROOT = path.join(REPOSITORY_ROOT, "Extensions", "combined", "dist");
|
||||
const EXTENSION_CHROME_ARTIFACT = path.join(EXTENSION_DIST_ROOT, "chrome");
|
||||
|
||||
function assertCurrentLiveArtifact(
|
||||
runtime,
|
||||
expectedBuildId,
|
||||
{
|
||||
verifyExtensionBuildReceipt = () => verifyBuildReceipt(EXTENSION_DIST_ROOT, REPOSITORY_ROOT),
|
||||
verifyExtensionJavaScript = () => {
|
||||
verifyMirroredJavaScript(EXTENSION_DIST_ROOT);
|
||||
verifyProductionJavaScript(EXTENSION_DIST_ROOT);
|
||||
return verifyStandaloneMv3Artifact(EXTENSION_CHROME_ARTIFACT);
|
||||
},
|
||||
verifyUserscript = verifyGeneratedUserscriptArtifact,
|
||||
} = {},
|
||||
) {
|
||||
if (runtime === "extension") {
|
||||
const receipt = verifyExtensionBuildReceipt();
|
||||
const artifact = verifyExtensionJavaScript();
|
||||
return { artifact, buildId: expectedBuildId, receipt, runtime };
|
||||
}
|
||||
if (runtime === "userscript") {
|
||||
const receipt = verifyUserscript({ expectedBuildId, liveTestBuild: true });
|
||||
return { buildId: expectedBuildId, receipt, runtime };
|
||||
}
|
||||
throw new TypeError(`Unsupported live artifact runtime: ${runtime}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXTENSION_CHROME_ARTIFACT,
|
||||
EXTENSION_DIST_ROOT,
|
||||
assertCurrentLiveArtifact,
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
const DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_ACCOUNT_SELECTION_ATTEMPTS = 2;
|
||||
const DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS = 250;
|
||||
const YOUTUBE_HOSTNAMES = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
|
||||
|
||||
function normalizeChannelHandle(value) {
|
||||
if (typeof value !== "string" || !/^@[A-Za-z0-9._-]{3,100}$/.test(value.trim())) {
|
||||
throw new Error("An exact public YouTube @handle is required to select the live browser profile.");
|
||||
}
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isCommittedYoutubePage(page) {
|
||||
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
|
||||
try {
|
||||
const url = new URL(page.url());
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
YOUTUBE_HOSTNAMES.has(url.hostname.toLowerCase()) &&
|
||||
!url.port &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function inspectYoutubeSessionDocument() {
|
||||
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
|
||||
const configuredLoggedIn = (() => {
|
||||
try {
|
||||
const value = globalThis.ytcfg?.get?.("LOGGED_IN") ?? globalThis.ytcfg?.data_?.LOGGED_IN;
|
||||
return typeof value === "boolean" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
committed:
|
||||
Boolean(document.documentElement) &&
|
||||
location.protocol === "https:" &&
|
||||
allowedHostnames.includes(location.hostname.toLowerCase()) &&
|
||||
!location.port &&
|
||||
!location.username &&
|
||||
!location.password,
|
||||
configuredLoggedIn,
|
||||
};
|
||||
}
|
||||
|
||||
function inspectVisibleAccountMenu(expectedHandle) {
|
||||
const allowedHostnames = ["youtube.com", "www.youtube.com", "m.youtube.com"];
|
||||
const normalizedExpectedHandle = expectedHandle.toLowerCase();
|
||||
const isVisible = (element) => {
|
||||
const rectangle = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rectangle.width > 0 && rectangle.height > 0 && style.display !== "none" && style.visibility !== "hidden";
|
||||
};
|
||||
const menuSelectors = [
|
||||
"ytd-multi-page-menu-renderer",
|
||||
"ytm-multi-page-menu-renderer",
|
||||
"tp-yt-iron-dropdown ytd-active-account-header-renderer",
|
||||
];
|
||||
const menus = [...document.querySelectorAll(menuSelectors.join(","))].filter(isVisible);
|
||||
let foundHandle = false;
|
||||
let matchedExpectedHandle = false;
|
||||
|
||||
for (const menu of menus) {
|
||||
for (const link of menu.querySelectorAll("a[href]")) {
|
||||
if (!isVisible(link)) continue;
|
||||
try {
|
||||
const url = new URL(link.getAttribute("href"), location.origin);
|
||||
if (url.protocol !== "https:" || !allowedHostnames.includes(url.hostname.toLowerCase())) continue;
|
||||
const path = decodeURIComponent(url.pathname).replace(/\/$/, "").toLowerCase();
|
||||
if (!/^\/@[a-z0-9._-]{3,100}$/.test(path)) continue;
|
||||
foundHandle = true;
|
||||
if (path === `/${normalizedExpectedHandle}`) matchedExpectedHandle = true;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedExpectedHandle) {
|
||||
for (const menu of menus) {
|
||||
for (const header of menu.querySelectorAll(
|
||||
"ytd-active-account-header-renderer, ytm-active-account-header-renderer",
|
||||
)) {
|
||||
if (!isVisible(header)) continue;
|
||||
const identityElements = [header, ...header.querySelectorAll("#channel-handle, yt-formatted-string, span")];
|
||||
const tokens = identityElements
|
||||
.flatMap((element) => (element.textContent ?? "").split(/\s+/))
|
||||
.map((token) => token.trim().replace(/\/$/, "").toLowerCase())
|
||||
.filter((token) => /^@[a-z0-9._-]{3,100}$/.test(token));
|
||||
if (tokens.length > 0) foundHandle = true;
|
||||
if (tokens.includes(normalizedExpectedHandle)) matchedExpectedHandle = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedExpectedHandle || foundHandle) {
|
||||
return { matchedExpectedHandle, ready: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function firstVisibleAvatar(page) {
|
||||
const avatars = page.locator("#avatar-btn");
|
||||
const count = await avatars.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const avatar = avatars.nth(index);
|
||||
if (await avatar.isVisible()) return avatar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readVisibleAccountMenu(page, expectedHandle) {
|
||||
return page.evaluate(inspectVisibleAccountMenu, expectedHandle);
|
||||
}
|
||||
|
||||
async function waitForVisibleAccountMenu(page, expectedHandle, timeoutMilliseconds) {
|
||||
const handle = await page.waitForFunction(inspectVisibleAccountMenu, expectedHandle, {
|
||||
polling: 100,
|
||||
timeout: timeoutMilliseconds,
|
||||
});
|
||||
try {
|
||||
return await handle.jsonValue();
|
||||
} finally {
|
||||
// The result has already been serialized. A concurrent navigation can make
|
||||
// disposal fail, but must not erase a conclusive account-menu result.
|
||||
await handle.dispose?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAuthenticatedYoutubePage(
|
||||
page,
|
||||
expectedHandle,
|
||||
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
|
||||
if (!isCommittedYoutubePage(page)) return false;
|
||||
|
||||
// Attached background tabs can be heavily throttled. Bringing only the page
|
||||
// currently being verified forward makes the account-menu hydration behave
|
||||
// like an ordinary user interaction without navigating or changing profile
|
||||
// state.
|
||||
await page.bringToFront?.();
|
||||
if (!isCommittedYoutubePage(page)) return false;
|
||||
|
||||
const documentState = await page.evaluate(inspectYoutubeSessionDocument);
|
||||
if (documentState?.committed !== true || documentState.configuredLoggedIn === false) return false;
|
||||
|
||||
const existingMenu = await readVisibleAccountMenu(page, normalizedExpectedHandle);
|
||||
if (existingMenu?.ready) return existingMenu.matchedExpectedHandle === true;
|
||||
|
||||
const avatar = await firstVisibleAvatar(page);
|
||||
if (!avatar) return false;
|
||||
|
||||
let openedMenu = false;
|
||||
try {
|
||||
await avatar.click({ timeout: accountProbeTimeoutMilliseconds });
|
||||
openedMenu = true;
|
||||
const menu = await waitForVisibleAccountMenu(page, normalizedExpectedHandle, accountProbeTimeoutMilliseconds);
|
||||
return menu?.matchedExpectedHandle === true;
|
||||
} finally {
|
||||
if (openedMenu) await page.keyboard.press("Escape").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function classifyAccountProbeFailure(error) {
|
||||
if (error?.name === "TimeoutError") return "timeout";
|
||||
if (/target (?:page|context|browser).*closed|page has been closed/i.test(error?.message ?? "")) {
|
||||
return "page-closed";
|
||||
}
|
||||
if (
|
||||
/execution context was destroyed|cannot find context|most likely because of a navigation/i.test(
|
||||
error?.message ?? "",
|
||||
)
|
||||
) {
|
||||
return "navigation-race";
|
||||
}
|
||||
return "probe-error";
|
||||
}
|
||||
|
||||
function wait(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function verifyCandidateYoutubePage(
|
||||
page,
|
||||
expectedHandle,
|
||||
verifyPage,
|
||||
{ accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
try {
|
||||
return {
|
||||
matched: Boolean(
|
||||
await verifyPage(page, expectedHandle, {
|
||||
accountProbeTimeoutMilliseconds,
|
||||
}),
|
||||
),
|
||||
resolved: true,
|
||||
};
|
||||
} catch (error) {
|
||||
// A page which closed or left YouTube while it was being inspected no
|
||||
// longer represents a candidate profile. Do not let that stale tab poison
|
||||
// the result, but keep every still-committed candidate fail-closed.
|
||||
if (!isCommittedYoutubePage(page)) return { matched: false, resolved: true };
|
||||
return {
|
||||
failureKind: classifyAccountProbeFailure(error),
|
||||
matched: false,
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, options) {
|
||||
const contexts = browser.contexts();
|
||||
if (!Array.isArray(contexts) || contexts.length === 0) {
|
||||
throw new Error("The attached Chromium browser has no browser contexts.");
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
const unresolvedContextIndexes = new Set();
|
||||
const unresolvedCandidates = [];
|
||||
let youtubePageCount = 0;
|
||||
|
||||
for (let contextIndex = 0; contextIndex < contexts.length; contextIndex += 1) {
|
||||
const context = contexts[contextIndex];
|
||||
const pages = typeof context?.pages === "function" ? context.pages() : null;
|
||||
if (!Array.isArray(pages)) {
|
||||
throw new Error(`Attached Chromium context ${contextIndex + 1} returned a malformed page inventory.`);
|
||||
}
|
||||
|
||||
const contextMatches = [];
|
||||
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
|
||||
const sessionPage = pages[pageIndex];
|
||||
if (!isCommittedYoutubePage(sessionPage)) continue;
|
||||
youtubePageCount += 1;
|
||||
const verification = await verifyCandidateYoutubePage(sessionPage, normalizedExpectedHandle, options.verifyPage, {
|
||||
accountProbeTimeoutMilliseconds: options.accountProbeTimeoutMilliseconds,
|
||||
});
|
||||
if (verification.matched) {
|
||||
contextMatches.push({ context, contextIndex, pageIndex, sessionPage });
|
||||
// Authentication belongs to the BrowserContext/profile, not to an
|
||||
// individual tab. One exact match resolves this profile; probing more
|
||||
// tabs would only add UI churn and transient failure opportunities.
|
||||
break;
|
||||
}
|
||||
if (!verification.resolved) {
|
||||
unresolvedContextIndexes.add(contextIndex);
|
||||
unresolvedCandidates.push({ contextIndex, failureKind: verification.failureKind, pageIndex });
|
||||
}
|
||||
}
|
||||
|
||||
if (contextMatches.length > 0) {
|
||||
unresolvedContextIndexes.delete(contextIndex);
|
||||
matches.push(contextMatches[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, unresolvedCandidates, unresolvedContextIndexes, youtubePageCount };
|
||||
}
|
||||
|
||||
function isSelectedSessionPageCurrent(selection) {
|
||||
if (!isCommittedYoutubePage(selection.sessionPage)) return false;
|
||||
try {
|
||||
const pages = selection.context.pages();
|
||||
return Array.isArray(pages) && pages.includes(selection.sessionPage);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAuthenticatedYoutubeContext(
|
||||
browser,
|
||||
expectedHandle,
|
||||
{
|
||||
accountSelectionAttempts = DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
|
||||
accountSelectionRetryDelayMilliseconds = DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
|
||||
accountProbeTimeoutMilliseconds = DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
|
||||
verifyPage = verifyAuthenticatedYoutubePage,
|
||||
waitForRetry = wait,
|
||||
} = {},
|
||||
) {
|
||||
const normalizedExpectedHandle = normalizeChannelHandle(expectedHandle);
|
||||
if (!browser || typeof browser.contexts !== "function") {
|
||||
throw new TypeError("An attached Chromium browser is required to select the authenticated profile.");
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(accountSelectionAttempts) || accountSelectionAttempts < 1) {
|
||||
throw new TypeError("The authenticated Chromium selection attempt count must be a positive integer.");
|
||||
}
|
||||
|
||||
let scan;
|
||||
for (let attempt = 1; attempt <= accountSelectionAttempts; attempt += 1) {
|
||||
scan = await scanAuthenticatedYoutubeContexts(browser, normalizedExpectedHandle, {
|
||||
accountProbeTimeoutMilliseconds,
|
||||
verifyPage,
|
||||
});
|
||||
if (scan.matches.length > 1) {
|
||||
throw new Error(
|
||||
`More than one attached Chromium context has a signed-in YouTube page for ${normalizedExpectedHandle}; refusing to choose a profile.`,
|
||||
);
|
||||
}
|
||||
if (scan.matches.length === 1 && scan.unresolvedContextIndexes.size === 0) {
|
||||
const selection = scan.matches[0];
|
||||
if (isSelectedSessionPageCurrent(selection)) return selection;
|
||||
scan.unresolvedContextIndexes.add(selection.contextIndex);
|
||||
scan.unresolvedCandidates.push({
|
||||
contextIndex: selection.contextIndex,
|
||||
failureKind: "selected-page-stale",
|
||||
pageIndex: selection.pageIndex,
|
||||
});
|
||||
}
|
||||
if (attempt < accountSelectionAttempts) await waitForRetry(accountSelectionRetryDelayMilliseconds);
|
||||
}
|
||||
|
||||
if (scan.unresolvedContextIndexes.size > 0) {
|
||||
const diagnostic = scan.unresolvedCandidates
|
||||
.filter(({ contextIndex }) => scan.unresolvedContextIndexes.has(contextIndex))
|
||||
.map(
|
||||
({ contextIndex, failureKind, pageIndex }) =>
|
||||
`context ${contextIndex + 1} page ${pageIndex + 1} (${failureKind})`,
|
||||
)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`Could not safely verify every candidate Chromium profile for ${normalizedExpectedHandle}; no extension was changed. Unresolved probes: ${diagnostic}.`,
|
||||
);
|
||||
}
|
||||
if (scan.matches.length === 0) {
|
||||
if (scan.youtubePageCount === 0) {
|
||||
throw new Error(
|
||||
`No pre-existing committed HTTPS YouTube page was found for ${normalizedExpectedHandle}; no extension was changed.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`No attached Chromium context has a pre-existing signed-in YouTube page for ${normalizedExpectedHandle}; no extension was changed.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`The authenticated Chromium profile for ${normalizedExpectedHandle} became unavailable.`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_ACCOUNT_SELECTION_ATTEMPTS,
|
||||
DEFAULT_ACCOUNT_SELECTION_RETRY_DELAY_MS,
|
||||
DEFAULT_ACCOUNT_PROBE_TIMEOUT_MS,
|
||||
classifyAccountProbeFailure,
|
||||
inspectVisibleAccountMenu,
|
||||
inspectYoutubeSessionDocument,
|
||||
isCommittedYoutubePage,
|
||||
normalizeChannelHandle,
|
||||
scanAuthenticatedYoutubeContexts,
|
||||
selectAuthenticatedYoutubeContext,
|
||||
verifyCandidateYoutubePage,
|
||||
verifyAuthenticatedYoutubePage,
|
||||
};
|
||||
@@ -0,0 +1,755 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const API_ORIGIN = "https://returnyoutubedislikeapi.com";
|
||||
const UNHANDLED_REJECTION_PREFIX = "__RYD_LIVE_UNHANDLED_REJECTION__";
|
||||
const MAX_BROWSER_SIGNALS = 50;
|
||||
const MAX_API_REQUESTS = 50;
|
||||
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
|
||||
const EXTERNAL_RESOURCE_HOSTS = Object.freeze({
|
||||
doubleclick: "doubleclick.net",
|
||||
googlevideo: "googlevideo.com",
|
||||
});
|
||||
const EXTERNAL_RESOURCE_FAILURE_PATTERN =
|
||||
/^Failed to load resource: (?:the server responded with a status of 403(?: \([^)]*\))?|net::ERR_(?:BLOCKED_BY_CLIENT|CONNECTION_CLOSED|CONNECTION_REFUSED|CONNECTION_RESET|FAILED|NAME_NOT_RESOLVED|TIMED_OUT))\.?$/;
|
||||
const DOUBLECLICK_CORS_PATTERN =
|
||||
/^Access to (?:fetch|XMLHttpRequest) at ['"]([^'"]+)['"](?: \(redirected from ['"]([^'"]+)['"]\))? from origin ['"](https:\/\/(?:www\.|m\.)?youtube\.com)['"] has been blocked by CORS policy: .+$/s;
|
||||
const SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE =
|
||||
"Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.";
|
||||
const YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE =
|
||||
"Framing 'https://accounts.youtube.com/' violates the following report-only Content Security Policy directive: \"frame-ancestors 'self'\". The violation has been logged, but no further action has been taken.\n";
|
||||
const YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN =
|
||||
/^Failed to load resource: the server responded with a status of 429(?: \([^)]*\))?\.?$/;
|
||||
const FATAL_SIGNAL_TYPES = new Set([
|
||||
"console.error",
|
||||
"pageerror",
|
||||
"unhandledrejection",
|
||||
"extension-service-worker.console.error",
|
||||
"extension-service-worker.instrumentation-error",
|
||||
"extension-service-worker.unhandledrejection",
|
||||
]);
|
||||
|
||||
function serializeError(error) {
|
||||
if (!error) return null;
|
||||
if (typeof error === "string") return { message: error, name: "Error", stack: null };
|
||||
const serialized = {
|
||||
message: String(error.message ?? error),
|
||||
name: String(error.name ?? "Error"),
|
||||
stack: typeof error.stack === "string" ? error.stack : null,
|
||||
};
|
||||
if (error instanceof AggregateError) serialized.errors = [...error.errors].map(serializeError);
|
||||
return serialized;
|
||||
}
|
||||
|
||||
function normalizeIgnoredSignalRules(rules = []) {
|
||||
if (!Array.isArray(rules)) throw new TypeError("ignoredSignalRules must be an array.");
|
||||
const ids = new Set();
|
||||
return rules.map((rule) => {
|
||||
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
||||
throw new TypeError("Each ignored-signal rule must be an object.");
|
||||
}
|
||||
const allowedKeys = new Set(["id", "message", "type", "url"]);
|
||||
const unknownKeys = Object.keys(rule).filter((key) => !allowedKeys.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new TypeError(`Ignored-signal rule contains unsupported fields: ${unknownKeys.join(", ")}.`);
|
||||
}
|
||||
if (typeof rule.id !== "string" || rule.id.trim() === "") {
|
||||
throw new TypeError("Each ignored-signal rule requires a non-empty id.");
|
||||
}
|
||||
if (ids.has(rule.id)) throw new TypeError(`Ignored-signal rule id ${rule.id} is duplicated.`);
|
||||
ids.add(rule.id);
|
||||
if (!FATAL_SIGNAL_TYPES.has(rule.type)) {
|
||||
throw new TypeError(`Ignored-signal rule ${rule.id} has an unsupported exact type.`);
|
||||
}
|
||||
if (typeof rule.message !== "string" || rule.message.length === 0) {
|
||||
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact message.`);
|
||||
}
|
||||
if (typeof rule.url !== "string" || rule.url.length === 0) {
|
||||
throw new TypeError(`Ignored-signal rule ${rule.id} requires a non-empty exact url.`);
|
||||
}
|
||||
return Object.freeze({ ...rule });
|
||||
});
|
||||
}
|
||||
|
||||
function signalMessage(signal) {
|
||||
return signal.message ?? signal.error?.message ?? "Unknown browser error";
|
||||
}
|
||||
|
||||
function signalUrl(signal) {
|
||||
return signal.workerUrl ?? signal.location?.url ?? null;
|
||||
}
|
||||
|
||||
function ignoredSignalRuleId(signal, rules) {
|
||||
const message = signalMessage(signal);
|
||||
const url = signalUrl(signal);
|
||||
const rule = rules.find((candidate) => {
|
||||
if (candidate.type !== signal.type) return false;
|
||||
return candidate.message === message && candidate.url === url;
|
||||
});
|
||||
return rule?.id ?? null;
|
||||
}
|
||||
|
||||
function hostnameCategory(value) {
|
||||
let hostname;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.port || url.username || url.password) return null;
|
||||
hostname = url.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [category, domain] of Object.entries(EXTERNAL_RESOURCE_HOSTS)) {
|
||||
if (hostname === domain || hostname.endsWith(`.${domain}`)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function environmentalConsoleWarningId(signal) {
|
||||
if (signal?.type !== "console.error" || signal.source !== "page") return null;
|
||||
const message = signalMessage(signal);
|
||||
const locationUrl = signal.location?.url;
|
||||
const locationCategory = hostnameCategory(locationUrl);
|
||||
|
||||
if (message === SANDBOXED_ABOUT_BLANK_SCRIPT_MESSAGE && locationUrl === "about:blank") {
|
||||
return "sandboxed-about-blank-script";
|
||||
}
|
||||
|
||||
if (message === YOUTUBE_ACCOUNTS_REPORT_ONLY_FRAME_ANCESTORS_MESSAGE && locationUrl === "") {
|
||||
return "youtube-accounts-report-only-frame-ancestors";
|
||||
}
|
||||
|
||||
if (EXTERNAL_RESOURCE_FAILURE_PATTERN.test(message)) {
|
||||
if (locationCategory === "googlevideo") return "googlevideo-resource-failure";
|
||||
if (locationCategory === "doubleclick") return "doubleclick-resource-failure";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (YOUTUBE_ROTATE_COOKIES_RATE_LIMIT_PATTERN.test(message)) {
|
||||
try {
|
||||
const url = new URL(locationUrl);
|
||||
if (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname.toLowerCase() === "accounts.youtube.com" &&
|
||||
url.pathname === "/RotateCookies" &&
|
||||
!url.port &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
) {
|
||||
return "youtube-rotate-cookies-rate-limit";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const corsMatch = message.match(DOUBLECLICK_CORS_PATTERN);
|
||||
if (!corsMatch || hostnameCategory(corsMatch[1]) !== "doubleclick") return null;
|
||||
const [, , redirectedFrom, origin] = corsMatch;
|
||||
if (redirectedFrom) {
|
||||
try {
|
||||
const redirectUrl = new URL(redirectedFrom);
|
||||
if (
|
||||
redirectUrl.origin !== origin ||
|
||||
!redirectUrl.pathname.startsWith("/pagead/viewthroughconversion/") ||
|
||||
redirectUrl.username ||
|
||||
redirectUrl.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return "doubleclick-cors";
|
||||
}
|
||||
|
||||
function extensionServiceWorkerUrl(extensionId) {
|
||||
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) {
|
||||
throw new TypeError("selectedExtensionId must be a valid Chrome extension ID.");
|
||||
}
|
||||
return `chrome-extension://${extensionId}/ryd.background.js`;
|
||||
}
|
||||
|
||||
function installUnhandledRejectionListener(prefix) {
|
||||
if (globalThis.__rydLiveUnhandledRejectionListenerInstalled) return;
|
||||
globalThis.__rydLiveUnhandledRejectionListenerInstalled = true;
|
||||
globalThis.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event.reason;
|
||||
let message;
|
||||
try {
|
||||
if (reason instanceof Error)
|
||||
message = `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`;
|
||||
else if (typeof reason === "string") message = reason;
|
||||
else message = JSON.stringify(reason);
|
||||
} catch {
|
||||
message = String(reason);
|
||||
}
|
||||
console.error(`${prefix}${message ?? "Unknown rejection"}`);
|
||||
});
|
||||
}
|
||||
|
||||
class LiveFatalSignalGuard {
|
||||
constructor(
|
||||
page,
|
||||
context,
|
||||
{
|
||||
clock = () => new Date(),
|
||||
ignoredSignalRules = [],
|
||||
log = console.log,
|
||||
onSignal = null,
|
||||
selectedExtensionId = null,
|
||||
} = {},
|
||||
) {
|
||||
this.clock = clock;
|
||||
this.context = context;
|
||||
this.ignoredSignalRules = normalizeIgnoredSignalRules(ignoredSignalRules);
|
||||
this.log = log;
|
||||
this.onSignal = onSignal;
|
||||
this.page = page;
|
||||
this.selectedExtensionWorkerUrl = selectedExtensionId ? extensionServiceWorkerUrl(selectedExtensionId) : null;
|
||||
this.serviceWorkerListeners = new Map();
|
||||
this.signals = [];
|
||||
this.started = false;
|
||||
|
||||
this.onPageConsole = this.onPageConsole.bind(this);
|
||||
this.onPageError = this.onPageError.bind(this);
|
||||
this.onServiceWorker = this.onServiceWorker.bind(this);
|
||||
}
|
||||
|
||||
now() {
|
||||
return this.clock().toISOString();
|
||||
}
|
||||
|
||||
recordSignal(type, details) {
|
||||
const baseSignal = { at: this.now(), type, ...details };
|
||||
const ignoredBy = ignoredSignalRuleId(baseSignal, this.ignoredSignalRules);
|
||||
const environmentalWarning = environmentalConsoleWarningId(baseSignal);
|
||||
const severity = ignoredBy || environmentalWarning ? "warning" : "fatal";
|
||||
const signal = { ...baseSignal, environmentalWarning, ignoredBy, severity };
|
||||
this.signals.push(signal);
|
||||
this.log(
|
||||
`LIVE_BROWSER_SIGNAL ${type} ${JSON.stringify({ ...details, environmentalWarning, ignoredBy, severity })}`,
|
||||
);
|
||||
this.onSignal?.(signal);
|
||||
return signal;
|
||||
}
|
||||
|
||||
onPageError(error) {
|
||||
this.recordSignal("pageerror", { error: serializeError(error), source: "page" });
|
||||
}
|
||||
|
||||
readConsoleMessage(message) {
|
||||
let location = {};
|
||||
try {
|
||||
location = message.location?.() ?? {};
|
||||
} catch {}
|
||||
return { location, message: message.text() };
|
||||
}
|
||||
|
||||
onPageConsole(consoleMessage) {
|
||||
if (consoleMessage.type() !== "error") return;
|
||||
const { location, message } = this.readConsoleMessage(consoleMessage);
|
||||
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
|
||||
this.recordSignal("unhandledrejection", {
|
||||
location,
|
||||
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
|
||||
source: "page",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.recordSignal("console.error", { location, message, source: "page" });
|
||||
}
|
||||
|
||||
async attachServiceWorker(worker) {
|
||||
if (!this.selectedExtensionWorkerUrl || worker.url() !== this.selectedExtensionWorkerUrl) return;
|
||||
if (this.serviceWorkerListeners.has(worker)) return;
|
||||
|
||||
const listener = (consoleMessage) => {
|
||||
if (consoleMessage.type() !== "error") return;
|
||||
const { location, message } = this.readConsoleMessage(consoleMessage);
|
||||
if (message.startsWith(UNHANDLED_REJECTION_PREFIX)) {
|
||||
this.recordSignal("extension-service-worker.unhandledrejection", {
|
||||
location,
|
||||
message: message.slice(UNHANDLED_REJECTION_PREFIX.length),
|
||||
source: "extension-service-worker",
|
||||
workerUrl: this.selectedExtensionWorkerUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.recordSignal("extension-service-worker.console.error", {
|
||||
location,
|
||||
message,
|
||||
source: "extension-service-worker",
|
||||
workerUrl: this.selectedExtensionWorkerUrl,
|
||||
});
|
||||
};
|
||||
this.serviceWorkerListeners.set(worker, listener);
|
||||
worker.on("console", listener);
|
||||
try {
|
||||
await worker.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
|
||||
} catch (error) {
|
||||
this.recordSignal("extension-service-worker.instrumentation-error", {
|
||||
error: serializeError(error),
|
||||
source: "extension-service-worker",
|
||||
workerUrl: this.selectedExtensionWorkerUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onServiceWorker(worker) {
|
||||
void this.attachServiceWorker(worker);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.page.on("console", this.onPageConsole);
|
||||
this.page.on("pageerror", this.onPageError);
|
||||
await this.page.addInitScript(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
|
||||
if (!this.page.isClosed()) await this.page.evaluate(installUnhandledRejectionListener, UNHANDLED_REJECTION_PREFIX);
|
||||
|
||||
if (this.selectedExtensionWorkerUrl) {
|
||||
this.context.on("serviceworker", this.onServiceWorker);
|
||||
await Promise.all(this.context.serviceWorkers().map((worker) => this.attachServiceWorker(worker)));
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
this.page.off("console", this.onPageConsole);
|
||||
this.page.off("pageerror", this.onPageError);
|
||||
if (this.selectedExtensionWorkerUrl) this.context.off("serviceworker", this.onServiceWorker);
|
||||
for (const [worker, listener] of this.serviceWorkerListeners) worker.off("console", listener);
|
||||
this.serviceWorkerListeners.clear();
|
||||
}
|
||||
|
||||
mark() {
|
||||
return this.signals.length;
|
||||
}
|
||||
|
||||
fatalSignalsBetween(startIndex = 0, endIndex = this.signals.length) {
|
||||
return this.signals.slice(startIndex, endIndex).filter((signal) => signal.severity !== "warning");
|
||||
}
|
||||
|
||||
assertNoFatalSignalsBetween(startIndex = 0, endIndex = this.signals.length, label = "live scenario") {
|
||||
const signals = this.fatalSignalsBetween(startIndex, endIndex);
|
||||
if (signals.length === 0) return;
|
||||
const summary = signals.map((signal) => `${signal.type}: ${signalMessage(signal)}`).join(" | ");
|
||||
const error = new Error(`${label} emitted ${signals.length} fatal browser signal(s): ${summary}`);
|
||||
error.name = "LiveFatalSignalError";
|
||||
error.signals = signals;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class LiveReadOnlyGate {
|
||||
constructor(requiredScenarioIds, { allowedSkippedScenarioIds = [] } = {}) {
|
||||
if (!Array.isArray(requiredScenarioIds) || requiredScenarioIds.length === 0) {
|
||||
throw new TypeError("The live read-only gate requires at least one scenario ID.");
|
||||
}
|
||||
if (requiredScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
|
||||
throw new TypeError("Every live read-only gate scenario ID must be a non-empty string.");
|
||||
}
|
||||
if (new Set(requiredScenarioIds).size !== requiredScenarioIds.length) {
|
||||
throw new TypeError("Live read-only gate scenario IDs must be unique.");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(allowedSkippedScenarioIds) ||
|
||||
allowedSkippedScenarioIds.some((scenarioId) => !requiredScenarioIds.includes(scenarioId))
|
||||
) {
|
||||
throw new TypeError("Allowed skipped scenarios must be a subset of the required live read-only scenarios.");
|
||||
}
|
||||
this.allowedSkipped = new Set(allowedSkippedScenarioIds);
|
||||
this.completed = new Set();
|
||||
this.failed = new Set();
|
||||
this.requiredScenarioIds = Object.freeze([...requiredScenarioIds]);
|
||||
}
|
||||
|
||||
record(scenarioId, outcome) {
|
||||
if (!this.requiredScenarioIds.includes(scenarioId)) {
|
||||
throw new TypeError(`Unknown live read-only scenario: ${scenarioId}`);
|
||||
}
|
||||
if (!["failed", "passed", "skipped"].includes(outcome)) {
|
||||
throw new TypeError(`Unsupported live read-only outcome for ${scenarioId}: ${outcome}`);
|
||||
}
|
||||
if (outcome === "failed" || (outcome === "skipped" && !this.allowedSkipped.has(scenarioId))) {
|
||||
this.completed.delete(scenarioId);
|
||||
this.failed.add(scenarioId);
|
||||
return;
|
||||
}
|
||||
if (this.failed.has(scenarioId)) return;
|
||||
this.completed.add(scenarioId);
|
||||
}
|
||||
|
||||
assertPassed() {
|
||||
const missing = this.requiredScenarioIds.filter((scenarioId) => !this.completed.has(scenarioId));
|
||||
if (this.failed.size === 0 && missing.length === 0) return;
|
||||
throw new Error(
|
||||
`Production reactions are blocked because this worker did not pass every read-only scenario. Missing: ${
|
||||
missing.join(", ") || "none"
|
||||
}; failed: ${[...this.failed].join(", ") || "none"}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readLivePageState() {
|
||||
const readRect = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
};
|
||||
};
|
||||
const isVisible = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.top < innerHeight &&
|
||||
rect.left < innerWidth &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const actionBarSelector = "reel-action-bar-view-model, .slim-video-action-bar-actions";
|
||||
const syntheticSelector = "[data-ryd-synthetic-shorts-dislike]";
|
||||
const renderers = [...document.querySelectorAll("ytd-reel-video-renderer, ytm-reel-video-renderer")];
|
||||
|
||||
return {
|
||||
document: {
|
||||
readyState: document.readyState,
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
viewport: { height: innerHeight, width: innerWidth },
|
||||
},
|
||||
runtimeMarkers: {
|
||||
extensionBuild: document.documentElement.getAttribute("data-ryd-extension-build"),
|
||||
extension: document.documentElement.getAttribute("data-ryd-extension-version"),
|
||||
userscriptBuild: document.documentElement.getAttribute("data-ryd-userscript-build"),
|
||||
userscript: document.documentElement.getAttribute("data-ryd-userscript-version"),
|
||||
},
|
||||
renderers: renderers.map((renderer, index) => ({
|
||||
actionBars: renderer.querySelectorAll(actionBarSelector).length,
|
||||
ariaHidden: renderer.getAttribute("aria-hidden"),
|
||||
index,
|
||||
isActive: renderer.getAttribute("is-active"),
|
||||
links: [...renderer.querySelectorAll('a[href*="/shorts/"]')].map((link) => ({
|
||||
href: link.getAttribute("href"),
|
||||
visible: isVisible(link),
|
||||
})),
|
||||
rect: readRect(renderer),
|
||||
syntheticControls: renderer.querySelectorAll(syntheticSelector).length,
|
||||
tagName: renderer.tagName.toLowerCase(),
|
||||
videoId: renderer.getAttribute("video-id"),
|
||||
visible: isVisible(renderer),
|
||||
})),
|
||||
actionBars: [...document.querySelectorAll(actionBarSelector)].map((actionBar, index) => ({
|
||||
index,
|
||||
nativeDislikes: actionBar.querySelectorAll("dislike-button-view-model, #dislike-button").length,
|
||||
nativeLikes: actionBar.querySelectorAll("like-button-view-model, #like-button").length,
|
||||
rect: readRect(actionBar),
|
||||
syntheticControls: actionBar.querySelectorAll(syntheticSelector).length,
|
||||
tagName: actionBar.tagName.toLowerCase(),
|
||||
videoId: actionBar.closest("ytd-reel-video-renderer, ytm-reel-video-renderer")?.getAttribute("video-id") ?? null,
|
||||
visible: isVisible(actionBar),
|
||||
})),
|
||||
syntheticControls: [...document.querySelectorAll(syntheticSelector)].map((control, index) => ({
|
||||
ariaPressed: control.querySelector("button")?.getAttribute("aria-pressed") ?? null,
|
||||
index,
|
||||
rect: readRect(control),
|
||||
text: (control.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
videoId: control.getAttribute("data-ryd-video-id"),
|
||||
visible: isVisible(control),
|
||||
})),
|
||||
videos: [...document.querySelectorAll("video")].map((video, index) => ({
|
||||
currentTime: video.currentTime,
|
||||
ended: video.ended,
|
||||
index,
|
||||
paused: video.paused,
|
||||
readyState: video.readyState,
|
||||
rect: readRect(video),
|
||||
visible: isVisible(video),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticApiUrl(value) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== API_ORIGIN) return null;
|
||||
|
||||
const query = {};
|
||||
for (const [name, requestValue] of url.searchParams) {
|
||||
query[name] = /auth|key|puzzle|secret|solution|token|user/i.test(name) ? "<redacted>" : requestValue;
|
||||
}
|
||||
return { pathname: url.pathname, query };
|
||||
}
|
||||
|
||||
function timestampForFilename(date) {
|
||||
return date.toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
class LiveRunDiagnostics {
|
||||
constructor(
|
||||
page,
|
||||
context,
|
||||
{
|
||||
clock = () => new Date(),
|
||||
fileSystem = fs,
|
||||
ignoredSignalRules = [],
|
||||
log = console.log,
|
||||
outputDirectory = path.resolve(__dirname, "../../../../test-results/live-youtube/diagnostics"),
|
||||
runtime = null,
|
||||
selectedExtensionId = null,
|
||||
} = {},
|
||||
) {
|
||||
this.browserSignals = [];
|
||||
this.clock = clock;
|
||||
this.context = context;
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = "startup";
|
||||
this.fileSystem = fileSystem;
|
||||
this.log = log;
|
||||
this.outputDirectory = outputDirectory;
|
||||
this.page = page;
|
||||
this.recentApiRequests = [];
|
||||
this.requestRecords = new WeakMap();
|
||||
this.runtime = runtime;
|
||||
this.fatalSignalCursor = 0;
|
||||
this.started = false;
|
||||
|
||||
this.onRequest = this.onRequest.bind(this);
|
||||
this.onRequestFailed = this.onRequestFailed.bind(this);
|
||||
this.onResponse = this.onResponse.bind(this);
|
||||
this.fatalSignalGuard = new LiveFatalSignalGuard(page, context, {
|
||||
clock,
|
||||
ignoredSignalRules,
|
||||
log: () => {},
|
||||
onSignal: (signal) => this.recordBrowserSignal(signal),
|
||||
selectedExtensionId,
|
||||
});
|
||||
}
|
||||
|
||||
now() {
|
||||
return this.clock().toISOString();
|
||||
}
|
||||
|
||||
appendCapped(collection, value, maximum) {
|
||||
collection.push(value);
|
||||
if (collection.length > maximum) collection.splice(0, collection.length - maximum);
|
||||
}
|
||||
|
||||
recordBrowserSignal(signal) {
|
||||
const { at, ...details } = signal;
|
||||
const enrichedSignal = {
|
||||
at,
|
||||
checkpoint: this.currentCheckpoint,
|
||||
stage: this.currentStage,
|
||||
...details,
|
||||
};
|
||||
this.appendCapped(this.browserSignals, enrichedSignal, MAX_BROWSER_SIGNALS);
|
||||
this.log(`LIVE_BROWSER_SIGNAL ${enrichedSignal.type} ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
onRequest(request) {
|
||||
const apiUrl = diagnosticApiUrl(request.url());
|
||||
if (!apiUrl) return;
|
||||
const record = {
|
||||
...apiUrl,
|
||||
at: this.now(),
|
||||
failure: null,
|
||||
method: request.method(),
|
||||
resourceType: typeof request.resourceType === "function" ? request.resourceType() : null,
|
||||
checkpoint: this.currentCheckpoint,
|
||||
stage: this.currentStage,
|
||||
status: null,
|
||||
};
|
||||
this.appendCapped(this.recentApiRequests, record, MAX_API_REQUESTS);
|
||||
this.requestRecords.set(request, record);
|
||||
}
|
||||
|
||||
onResponse(response) {
|
||||
const record = this.requestRecords.get(response.request());
|
||||
if (!record) return;
|
||||
record.status = response.status();
|
||||
}
|
||||
|
||||
onRequestFailed(request) {
|
||||
const record = this.requestRecords.get(request);
|
||||
if (!record) return;
|
||||
record.failure = request.failure()?.errorText ?? "Unknown request failure";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
await this.fatalSignalGuard.start();
|
||||
this.context.on("request", this.onRequest);
|
||||
this.context.on("requestfailed", this.onRequestFailed);
|
||||
this.context.on("response", this.onResponse);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
this.fatalSignalGuard.stop();
|
||||
this.context.off("request", this.onRequest);
|
||||
this.context.off("requestfailed", this.onRequestFailed);
|
||||
this.context.off("response", this.onResponse);
|
||||
}
|
||||
|
||||
checkpoint(name, details = {}) {
|
||||
this.currentCheckpoint = name;
|
||||
this.log(`LIVE_CHECKPOINT ${name} ${JSON.stringify(details)}`);
|
||||
}
|
||||
|
||||
stageStarted(name) {
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = name;
|
||||
this.log(`LIVE_STAGE_START ${name}`);
|
||||
}
|
||||
|
||||
stageCompleted(name, startedAt) {
|
||||
this.currentCheckpoint = null;
|
||||
this.currentStage = name;
|
||||
this.log(`LIVE_STAGE_COMPLETE ${name} durationMs=${Date.now() - startedAt}`);
|
||||
}
|
||||
|
||||
stageFailed(name, startedAt, error) {
|
||||
this.currentStage = name;
|
||||
this.log(
|
||||
`LIVE_STAGE_FAILED ${name} durationMs=${Date.now() - startedAt} error=${JSON.stringify(error?.message ?? String(error))}`,
|
||||
);
|
||||
}
|
||||
|
||||
consumeFatalSignals(label) {
|
||||
const startIndex = this.fatalSignalCursor;
|
||||
const endIndex = this.fatalSignalGuard.mark();
|
||||
this.fatalSignalCursor = endIndex;
|
||||
this.fatalSignalGuard.assertNoFatalSignalsBetween(startIndex, endIndex, label);
|
||||
}
|
||||
|
||||
async snapshot(error) {
|
||||
let pageState = null;
|
||||
let pageStateError = null;
|
||||
let url = null;
|
||||
try {
|
||||
url = this.page.url();
|
||||
} catch {
|
||||
// A disconnected page still produces a useful harness/request snapshot.
|
||||
}
|
||||
if (!this.page.isClosed()) {
|
||||
try {
|
||||
pageState = await this.page.evaluate(readLivePageState);
|
||||
} catch (snapshotError) {
|
||||
pageStateError = serializeError(snapshotError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
browserSignals: this.browserSignals,
|
||||
capturedAt: this.now(),
|
||||
currentCheckpoint: this.currentCheckpoint,
|
||||
currentStage: this.currentStage,
|
||||
error: serializeError(error),
|
||||
pageClosed: this.page.isClosed(),
|
||||
pageState,
|
||||
pageStateError,
|
||||
recentApiRequests: this.recentApiRequests,
|
||||
runtime: this.runtime,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async persistFailureSnapshot(error) {
|
||||
const snapshot = await this.snapshot(error);
|
||||
this.fileSystem.mkdirSync(this.outputDirectory, { recursive: true });
|
||||
const fileName = `failure-${timestampForFilename(this.clock())}.json`;
|
||||
const snapshotPath = path.join(this.outputDirectory, fileName);
|
||||
this.fileSystem.writeFileSync(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8");
|
||||
return snapshotPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoggedStage(diagnostics, name, action) {
|
||||
const startedAt = Date.now();
|
||||
diagnostics.stageStarted(name);
|
||||
let result;
|
||||
let failure = null;
|
||||
try {
|
||||
result = await action();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
try {
|
||||
diagnostics.consumeFatalSignals?.(name);
|
||||
} catch (signalError) {
|
||||
failure = failure
|
||||
? new AggregateError([failure, signalError], `${name} failed and emitted fatal browser signals.`)
|
||||
: signalError;
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
diagnostics.stageFailed(name, startedAt, failure);
|
||||
throw failure;
|
||||
}
|
||||
diagnostics.stageCompleted(name, startedAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runIndependentLoggedStages(diagnostics, stages) {
|
||||
if (!Array.isArray(stages)) throw new TypeError("Independent live stages must be an array.");
|
||||
const results = [];
|
||||
const failures = [];
|
||||
for (const stage of stages) {
|
||||
if (!stage || typeof stage.name !== "string" || typeof stage.action !== "function") {
|
||||
throw new TypeError("Each independent live stage requires a name and action function.");
|
||||
}
|
||||
try {
|
||||
results.push({ name: stage.name, result: await runLoggedStage(diagnostics, stage.name, stage.action) });
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
results.push({ error: serializeError(error), name: stage.name });
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
const error = new AggregateError(
|
||||
failures,
|
||||
`${failures.length} of ${stages.length} independent live read-only stages failed.`,
|
||||
);
|
||||
error.stageResults = results;
|
||||
throw error;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_ORIGIN,
|
||||
LiveFatalSignalGuard,
|
||||
LiveReadOnlyGate,
|
||||
LiveRunDiagnostics,
|
||||
UNHANDLED_REJECTION_PREFIX,
|
||||
diagnosticApiUrl,
|
||||
environmentalConsoleWarningId,
|
||||
extensionServiceWorkerUrl,
|
||||
ignoredSignalRuleId,
|
||||
installUnhandledRejectionListener,
|
||||
normalizeIgnoredSignalRules,
|
||||
readLivePageState,
|
||||
runIndependentLoggedStages,
|
||||
runLoggedStage,
|
||||
serializeError,
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
const path = require("node:path");
|
||||
|
||||
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
|
||||
const LIVE_BUILD_ID_PATTERN = /^[a-f0-9]{32}$/;
|
||||
const BACKGROUND_SCRIPT = "ryd.background.js";
|
||||
const DEFAULT_RELOAD_TIMEOUT_MS = 15_000;
|
||||
const EXTENSION_INVENTORY_POLL_INTERVAL_MS = 50;
|
||||
const LIVE_EXTENSION_PATH = path.resolve(__dirname, "../../../combined/dist/chrome");
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
function normalizeExtensionPath(value, { pathImpl = path, platform = process.platform } = {}) {
|
||||
if (typeof value !== "string" || value.trim() === "") return null;
|
||||
let normalized = pathImpl.resolve(value.trim()).replace(/[\\/]+$/u, "");
|
||||
if (platform === "win32") normalized = normalized.toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function selectEnabledUnpackedExtension(extensions, expectedPath, { requireEnabled = true, ...pathOptions } = {}) {
|
||||
if (!Array.isArray(extensions)) throw new Error("Chrome returned a malformed unpacked-extension inventory.");
|
||||
const normalizedExpectedPath = normalizeExtensionPath(expectedPath, pathOptions);
|
||||
if (!normalizedExpectedPath) throw new Error("An absolute unpacked-extension path is required.");
|
||||
|
||||
const matching = extensions.filter(
|
||||
(extension) => normalizeExtensionPath(extension?.path, pathOptions) === normalizedExpectedPath,
|
||||
);
|
||||
if (matching.length === 0) {
|
||||
throw new Error(`The unpacked extension at ${expectedPath} is not installed in the attached Chrome profile.`);
|
||||
}
|
||||
if (matching.length > 1) {
|
||||
throw new Error(`Chrome reported more than one unpacked extension at ${expectedPath}; refusing to choose one.`);
|
||||
}
|
||||
|
||||
const [extension] = matching;
|
||||
if (!EXTENSION_ID_PATTERN.test(extension.id ?? "")) {
|
||||
throw new Error(`Chrome reported an invalid extension ID for ${expectedPath}.`);
|
||||
}
|
||||
if (requireEnabled && extension.enabled !== true) {
|
||||
throw new Error(`The unpacked extension at ${expectedPath} is installed but disabled.`);
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
function isOrdinaryYoutubePage(page) {
|
||||
if (!page || typeof page.url !== "function" || page.isClosed?.()) return false;
|
||||
try {
|
||||
const url = new URL(page.url());
|
||||
return url.protocol === "https:" && ["youtube.com", "www.youtube.com", "m.youtube.com"].includes(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSelectedSessionPage(sessionPage, context) {
|
||||
if (!isOrdinaryYoutubePage(sessionPage)) {
|
||||
throw new Error("The selected extension-reload session page must be an open ordinary HTTPS YouTube page.");
|
||||
}
|
||||
if (typeof sessionPage.context !== "function" || sessionPage.context() !== context) {
|
||||
throw new Error("The selected extension-reload session page does not belong to the attached Chrome context.");
|
||||
}
|
||||
return sessionPage;
|
||||
}
|
||||
|
||||
function extensionServiceWorkerUrl(extensionId) {
|
||||
if (!EXTENSION_ID_PATTERN.test(extensionId ?? "")) throw new Error("A valid Chrome extension ID is required.");
|
||||
return `chrome-extension://${extensionId}/${BACKGROUND_SCRIPT}`;
|
||||
}
|
||||
|
||||
function matchingExtensionWorkers(context, extensionId, excludedWorker = null) {
|
||||
const expectedUrl = extensionServiceWorkerUrl(extensionId);
|
||||
return context.serviceWorkers().filter((worker) => worker !== excludedWorker && worker.url() === expectedUrl);
|
||||
}
|
||||
|
||||
async function waitForExtensionWorker(
|
||||
context,
|
||||
serviceWorkerSession,
|
||||
extensionId,
|
||||
{ delayImpl = delay, excludedWorker = null, now = Date.now, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
let workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
|
||||
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
|
||||
if (workers.length === 1) return workers[0];
|
||||
|
||||
await serviceWorkerSession.send("ServiceWorker.enable");
|
||||
await serviceWorkerSession.send("ServiceWorker.startWorker", {
|
||||
scopeURL: `chrome-extension://${extensionId}/`,
|
||||
});
|
||||
|
||||
const deadline = now() + timeoutMilliseconds;
|
||||
while (now() < deadline) {
|
||||
workers = matchingExtensionWorkers(context, extensionId, excludedWorker);
|
||||
if (workers.length > 1) throw new Error(`Chrome exposed duplicate service workers for extension ${extensionId}.`);
|
||||
if (workers.length === 1) return workers[0];
|
||||
await delayImpl(Math.min(50, Math.max(1, deadline - now())));
|
||||
}
|
||||
throw new Error(`Chrome did not start ${extensionServiceWorkerUrl(extensionId)} within ${timeoutMilliseconds}ms.`);
|
||||
}
|
||||
|
||||
async function readWorkerIdentity(worker) {
|
||||
return worker.evaluate(async () => {
|
||||
const runtime = globalThis.chrome?.runtime;
|
||||
const manifest = runtime?.getManifest?.();
|
||||
let resourceBuildId = null;
|
||||
try {
|
||||
const response = await fetch(runtime.getURL("live-build.json"), { cache: "no-store" });
|
||||
const marker = response.ok ? await response.json() : null;
|
||||
resourceBuildId = marker?.buildId ?? null;
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
backgroundScript: manifest?.background?.service_worker ?? null,
|
||||
compiledBuildId: globalThis.__RYD_LIVE_EXTENSION_BUILD__?.buildId ?? null,
|
||||
extensionId: runtime?.id ?? null,
|
||||
resourceBuildId,
|
||||
version: manifest?.version ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function assertWorkerIdentity(identity, extensionId, { expectedBuildId = null, expectedVersion = null } = {}) {
|
||||
if (!identity || typeof identity !== "object") throw new Error("The extension service worker returned no identity.");
|
||||
if (identity.extensionId !== extensionId) {
|
||||
throw new Error(
|
||||
`The service worker belongs to ${identity.extensionId ?? "an unknown extension"}, not ${extensionId}.`,
|
||||
);
|
||||
}
|
||||
if (identity.backgroundScript !== BACKGROUND_SCRIPT) {
|
||||
throw new Error(`Extension ${extensionId} does not use the expected ${BACKGROUND_SCRIPT} service worker.`);
|
||||
}
|
||||
if (expectedVersion !== null && identity.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`Reloaded extension ${extensionId} has version ${identity.version ?? "<missing>"}, expected ${expectedVersion}.`,
|
||||
);
|
||||
}
|
||||
if (expectedBuildId !== null) {
|
||||
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId)) throw new Error("The expected live-build ID is malformed.");
|
||||
if (identity.compiledBuildId !== expectedBuildId) {
|
||||
throw new Error(
|
||||
`Reloaded extension ${extensionId} is running build ${identity.compiledBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
|
||||
);
|
||||
}
|
||||
if (identity.resourceBuildId !== expectedBuildId) {
|
||||
throw new Error(
|
||||
`Extension ${extensionId} exposes resource build ${identity.resourceBuildId ?? "<missing>"}, expected ${expectedBuildId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function requestWorkerReload(worker, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS) {
|
||||
const closed = worker.waitForEvent("close", { timeout: timeoutMilliseconds });
|
||||
await worker.evaluate(() => {
|
||||
const runtime = chrome.runtime;
|
||||
setTimeout(() => runtime.reload(), 0);
|
||||
return true;
|
||||
});
|
||||
await closed;
|
||||
}
|
||||
|
||||
async function readUnpackedExtensions(browserSession) {
|
||||
try {
|
||||
const result = await browserSession.send("Extensions.getExtensions");
|
||||
return result?.extensions;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
"Chrome did not expose Extensions.getExtensions on this CDP connection, so the unpacked extension cannot be identified safely. Reload it manually; no extension was changed.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReloadedExtensionEnabled(
|
||||
browserSession,
|
||||
expectedExtensionPath,
|
||||
expectedExtensionId,
|
||||
{ delayImpl = delay, now = Date.now, pathOptions, timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS } = {},
|
||||
) {
|
||||
const deadline = now() + timeoutMilliseconds;
|
||||
|
||||
while (true) {
|
||||
const inventory = await readUnpackedExtensions(browserSession);
|
||||
const extension = selectEnabledUnpackedExtension(inventory, expectedExtensionPath, {
|
||||
...pathOptions,
|
||||
requireEnabled: false,
|
||||
});
|
||||
if (extension.id !== expectedExtensionId) {
|
||||
throw new Error(
|
||||
`The unpacked extension ID changed from ${expectedExtensionId} to ${extension.id} during reload.`,
|
||||
);
|
||||
}
|
||||
if (extension.enabled === true) return extension;
|
||||
|
||||
const remainingMilliseconds = deadline - now();
|
||||
if (remainingMilliseconds <= 0) {
|
||||
throw new Error(
|
||||
`The unpacked extension at ${expectedExtensionPath} remained disabled for ${timeoutMilliseconds}ms after reload.`,
|
||||
);
|
||||
}
|
||||
await delayImpl(Math.min(EXTENSION_INVENTORY_POLL_INTERVAL_MS, remainingMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLiveExtension({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId,
|
||||
expectedExtensionPath,
|
||||
expectedVersion,
|
||||
pathOptions,
|
||||
serviceWorkerSession,
|
||||
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
|
||||
waitOptions = {},
|
||||
}) {
|
||||
if (!LIVE_BUILD_ID_PATTERN.test(expectedBuildId ?? "")) throw new Error("The expected live-build ID is malformed.");
|
||||
|
||||
const beforeInventory = await readUnpackedExtensions(browserSession);
|
||||
const before = selectEnabledUnpackedExtension(beforeInventory, expectedExtensionPath, pathOptions);
|
||||
const oldWorker = await waitForExtensionWorker(context, serviceWorkerSession, before.id, {
|
||||
...waitOptions,
|
||||
timeoutMilliseconds,
|
||||
});
|
||||
assertWorkerIdentity(await readWorkerIdentity(oldWorker), before.id);
|
||||
|
||||
await requestWorkerReload(oldWorker, timeoutMilliseconds);
|
||||
|
||||
const after = await waitForReloadedExtensionEnabled(browserSession, expectedExtensionPath, before.id, {
|
||||
...waitOptions,
|
||||
pathOptions,
|
||||
timeoutMilliseconds,
|
||||
});
|
||||
|
||||
const newWorker = await waitForExtensionWorker(context, serviceWorkerSession, after.id, {
|
||||
...waitOptions,
|
||||
excludedWorker: oldWorker,
|
||||
timeoutMilliseconds,
|
||||
});
|
||||
const identity = assertWorkerIdentity(await readWorkerIdentity(newWorker), after.id, {
|
||||
expectedBuildId,
|
||||
expectedVersion,
|
||||
});
|
||||
|
||||
return {
|
||||
buildId: identity.compiledBuildId,
|
||||
extensionId: after.id,
|
||||
previousVersion: before.version ?? null,
|
||||
version: identity.version,
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId,
|
||||
expectedExtensionPath = LIVE_EXTENSION_PATH,
|
||||
expectedVersion,
|
||||
reloadImpl = reloadLiveExtension,
|
||||
sessionPage,
|
||||
timeoutMilliseconds = DEFAULT_RELOAD_TIMEOUT_MS,
|
||||
}) {
|
||||
assertSelectedSessionPage(sessionPage, context);
|
||||
const browserSession = await browser.newBrowserCDPSession();
|
||||
let serviceWorkerSession = null;
|
||||
try {
|
||||
serviceWorkerSession = await context.newCDPSession(sessionPage);
|
||||
return await reloadImpl({
|
||||
browserSession,
|
||||
context,
|
||||
expectedBuildId,
|
||||
expectedExtensionPath,
|
||||
expectedVersion,
|
||||
serviceWorkerSession,
|
||||
timeoutMilliseconds,
|
||||
});
|
||||
} finally {
|
||||
await serviceWorkerSession?.detach().catch(() => {});
|
||||
await browserSession.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKGROUND_SCRIPT,
|
||||
DEFAULT_RELOAD_TIMEOUT_MS,
|
||||
LIVE_EXTENSION_PATH,
|
||||
assertSelectedSessionPage,
|
||||
assertWorkerIdentity,
|
||||
extensionServiceWorkerUrl,
|
||||
normalizeExtensionPath,
|
||||
readUnpackedExtensions,
|
||||
readWorkerIdentity,
|
||||
reloadLiveExtension,
|
||||
reloadLiveExtensionInBrowser,
|
||||
requestWorkerReload,
|
||||
selectEnabledUnpackedExtension,
|
||||
waitForReloadedExtensionEnabled,
|
||||
waitForExtensionWorker,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const REQUIRED_PRODUCTION_REACTION_SCENARIOS = Object.freeze(["post-navigation-vote", "reaction-matrix"]);
|
||||
const SUPPORTED_LIVE_RUNTIMES = new Set(["extension", "userscript"]);
|
||||
const REPOSITORY_ROOT = path.resolve(__dirname, "../../../..");
|
||||
|
||||
class LiveValidationIncompleteError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.code = "LIVE_VALIDATION_INCOMPLETE";
|
||||
this.name = "LiveValidationIncompleteError";
|
||||
}
|
||||
}
|
||||
|
||||
function readFullLiveRuntime(environment = process.env) {
|
||||
const runtime = environment.RYD_LIVE_RUNTIME?.trim();
|
||||
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) {
|
||||
throw new Error('RYD_LIVE_RUNTIME must be either "userscript" or "extension" before building a full live run.');
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function buildLiveRuntimeArtifact(
|
||||
runtime,
|
||||
{
|
||||
environment = process.env,
|
||||
nodeExecutable = process.execPath,
|
||||
platform = process.platform,
|
||||
repositoryRoot = REPOSITORY_ROOT,
|
||||
spawnSyncImpl = spawnSync,
|
||||
} = {},
|
||||
) {
|
||||
if (!SUPPORTED_LIVE_RUNTIMES.has(runtime)) throw new TypeError(`Unsupported live build runtime: ${runtime}`);
|
||||
const script = `build:live:${runtime}`;
|
||||
const npmExecutable = environment.npm_execpath?.trim();
|
||||
const command = npmExecutable ? nodeExecutable : platform === "win32" ? "npm.cmd" : "npm";
|
||||
const args = npmExecutable ? [npmExecutable, "run", script] : ["run", script];
|
||||
const result = spawnSyncImpl(command, args, {
|
||||
cwd: repositoryRoot,
|
||||
env: environment,
|
||||
...(platform === "win32" && !npmExecutable ? { shell: true } : {}),
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw new Error(`Could not execute npm run ${script}.`, { cause: result.error });
|
||||
if (result.status !== 0) throw new Error(`npm run ${script} failed with exit code ${result.status}.`);
|
||||
return { runtime, script };
|
||||
}
|
||||
|
||||
function requireUserscriptInstallAcknowledgement(value) {
|
||||
if (value?.trim() !== "INSTALLED") {
|
||||
throw new LiveValidationIncompleteError(
|
||||
"Full userscript validation is incomplete because the freshly built userscript was not acknowledged as installed.",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireProductionReactionApproval(value) {
|
||||
if (value?.trim().toUpperCase() === "SKIP") {
|
||||
throw new LiveValidationIncompleteError(
|
||||
"Full live validation is incomplete because production reaction scenarios were skipped.",
|
||||
);
|
||||
}
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function assertFullLiveReactionCompletion(results) {
|
||||
if (!results || typeof results !== "object") {
|
||||
throw new LiveValidationIncompleteError("Full live validation has no production reaction results.");
|
||||
}
|
||||
const missing = REQUIRED_PRODUCTION_REACTION_SCENARIOS.filter((scenarioId) => results[scenarioId] == null);
|
||||
if (missing.length > 0) {
|
||||
throw new LiveValidationIncompleteError(
|
||||
`Full live validation did not complete production reaction scenarios: ${missing.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
classification: "full",
|
||||
completedScenarioIds: REQUIRED_PRODUCTION_REACTION_SCENARIOS,
|
||||
productionReactionsCompleted: true,
|
||||
releaseReady: true,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LiveValidationIncompleteError,
|
||||
REQUIRED_PRODUCTION_REACTION_SCENARIOS,
|
||||
assertFullLiveReactionCompletion,
|
||||
buildLiveRuntimeArtifact,
|
||||
readFullLiveRuntime,
|
||||
requireProductionReactionApproval,
|
||||
requireUserscriptInstallAcknowledgement,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
const { runIndependentLoggedStages } = require("./live-diagnostics");
|
||||
const {
|
||||
assertInteractiveReadOnlyScenarioCompletions,
|
||||
assertInteractiveReadOnlyScenarioDefinitions,
|
||||
} = require("./live-interactive-scenario-contract");
|
||||
|
||||
const INTERACTIVE_READ_ONLY_SCENARIOS = Object.freeze([
|
||||
Object.freeze({ key: "short", name: "read-only.short-render", scenarioId: "shorts-render" }),
|
||||
Object.freeze({
|
||||
key: "channelShorts",
|
||||
name: "read-only.channel-to-shorts-and-ten-plus-valid-next",
|
||||
scenarioId: "channel-shorts-navigation",
|
||||
}),
|
||||
Object.freeze({ key: "channelWatch", name: "read-only.channel-to-watch", scenarioId: "channel-watch-navigation" }),
|
||||
Object.freeze({ key: "reload", name: "read-only.reload", scenarioId: "reload" }),
|
||||
Object.freeze({ key: "responsive", name: "read-only.responsive-visual", scenarioId: "responsive-visual" }),
|
||||
Object.freeze({ key: "spa", name: "read-only.playlist-spa", scenarioId: "spa-navigation" }),
|
||||
Object.freeze({
|
||||
key: "watchActionTopology",
|
||||
name: "read-only.watch-action-topology",
|
||||
scenarioId: "watch-action-topology",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "sidebarStress",
|
||||
name: "read-only.sidebar-stress",
|
||||
scenarioId: "sidebar-navigation-stress",
|
||||
}),
|
||||
Object.freeze({ key: "watch", name: "read-only.watch-render", scenarioId: "watch-render" }),
|
||||
]);
|
||||
|
||||
assertInteractiveReadOnlyScenarioDefinitions(INTERACTIVE_READ_ONLY_SCENARIOS);
|
||||
|
||||
function createInteractiveReadOnlyStages({ diagnostics, options, readOnly, runtimeAdapter, scenarioRunner }) {
|
||||
if (!options || typeof options !== "object") throw new TypeError("Live options are required.");
|
||||
if (!options.navigation?.watch) {
|
||||
throw new TypeError("The full interactive read-only gate requires a configured channel Watch target.");
|
||||
}
|
||||
if (!readOnly || typeof readOnly !== "object") throw new TypeError("A read-only result object is required.");
|
||||
if (!runtimeAdapter) throw new TypeError("A live runtime adapter is required.");
|
||||
if (!scenarioRunner || typeof scenarioRunner.run !== "function") {
|
||||
throw new TypeError("A shared live scenario runner is required.");
|
||||
}
|
||||
|
||||
return INTERACTIVE_READ_ONLY_SCENARIOS.map(({ key, name, scenarioId }) => ({
|
||||
action: async () => {
|
||||
readOnly[key] = await scenarioRunner.run(runtimeAdapter, scenarioId, options);
|
||||
return readOnly[key];
|
||||
},
|
||||
name,
|
||||
scenarioId,
|
||||
}));
|
||||
}
|
||||
|
||||
async function runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime,
|
||||
options,
|
||||
reloadExtension,
|
||||
runStages = runIndependentLoggedStages,
|
||||
scenarioRunner,
|
||||
}) {
|
||||
if (typeof initializeRuntime !== "function") throw new TypeError("A live runtime initializer is required.");
|
||||
if (typeof runStages !== "function") throw new TypeError("A live stage executor is required.");
|
||||
|
||||
let reloadResult = null;
|
||||
if (options?.runtime === "extension") {
|
||||
if (typeof reloadExtension !== "function") throw new TypeError("An extension reload function is required.");
|
||||
reloadResult = await reloadExtension();
|
||||
}
|
||||
|
||||
const initialized = await initializeRuntime(reloadResult);
|
||||
if (!initialized?.diagnostics || !initialized?.runtimeAdapter) {
|
||||
throw new TypeError("The live runtime initializer must return diagnostics and a runtime adapter.");
|
||||
}
|
||||
|
||||
const readOnly = {};
|
||||
const stages = createInteractiveReadOnlyStages({
|
||||
diagnostics: initialized.diagnostics,
|
||||
options,
|
||||
readOnly,
|
||||
runtimeAdapter: initialized.runtimeAdapter,
|
||||
scenarioRunner,
|
||||
});
|
||||
const completedScenarioIds = [];
|
||||
const completionTrackedStages = stages.map((stage) => ({
|
||||
...stage,
|
||||
action: async () => {
|
||||
const result = await stage.action();
|
||||
if (result === undefined) {
|
||||
throw new Error(`Interactive read-only scenario ${stage.scenarioId} completed without a result.`);
|
||||
}
|
||||
completedScenarioIds.push(stage.scenarioId);
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
await runStages(initialized.diagnostics, completionTrackedStages);
|
||||
assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds);
|
||||
return { readOnly, reloadResult };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
INTERACTIVE_READ_ONLY_SCENARIOS,
|
||||
createInteractiveReadOnlyStages,
|
||||
runInteractiveReadOnlyAfterRuntimeReload,
|
||||
};
|
||||
@@ -0,0 +1,547 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { chromium } = require("@playwright/test");
|
||||
const {
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
} = require("../../../e2e/live-runtime-adapter");
|
||||
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
|
||||
const { consumeLiveVoteApproval, hasFreshVoteApproval, readLiveOptions } = require("../../live/live-options");
|
||||
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
|
||||
const { LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
|
||||
const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
|
||||
const {
|
||||
assertFullLiveReactionCompletion,
|
||||
buildLiveRuntimeArtifact,
|
||||
readFullLiveRuntime,
|
||||
requireProductionReactionApproval,
|
||||
requireUserscriptInstallAcknowledgement,
|
||||
} = require("./live-full-run-contract");
|
||||
const { runInteractiveReadOnlyAfterRuntimeReload } = require("./live-interactive-readonly");
|
||||
const { LiveYoutubeDriver, VoteTrafficRecorder, withOperationTimeout } = require("./live-youtube-driver");
|
||||
|
||||
const REPOSITORY_DIRECTORY = path.resolve(__dirname, "../../../..");
|
||||
const LIVE_EVIDENCE_DIRECTORY = path.resolve(__dirname, "../../../../test-results/live-youtube");
|
||||
const LIVE_EVIDENCE_PARENT_DIRECTORY = path.dirname(LIVE_EVIDENCE_DIRECTORY);
|
||||
const LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS = 10_000;
|
||||
const LIVE_SESSION_COMMANDS = new Set(["EXIT", "RERUN"]);
|
||||
const scenarioRunner = createSharedLiveScenarioRunner();
|
||||
|
||||
function createInputLineReader(input = process.stdin) {
|
||||
const lineInterface = readline.createInterface({ input, terminal: false });
|
||||
const bufferedLines = [];
|
||||
const waiters = [];
|
||||
let closed = false;
|
||||
|
||||
lineInterface.on("line", (line) => {
|
||||
const value = line.trim();
|
||||
const waiter = waiters.shift();
|
||||
if (waiter) waiter(value);
|
||||
else bufferedLines.push(value);
|
||||
});
|
||||
lineInterface.once("close", () => {
|
||||
closed = true;
|
||||
for (const waiter of waiters.splice(0)) waiter(null);
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => lineInterface.close(),
|
||||
readLine: () => {
|
||||
if (bufferedLines.length > 0) return Promise.resolve(bufferedLines.shift());
|
||||
if (closed) return Promise.resolve(null);
|
||||
return new Promise((resolve) => waiters.push(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readPersistentSessionMode(environment = process.env) {
|
||||
const configured = environment.RYD_LIVE_KEEP_CDP_SESSION?.trim();
|
||||
if (!configured) return false;
|
||||
if (configured !== "1") {
|
||||
throw new Error("RYD_LIVE_KEEP_CDP_SESSION must be 1 or unset.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeLiveSessionCommand(value) {
|
||||
if (value === null) return "EXIT";
|
||||
const command = value?.trim().toUpperCase();
|
||||
return LIVE_SESSION_COMMANDS.has(command) ? command : null;
|
||||
}
|
||||
|
||||
function assertReusableConnectionOptions(initialOptions, nextOptions) {
|
||||
const immutableFields = ["cdpEndpoint", "expectedChannel", "runtime"];
|
||||
for (const field of immutableFields) {
|
||||
if (nextOptions?.[field] !== initialOptions?.[field]) {
|
||||
throw new Error(
|
||||
`Cannot reuse the live browser connection after ${field} changed from ${initialOptions?.[field] ?? "<missing>"} to ${nextOptions?.[field] ?? "<missing>"}. Exit and start a new live runner instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return nextOptions;
|
||||
}
|
||||
|
||||
function copyDirectoryEntry(source, destination, fsImpl = fs) {
|
||||
const stats = fsImpl.statSync(source);
|
||||
if (stats.isDirectory()) {
|
||||
fsImpl.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fsImpl.readdirSync(source)) {
|
||||
copyDirectoryEntry(path.join(source, entry), path.join(destination, entry), fsImpl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fsImpl.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fsImpl.copyFileSync(source, destination);
|
||||
}
|
||||
|
||||
function canonicalizeExistingPath(targetPath, fsImpl = fs) {
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
if (!fsImpl.existsSync(resolvedPath)) return resolvedPath;
|
||||
const realpath = fsImpl.realpathSync?.native ?? fsImpl.realpathSync;
|
||||
return realpath ? realpath(resolvedPath) : resolvedPath;
|
||||
}
|
||||
|
||||
function pathsAreEqual(left, right) {
|
||||
const normalize = (value) => {
|
||||
const normalized = path.normalize(value);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
};
|
||||
return normalize(left) === normalize(right);
|
||||
}
|
||||
|
||||
function isAttemptsEvidenceEntry(entry) {
|
||||
return entry.toLowerCase() === "attempts";
|
||||
}
|
||||
|
||||
function resolveSafeLiveEvidenceDirectory(
|
||||
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
|
||||
fsImpl = fs,
|
||||
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
|
||||
) {
|
||||
const resolvedEvidenceDirectory = canonicalizeExistingPath(evidenceDirectory, fsImpl);
|
||||
const resolvedRepositoryDirectory = canonicalizeExistingPath(REPOSITORY_DIRECTORY, fsImpl);
|
||||
const resolvedAllowedParentDirectory = canonicalizeExistingPath(allowedParentDirectory, fsImpl);
|
||||
const filesystemRoot = path.parse(resolvedEvidenceDirectory).root;
|
||||
if (
|
||||
pathsAreEqual(resolvedEvidenceDirectory, filesystemRoot) ||
|
||||
pathsAreEqual(resolvedEvidenceDirectory, resolvedRepositoryDirectory) ||
|
||||
!pathsAreEqual(path.dirname(resolvedEvidenceDirectory), resolvedAllowedParentDirectory) ||
|
||||
!pathsAreEqual(path.basename(resolvedEvidenceDirectory), "live-youtube")
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to reset unsafe live evidence directory ${resolvedEvidenceDirectory}. The target must be the dedicated live-youtube directory directly under ${resolvedAllowedParentDirectory}, never a filesystem or repository root.`,
|
||||
);
|
||||
}
|
||||
if (fsImpl.existsSync(resolvedEvidenceDirectory) && !fsImpl.statSync(resolvedEvidenceDirectory).isDirectory()) {
|
||||
throw new Error(`Refusing to reset live evidence path because it is not a directory: ${resolvedEvidenceDirectory}`);
|
||||
}
|
||||
return resolvedEvidenceDirectory;
|
||||
}
|
||||
|
||||
function resetLiveAttemptEvidenceWorkspace(
|
||||
{ attemptNumber } = {},
|
||||
{
|
||||
allowedParentDirectory = LIVE_EVIDENCE_PARENT_DIRECTORY,
|
||||
evidenceDirectory = LIVE_EVIDENCE_DIRECTORY,
|
||||
fsImpl = fs,
|
||||
} = {},
|
||||
) {
|
||||
const resolvedEvidenceDirectory = resolveSafeLiveEvidenceDirectory(evidenceDirectory, fsImpl, allowedParentDirectory);
|
||||
if (!fsImpl.existsSync(resolvedEvidenceDirectory)) {
|
||||
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries: [] };
|
||||
}
|
||||
|
||||
const removedEntries = [];
|
||||
for (const entry of fsImpl.readdirSync(resolvedEvidenceDirectory).sort()) {
|
||||
if (isAttemptsEvidenceEntry(entry)) continue;
|
||||
const entryPath = path.resolve(resolvedEvidenceDirectory, entry);
|
||||
const relativeEntryPath = path.relative(resolvedEvidenceDirectory, entryPath);
|
||||
if (
|
||||
!relativeEntryPath ||
|
||||
relativeEntryPath === ".." ||
|
||||
relativeEntryPath.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relativeEntryPath)
|
||||
) {
|
||||
throw new Error(`Refusing to remove live evidence entry outside the dedicated directory: ${entryPath}`);
|
||||
}
|
||||
fsImpl.rmSync(entryPath, { force: true, recursive: true });
|
||||
removedEntries.push(entry);
|
||||
}
|
||||
return { attemptNumber, evidenceDirectory: resolvedEvidenceDirectory, removedEntries };
|
||||
}
|
||||
|
||||
function preserveLiveAttemptEvidence(
|
||||
{ attemptNumber, outcome },
|
||||
{ evidenceDirectory = LIVE_EVIDENCE_DIRECTORY, fsImpl = fs, now = () => new Date() } = {},
|
||||
) {
|
||||
const timestamp = now().toISOString().replace(/[:.]/gu, "-");
|
||||
const attemptDirectory = path.join(
|
||||
evidenceDirectory,
|
||||
"attempts",
|
||||
`${String(attemptNumber).padStart(2, "0")}-${timestamp}-${outcome}`,
|
||||
);
|
||||
fsImpl.mkdirSync(attemptDirectory, { recursive: true });
|
||||
|
||||
if (fsImpl.existsSync(evidenceDirectory)) {
|
||||
for (const entry of fsImpl.readdirSync(evidenceDirectory)) {
|
||||
if (isAttemptsEvidenceEntry(entry)) continue;
|
||||
copyDirectoryEntry(path.join(evidenceDirectory, entry), path.join(attemptDirectory, entry), fsImpl);
|
||||
}
|
||||
}
|
||||
fsImpl.writeFileSync(
|
||||
path.join(attemptDirectory, "attempt.json"),
|
||||
`${JSON.stringify({ attemptNumber, capturedAt: now().toISOString(), outcome }, null, 2)}\n`,
|
||||
);
|
||||
return attemptDirectory;
|
||||
}
|
||||
|
||||
function createInteractiveLiveDriver({ context, diagnostics, options, page, selectedExtensionId }) {
|
||||
return new LiveYoutubeDriver(page, context, {
|
||||
authenticatedHandle: options.expectedChannel,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
|
||||
selectedExtensionId,
|
||||
});
|
||||
}
|
||||
|
||||
function createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }) {
|
||||
return (videoId) =>
|
||||
new VoteTrafficRecorder(context, videoId, {
|
||||
page,
|
||||
runtime: options.runtime,
|
||||
selectedExtensionId,
|
||||
trafficLedger: driver.trafficLedger,
|
||||
});
|
||||
}
|
||||
|
||||
function createReactionTargetDetails(options) {
|
||||
return {
|
||||
reactionShort: options.reactionShort,
|
||||
reactionWatch: options.watchB,
|
||||
};
|
||||
}
|
||||
|
||||
function formatReactionApprovalReadiness(options) {
|
||||
return `READY_FOR_REACTION_APPROVAL runtime=${options.runtime} watch=${options.watchB} reactionShort=${options.reactionShort} readOnlyShort=${options.short}`;
|
||||
}
|
||||
|
||||
async function closeLiveAttemptPage(page, { timeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS } = {}) {
|
||||
if (!page || page.isClosed()) return false;
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new TypeError("The live-attempt page-close timeout must be positive.");
|
||||
}
|
||||
await withOperationTimeout(
|
||||
() => page.close(),
|
||||
timeoutMs,
|
||||
`Timed out after ${timeoutMs}ms while closing the live-attempt test page.`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function prepareLiveAttempt({ buildArtifact, environment, output, readLine, readOptions, requestedRuntime }) {
|
||||
const build = buildArtifact(requestedRuntime);
|
||||
output.log(`LIVE_ARTIFACT_BUILT ${JSON.stringify(build)}`);
|
||||
if (requestedRuntime === "userscript") {
|
||||
output.log(
|
||||
"LIVE_USERSCRIPT_INSTALL_REQUIRED Reinstall test-results/live-build/userscript/Return Youtube Dislike.user.js, then enter INSTALLED.",
|
||||
);
|
||||
requireUserscriptInstallAcknowledgement(await readLine());
|
||||
}
|
||||
const options = readOptions(environment);
|
||||
if (!options) throw new Error("Set RYD_LIVE_YOUTUBE=1 and the documented allowlist variables to opt in.");
|
||||
if (options.runtime !== requestedRuntime) {
|
||||
throw new Error(`The built live runtime ${requestedRuntime} does not match configured runtime ${options.runtime}.`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function runConnectedLiveAttempt({
|
||||
browser,
|
||||
options,
|
||||
output,
|
||||
pageCloseTimeoutMs = LIVE_ATTEMPT_PAGE_CLOSE_TIMEOUT_MS,
|
||||
readLine,
|
||||
selectedSession,
|
||||
}) {
|
||||
let attemptError;
|
||||
let diagnostics;
|
||||
let driver;
|
||||
let page;
|
||||
let selectedExtensionId = null;
|
||||
try {
|
||||
const { context } = selectedSession;
|
||||
let runtimeAdapter;
|
||||
const { readOnly } = await runInteractiveReadOnlyAfterRuntimeReload({
|
||||
initializeRuntime: async (reloaded) => {
|
||||
selectedExtensionId = reloaded?.extensionId ?? null;
|
||||
page = await context.newPage();
|
||||
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
|
||||
await diagnostics.start();
|
||||
driver = createInteractiveLiveDriver({
|
||||
context,
|
||||
diagnostics,
|
||||
options,
|
||||
page,
|
||||
selectedExtensionId,
|
||||
});
|
||||
const createAdapter =
|
||||
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
|
||||
runtimeAdapter = createAdapter({
|
||||
driver,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
});
|
||||
output.log("CHROMIUM_CONNECTED");
|
||||
return { diagnostics, runtimeAdapter };
|
||||
},
|
||||
options,
|
||||
reloadExtension: async () => {
|
||||
const reloaded = await reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
sessionPage: selectedSession.sessionPage,
|
||||
});
|
||||
output.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
|
||||
return reloaded;
|
||||
},
|
||||
scenarioRunner,
|
||||
});
|
||||
await driver.assertNoUnclaimedAttributedTraffic("interactive read-only run");
|
||||
output.log(`READ_ONLY_COMPLETE ${JSON.stringify(readOnly)}`);
|
||||
output.log(formatReactionApprovalReadiness(options));
|
||||
|
||||
diagnostics.checkpoint("reaction-approval.waiting", {
|
||||
instruction: "Enter the fresh production-reaction approval token",
|
||||
});
|
||||
const approval = requireProductionReactionApproval(await readLine());
|
||||
diagnostics.consumeFatalSignals("reaction approval wait");
|
||||
await driver.assertNoUnclaimedAttributedTraffic("reaction approval wait");
|
||||
if (!hasFreshVoteApproval(approval, options.runtime, options.watchB, Date.now())) {
|
||||
throw new Error("The supplied live reaction approval is missing, expired, or does not match this run.");
|
||||
}
|
||||
|
||||
let approvalConsumed = false;
|
||||
const reactionServices = {
|
||||
createRecorder: createInteractiveVoteRecorderFactory({ context, driver, options, page, selectedExtensionId }),
|
||||
consumeVoteApproval: async () => {
|
||||
driver.assertCurrentVideo(options.watchB);
|
||||
await driver.assertRuntime(options.runtime, options.expectedVersion, options.expectedBuildId);
|
||||
if (approvalConsumed) return;
|
||||
if (!consumeLiveVoteApproval(approval, options.runtime, options.watchB)) {
|
||||
throw new Error("The live reaction approval expired or was already used. No reaction was clicked.");
|
||||
}
|
||||
approvalConsumed = true;
|
||||
},
|
||||
};
|
||||
const reaction = await runLoggedStage(diagnostics, "production-reactions", async () => ({
|
||||
"post-navigation-vote": await scenarioRunner.run(
|
||||
runtimeAdapter,
|
||||
"post-navigation-vote",
|
||||
options,
|
||||
reactionServices,
|
||||
),
|
||||
"reaction-matrix": await scenarioRunner.run(runtimeAdapter, "reaction-matrix", options, reactionServices),
|
||||
}));
|
||||
await driver.assertNoUnclaimedAttributedTraffic("interactive production reactions");
|
||||
const completion = assertFullLiveReactionCompletion(reaction);
|
||||
output.log(`PRODUCTION_REACTIONS_COMPLETE ${JSON.stringify(reaction)}`);
|
||||
output.log(
|
||||
`LIVE_VALIDATION_COMPLETE ${JSON.stringify({
|
||||
...completion,
|
||||
...createReactionTargetDetails(options),
|
||||
})}`,
|
||||
);
|
||||
return { classification: completion.classification, reaction, readOnly };
|
||||
} catch (error) {
|
||||
attemptError = error;
|
||||
if (diagnostics) {
|
||||
try {
|
||||
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
|
||||
output.error(`LIVE_FAILURE_SNAPSHOT ${snapshotPath}`);
|
||||
} catch (snapshotError) {
|
||||
output.error(`LIVE_FAILURE_SNAPSHOT_FAILED ${snapshotError.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
driver?.stop();
|
||||
diagnostics?.stop();
|
||||
let pageCloseError;
|
||||
try {
|
||||
if (page && !page.isClosed()) {
|
||||
output.log(`LIVE_ATTEMPT_PAGE_CLOSE_START ${JSON.stringify({ timeoutMs: pageCloseTimeoutMs })}`);
|
||||
await closeLiveAttemptPage(page, { timeoutMs: pageCloseTimeoutMs });
|
||||
output.log("LIVE_ATTEMPT_PAGE_CLOSE_COMPLETE");
|
||||
}
|
||||
} catch (error) {
|
||||
pageCloseError = error;
|
||||
output.error(`LIVE_ATTEMPT_PAGE_CLOSE_FAILED ${JSON.stringify({ message: error.message })}`);
|
||||
}
|
||||
if (attemptError && pageCloseError) {
|
||||
throw new AggregateError(
|
||||
[attemptError, pageCloseError],
|
||||
"The live attempt and its test-page cleanup both failed.",
|
||||
);
|
||||
}
|
||||
if (attemptError) throw attemptError;
|
||||
if (pageCloseError) throw pageCloseError;
|
||||
}
|
||||
}
|
||||
|
||||
async function runLiveAttemptLoop({
|
||||
initialOptions,
|
||||
output,
|
||||
persistentSession,
|
||||
prepareNextAttempt,
|
||||
preserveEvidence,
|
||||
readLine,
|
||||
resetEvidenceWorkspace,
|
||||
runAttempt,
|
||||
}) {
|
||||
let attemptNumber = 1;
|
||||
let options = initialOptions;
|
||||
|
||||
while (true) {
|
||||
const reset = await resetEvidenceWorkspace({ attemptNumber });
|
||||
output.log(
|
||||
`LIVE_ATTEMPT_EVIDENCE_RESET ${JSON.stringify({
|
||||
attemptNumber,
|
||||
removedEntries: reset?.removedEntries ?? [],
|
||||
})}`,
|
||||
);
|
||||
output.log(
|
||||
`LIVE_ATTEMPT_START ${JSON.stringify({ attemptNumber, persistentSession, runtime: options?.runtime ?? null })}`,
|
||||
);
|
||||
let attemptError = null;
|
||||
let attemptResult = null;
|
||||
try {
|
||||
if (attemptNumber > 1) {
|
||||
const nextOptions = await prepareNextAttempt();
|
||||
options = assertReusableConnectionOptions(initialOptions, nextOptions);
|
||||
}
|
||||
attemptResult = await runAttempt(options, attemptNumber);
|
||||
output.log(`LIVE_ATTEMPT_COMPLETE ${JSON.stringify({ attemptNumber, outcome: "passed" })}`);
|
||||
} catch (error) {
|
||||
attemptError = error;
|
||||
output.error(
|
||||
`LIVE_ATTEMPT_FAILED ${JSON.stringify({ attemptNumber, message: error.message, outcome: "failed" })}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!persistentSession) {
|
||||
if (attemptError) throw attemptError;
|
||||
return attemptResult;
|
||||
}
|
||||
|
||||
const outcome = attemptError ? "failed" : "passed";
|
||||
try {
|
||||
const evidencePath = await preserveEvidence({ attemptNumber, outcome });
|
||||
output.log(`LIVE_ATTEMPT_EVIDENCE ${JSON.stringify({ attemptNumber, outcome, path: evidencePath })}`);
|
||||
} catch (evidenceError) {
|
||||
attemptError = attemptError
|
||||
? new AggregateError([attemptError, evidenceError], "The live attempt and its evidence snapshot both failed.")
|
||||
: evidenceError;
|
||||
output.error(`LIVE_ATTEMPT_EVIDENCE_FAILED ${JSON.stringify({ attemptNumber, message: evidenceError.message })}`);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
output.log(`READY_FOR_LIVE_RERUN_OR_EXIT attempt=${attemptNumber} outcome=${attemptError ? "failed" : "passed"}`);
|
||||
const command = normalizeLiveSessionCommand(await readLine());
|
||||
if (command === "RERUN") {
|
||||
attemptNumber += 1;
|
||||
break;
|
||||
}
|
||||
if (command === "EXIT") {
|
||||
if (attemptError) throw attemptError;
|
||||
return attemptResult;
|
||||
}
|
||||
output.error("LIVE_RERUN_COMMAND_REJECTED expected=RERUN_OR_EXIT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main({
|
||||
buildArtifact = buildLiveRuntimeArtifact,
|
||||
connectBrowser = (options) =>
|
||||
chromium.connectOverCDP(options.cdpEndpoint, {
|
||||
isLocal: true,
|
||||
noDefaults: true,
|
||||
timeout: options.cdpConnectTimeoutMilliseconds,
|
||||
}),
|
||||
environment = process.env,
|
||||
executeAttempt = runConnectedLiveAttempt,
|
||||
output = console,
|
||||
preserveEvidence = preserveLiveAttemptEvidence,
|
||||
readLine,
|
||||
readOptions = readLiveOptions,
|
||||
resetEvidenceWorkspace = resetLiveAttemptEvidenceWorkspace,
|
||||
selectContext = selectAuthenticatedYoutubeContext,
|
||||
} = {}) {
|
||||
const persistentSession = readPersistentSessionMode(environment);
|
||||
const requestedRuntime = readFullLiveRuntime(environment);
|
||||
const ownedLineReader = readLine ? null : createInputLineReader();
|
||||
const nextLine = readLine ?? ownedLineReader.readLine;
|
||||
const prepareAttempt = () =>
|
||||
prepareLiveAttempt({
|
||||
buildArtifact,
|
||||
environment,
|
||||
output,
|
||||
readLine: nextLine,
|
||||
readOptions,
|
||||
requestedRuntime,
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
const initialOptions = await prepareAttempt();
|
||||
output.log("WAITING_FOR_CHROMIUM_DEBUG_APPROVAL");
|
||||
browser = await connectBrowser(initialOptions);
|
||||
const selectedSession = await selectContext(browser, initialOptions.expectedChannel);
|
||||
output.log(
|
||||
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${initialOptions.expectedChannel}`,
|
||||
);
|
||||
|
||||
return await runLiveAttemptLoop({
|
||||
initialOptions,
|
||||
output,
|
||||
persistentSession,
|
||||
prepareNextAttempt: prepareAttempt,
|
||||
preserveEvidence,
|
||||
readLine: nextLine,
|
||||
resetEvidenceWorkspace,
|
||||
runAttempt: (options, attemptNumber) =>
|
||||
executeAttempt({ browser, options, output, readLine: nextLine, selectedSession, attemptNumber }),
|
||||
});
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
ownedLineReader?.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertReusableConnectionOptions,
|
||||
closeLiveAttemptPage,
|
||||
createInputLineReader,
|
||||
createInteractiveLiveDriver,
|
||||
createInteractiveVoteRecorderFactory,
|
||||
createReactionTargetDetails,
|
||||
formatReactionApprovalReadiness,
|
||||
main,
|
||||
normalizeLiveSessionCommand,
|
||||
prepareLiveAttempt,
|
||||
preserveLiveAttemptEvidence,
|
||||
readPersistentSessionMode,
|
||||
resetLiveAttemptEvidenceWorkspace,
|
||||
resolveSafeLiveEvidenceDirectory,
|
||||
runConnectedLiveAttempt,
|
||||
runLiveAttemptLoop,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
const REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS = Object.freeze([
|
||||
"shorts-render",
|
||||
"channel-shorts-navigation",
|
||||
"channel-watch-navigation",
|
||||
"reload",
|
||||
"responsive-visual",
|
||||
"spa-navigation",
|
||||
"watch-action-topology",
|
||||
"sidebar-navigation-stress",
|
||||
"watch-render",
|
||||
]);
|
||||
|
||||
function assertExactScenarioOrder(actualScenarioIds, label) {
|
||||
if (!Array.isArray(actualScenarioIds)) throw new TypeError(`${label} must be an array.`);
|
||||
if (actualScenarioIds.some((scenarioId) => typeof scenarioId !== "string" || scenarioId.trim() === "")) {
|
||||
throw new TypeError(`${label} must contain only non-empty scenario IDs.`);
|
||||
}
|
||||
|
||||
const duplicates = actualScenarioIds.filter((scenarioId, index) => actualScenarioIds.indexOf(scenarioId) !== index);
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(`${label} contains duplicate scenario IDs: ${[...new Set(duplicates)].join(", ")}.`);
|
||||
}
|
||||
|
||||
const actual = new Set(actualScenarioIds);
|
||||
const required = new Set(REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS);
|
||||
const missing = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.filter((scenarioId) => !actual.has(scenarioId));
|
||||
const unexpected = actualScenarioIds.filter((scenarioId) => !required.has(scenarioId));
|
||||
if (missing.length > 0 || unexpected.length > 0) {
|
||||
throw new Error(
|
||||
`${label} does not match the required interactive read-only set. Missing: ${
|
||||
missing.join(", ") || "none"
|
||||
}; unexpected: ${unexpected.join(", ") || "none"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstMismatch = actualScenarioIds.findIndex(
|
||||
(scenarioId, index) => scenarioId !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS[index],
|
||||
);
|
||||
if (actualScenarioIds.length !== REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS.length || firstMismatch !== -1) {
|
||||
throw new Error(`${label} does not follow the required interactive read-only order.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertUniqueDefinitionField(definitions, field) {
|
||||
const values = definitions.map((definition) => definition?.[field]);
|
||||
if (values.some((value) => typeof value !== "string" || value.trim() === "")) {
|
||||
throw new TypeError(`Every interactive read-only scenario requires a non-empty ${field}.`);
|
||||
}
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new Error(`Interactive read-only scenario ${field} values must be unique.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertInteractiveReadOnlyScenarioDefinitions(definitions) {
|
||||
if (!Array.isArray(definitions)) throw new TypeError("Interactive read-only scenario definitions must be an array.");
|
||||
assertUniqueDefinitionField(definitions, "key");
|
||||
assertUniqueDefinitionField(definitions, "name");
|
||||
return assertExactScenarioOrder(
|
||||
definitions.map((definition) => definition?.scenarioId),
|
||||
"Interactive read-only scenario definitions",
|
||||
);
|
||||
}
|
||||
|
||||
function assertInteractiveReadOnlyScenarioCompletions(completedScenarioIds) {
|
||||
return assertExactScenarioOrder(completedScenarioIds, "Completed interactive read-only scenarios");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS,
|
||||
assertInteractiveReadOnlyScenarioCompletions,
|
||||
assertInteractiveReadOnlyScenarioDefinitions,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
const { chromium, test } = require("@playwright/test");
|
||||
const {
|
||||
createExtensionLiveRuntimeAdapter,
|
||||
createUserscriptLiveRuntimeAdapter,
|
||||
} = require("../../../e2e/live-runtime-adapter");
|
||||
const { createSharedLiveScenarioRunner } = require("../../../e2e/shared-live-scenarios");
|
||||
const { readLiveOptions } = require("../../live/live-options");
|
||||
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
|
||||
const { LiveReadOnlyGate, LiveRunDiagnostics, runLoggedStage } = require("./live-diagnostics");
|
||||
const { reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
|
||||
const { REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS } = require("./live-interactive-scenario-contract");
|
||||
const { LiveYoutubeDriver } = require("./live-youtube-driver");
|
||||
|
||||
const options = readLiveOptions();
|
||||
const scenarioRunner = createSharedLiveScenarioRunner();
|
||||
const REQUIRED_READ_ONLY_SCENARIOS = REQUIRED_INTERACTIVE_READ_ONLY_SCENARIO_IDS;
|
||||
|
||||
if (options === null) {
|
||||
throw new Error(
|
||||
"No live YouTube scenarios ran. Set RYD_LIVE_YOUTUBE=1 and every documented runtime/allowlist variable.",
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("live YouTube RYD read-only smoke", () => {
|
||||
let browser;
|
||||
let context;
|
||||
let diagnostics;
|
||||
let page;
|
||||
let driver;
|
||||
let runtimeAdapter;
|
||||
let selectedExtensionId = null;
|
||||
let sessionPage;
|
||||
let currentReadOnlyScenarioId = null;
|
||||
const readOnlyGate = new LiveReadOnlyGate(REQUIRED_READ_ONLY_SCENARIOS);
|
||||
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
testInfo.setTimeout(options.cdpConnectTimeoutMilliseconds + 15_000);
|
||||
try {
|
||||
browser = await chromium.connectOverCDP(options.cdpEndpoint, {
|
||||
isLocal: true,
|
||||
noDefaults: true,
|
||||
timeout: options.cdpConnectTimeoutMilliseconds,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Could not attach to the running Chrome/Chromium profile. Enable remote debugging in the target browser and set RYD_CDP_ENDPOINT to its WebSocket, HTTP, host:port, or browser-alias endpoint, then retry. ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const selectedSession = await selectAuthenticatedYoutubeContext(browser, options.expectedChannel);
|
||||
context = selectedSession.context;
|
||||
sessionPage = selectedSession.sessionPage;
|
||||
console.log(
|
||||
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${options.expectedChannel}`,
|
||||
);
|
||||
if (options.runtime === "extension") {
|
||||
const reloaded = await reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
sessionPage,
|
||||
});
|
||||
selectedExtensionId = reloaded.extensionId;
|
||||
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(reloaded)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
currentReadOnlyScenarioId = null;
|
||||
page = await context.newPage();
|
||||
diagnostics = new LiveRunDiagnostics(page, context, { runtime: options.runtime, selectedExtensionId });
|
||||
await diagnostics.start();
|
||||
driver = new LiveYoutubeDriver(page, context, {
|
||||
authenticatedHandle: options.expectedChannel,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
reportProgress: (name, details) => diagnostics.checkpoint(name, details),
|
||||
selectedExtensionId,
|
||||
});
|
||||
const createAdapter =
|
||||
options.runtime === "extension" ? createExtensionLiveRuntimeAdapter : createUserscriptLiveRuntimeAdapter;
|
||||
runtimeAdapter = createAdapter({
|
||||
driver,
|
||||
expectedBuildId: options.expectedBuildId,
|
||||
expectedVersion: options.expectedVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({}, testInfo) => {
|
||||
if (!diagnostics) return;
|
||||
let lateSignalError = null;
|
||||
let snapshotError = null;
|
||||
try {
|
||||
if (driver?.trafficLedger) {
|
||||
await driver.assertNoUnclaimedAttributedTraffic(`Playwright scenario ${testInfo.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
lateSignalError = error;
|
||||
}
|
||||
try {
|
||||
diagnostics.consumeFatalSignals(`after ${testInfo.title}`);
|
||||
} catch (error) {
|
||||
lateSignalError = lateSignalError
|
||||
? new AggregateError([lateSignalError, error], "Late traffic and browser diagnostics both failed.")
|
||||
: error;
|
||||
}
|
||||
try {
|
||||
if (lateSignalError || testInfo.status !== testInfo.expectedStatus) {
|
||||
const error =
|
||||
lateSignalError ?? testInfo.errors.at(-1) ?? new Error(`${testInfo.title} failed without an attached error.`);
|
||||
const snapshotPath = await diagnostics.persistFailureSnapshot(error);
|
||||
await testInfo.attach("live-failure-snapshot", { contentType: "application/json", path: snapshotPath });
|
||||
}
|
||||
} catch (error) {
|
||||
snapshotError = error;
|
||||
} finally {
|
||||
if (currentReadOnlyScenarioId) {
|
||||
const completed = !lateSignalError && !snapshotError && testInfo.status === "passed";
|
||||
readOnlyGate.record(currentReadOnlyScenarioId, completed ? testInfo.status : "failed");
|
||||
}
|
||||
driver?.stop();
|
||||
diagnostics.stop();
|
||||
if (page && !page.isClosed()) await page.close();
|
||||
diagnostics = null;
|
||||
driver = null;
|
||||
page = null;
|
||||
runtimeAdapter = null;
|
||||
}
|
||||
if (lateSignalError && snapshotError) {
|
||||
throw new AggregateError(
|
||||
[lateSignalError, snapshotError],
|
||||
"A late fatal browser signal and snapshot failure occurred.",
|
||||
);
|
||||
}
|
||||
if (lateSignalError) throw lateSignalError;
|
||||
if (snapshotError) throw snapshotError;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
readOnlyGate.assertPassed();
|
||||
console.log(
|
||||
`LIVE_VALIDATION_READ_ONLY_COMPLETE ${JSON.stringify({
|
||||
classification: "read-only",
|
||||
productionReactionsCompleted: false,
|
||||
releaseReady: false,
|
||||
})}`,
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
const runScenario = (scenarioId, services) =>
|
||||
runLoggedStage(diagnostics, `playwright.${scenarioId}`, () =>
|
||||
scenarioRunner.run(runtimeAdapter, scenarioId, options, services),
|
||||
);
|
||||
|
||||
const runReadOnlyScenario = (scenarioId) => {
|
||||
currentReadOnlyScenarioId = scenarioId;
|
||||
return runScenario(scenarioId);
|
||||
};
|
||||
|
||||
test(`${options?.runtime || "runtime"}: renders on an allowlisted Short`, async () => {
|
||||
await runReadOnlyScenario("shorts-render");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: audits ten-plus successful Shorts Next-video SPA samples`, async () => {
|
||||
test.setTimeout(120_000 + options.navigation.shortsNextHops * 2 * 45_000);
|
||||
await runReadOnlyScenario("channel-shorts-navigation");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: initializes after cold channel-to-watch SPA navigation`, async () => {
|
||||
test.setTimeout(120_000);
|
||||
await runReadOnlyScenario("channel-watch-navigation");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: renders on a signed-in watch page`, async () => {
|
||||
await runReadOnlyScenario("watch-render");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: reinitializes after reload`, async () => {
|
||||
await runReadOnlyScenario("reload");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: reinitializes after real playlist SPA navigation`, async () => {
|
||||
await runReadOnlyScenario("spa-navigation");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: preserves the complete Watch action topology`, async () => {
|
||||
test.setTimeout(240_000);
|
||||
await runReadOnlyScenario("watch-action-topology");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: survives consecutive real sidebar SPA navigations`, async () => {
|
||||
test.setTimeout(60_000 + options.sidebar.hopCount * 75_000);
|
||||
await runReadOnlyScenario("sidebar-navigation-stress");
|
||||
});
|
||||
|
||||
test(`${options?.runtime || "runtime"}: preserves reaction UI geometry across responsive widths`, async () => {
|
||||
test.setTimeout(180_000);
|
||||
await runReadOnlyScenario("responsive-visual");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("@playwright/test");
|
||||
const { resolveCdpEndpoint } = require("../../live/live-cdp-endpoint");
|
||||
const { selectAuthenticatedYoutubeContext } = require("./live-authenticated-context");
|
||||
const { LIVE_EXTENSION_PATH, reloadLiveExtensionInBrowser } = require("./live-extension-reloader");
|
||||
|
||||
const extensionPath = LIVE_EXTENSION_PATH;
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function connectTimeoutMilliseconds(environment) {
|
||||
const configured = Number(environment.RYD_CDP_CONNECT_TIMEOUT_MS || 120_000);
|
||||
if (!Number.isSafeInteger(configured) || configured < 15_000 || configured > 300_000) {
|
||||
throw new Error("RYD_CDP_CONNECT_TIMEOUT_MS must be an integer from 15000 through 300000.");
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const expectedBuildId = readJson(path.join(extensionPath, "live-build.json")).buildId;
|
||||
const expectedVersion = readJson(path.join(extensionPath, "manifest.json")).version;
|
||||
const timeoutMilliseconds = connectTimeoutMilliseconds(process.env);
|
||||
const cdpEndpoint = resolveCdpEndpoint(process.env.RYD_CDP_ENDPOINT);
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.connectOverCDP(cdpEndpoint, {
|
||||
isLocal: true,
|
||||
noDefaults: true,
|
||||
timeout: timeoutMilliseconds,
|
||||
});
|
||||
const selectedSession = await selectAuthenticatedYoutubeContext(browser, process.env.RYD_LIVE_EXPECTED_CHANNEL);
|
||||
const { context, sessionPage } = selectedSession;
|
||||
console.log(
|
||||
`AUTHENTICATED_CHROMIUM_CONTEXT_SELECTED context=${selectedSession.contextIndex + 1} page=${selectedSession.pageIndex + 1} handle=${process.env.RYD_LIVE_EXPECTED_CHANNEL}`,
|
||||
);
|
||||
|
||||
const result = await reloadLiveExtensionInBrowser({
|
||||
browser,
|
||||
context,
|
||||
expectedBuildId,
|
||||
expectedExtensionPath: extensionPath,
|
||||
expectedVersion,
|
||||
sessionPage,
|
||||
timeoutMilliseconds,
|
||||
});
|
||||
console.log(`LIVE_EXTENSION_RELOADED ${JSON.stringify(result)}`);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { connectTimeoutMilliseconds, main };
|
||||
@@ -0,0 +1,236 @@
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST = 3;
|
||||
const WATCH_RATIO_GEOMETRY_TOLERANCE = 0.015;
|
||||
const WATCH_RATIO_INLINE_TOLERANCE = 0.000_1;
|
||||
|
||||
function normalizeTooltipText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/giu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formattedWatchTooltipCandidates(expectedCounts, locale) {
|
||||
assertExpectedWatchCounts(expectedCounts);
|
||||
const formatter = new Intl.NumberFormat(locale || undefined);
|
||||
const total = expectedCounts.likes + expectedCounts.dislikes;
|
||||
const widthPercent = total > 0 ? (expectedCounts.likes / total) * 100 : 50;
|
||||
const likePercentageValue = Number.parseFloat(widthPercent.toFixed(1));
|
||||
const dislikePercentageValue = 100 - likePercentageValue;
|
||||
const likes = formatter.format(expectedCounts.likes);
|
||||
const dislikes = formatter.format(expectedCounts.dislikes);
|
||||
const likePercentage = formatter.format(likePercentageValue);
|
||||
const dislikePercentage = formatter.format(dislikePercentageValue);
|
||||
const countPair = `${likes} / ${dislikes}`;
|
||||
|
||||
return [
|
||||
{ form: "counts", text: countPair },
|
||||
{ form: "counts-with-like-percentage", text: `${countPair} - ${likePercentage}%` },
|
||||
{ form: "counts-with-dislike-percentage", text: `${countPair} - ${dislikePercentage}%` },
|
||||
{ form: "both-percentages", text: `${likePercentage}% / ${dislikePercentage}%` },
|
||||
{ form: "like-percentage", text: `${likePercentage}%` },
|
||||
{ form: "dislike-percentage", text: `${dislikePercentage}%` },
|
||||
];
|
||||
}
|
||||
|
||||
function clampColorChannel(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function parseAlpha(value) {
|
||||
if (value === undefined) return 1;
|
||||
const parsed = value.endsWith("%") ? Number.parseFloat(value) / 100 : Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : null;
|
||||
}
|
||||
|
||||
function parseRgbChannel(value) {
|
||||
const parsed = value.endsWith("%") ? (Number.parseFloat(value) / 100) * 255 : Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? clampColorChannel(parsed) : null;
|
||||
}
|
||||
|
||||
function parseCssColor(value) {
|
||||
const color = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!color || color === "transparent") return color === "transparent" ? { a: 0, b: 0, g: 0, r: 0 } : null;
|
||||
|
||||
const hex = color.match(/^#([a-f0-9]{3,8})$/iu)?.[1];
|
||||
if (hex) {
|
||||
const expanded = hex.length === 3 || hex.length === 4 ? [...hex].map((digit) => digit + digit).join("") : hex;
|
||||
if (expanded.length !== 6 && expanded.length !== 8) return null;
|
||||
return {
|
||||
a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1,
|
||||
b: Number.parseInt(expanded.slice(4, 6), 16),
|
||||
g: Number.parseInt(expanded.slice(2, 4), 16),
|
||||
r: Number.parseInt(expanded.slice(0, 2), 16),
|
||||
};
|
||||
}
|
||||
|
||||
const rgb = color.match(/^rgba?\((.*)\)$/iu)?.[1];
|
||||
if (rgb) {
|
||||
const commaParts = rgb.includes(",") ? rgb.split(",").map((part) => part.trim()) : null;
|
||||
const legacyAlpha = commaParts?.length === 4 ? commaParts.pop() : undefined;
|
||||
const normalized = (commaParts ?? [rgb]).join(" ").replace(/\s*\/\s*/gu, " / ");
|
||||
const [channelsPart, modernAlpha] = normalized.split(/\s+\/\s+/u);
|
||||
const channels = channelsPart.trim().split(/\s+/u);
|
||||
if (channels.length !== 3) return null;
|
||||
const [r, g, b] = channels.map(parseRgbChannel);
|
||||
const a = parseAlpha(modernAlpha ?? legacyAlpha);
|
||||
return [r, g, b, a].every((channel) => channel !== null) ? { a, b, g, r } : null;
|
||||
}
|
||||
|
||||
const srgb = color.match(/^color\(srgb\s+(.+)\)$/iu)?.[1];
|
||||
if (srgb) {
|
||||
const [channelsPart, alphaPart] = srgb.split(/\s+\/\s+/u);
|
||||
const channels = channelsPart.trim().split(/\s+/u).map(Number.parseFloat);
|
||||
const a = parseAlpha(alphaPart);
|
||||
if (channels.length !== 3 || !channels.every(Number.isFinite) || a === null) return null;
|
||||
return {
|
||||
a,
|
||||
b: clampColorChannel(channels[2] * 255),
|
||||
g: clampColorChannel(channels[1] * 255),
|
||||
r: clampColorChannel(channels[0] * 255),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function compositeColor(foreground, background) {
|
||||
const alpha = foreground.a + background.a * (1 - foreground.a);
|
||||
if (alpha === 0) return { a: 0, b: 0, g: 0, r: 0 };
|
||||
const channel = (key) =>
|
||||
(foreground[key] * foreground.a + background[key] * background.a * (1 - foreground.a)) / alpha;
|
||||
return { a: alpha, b: channel("b"), g: channel("g"), r: channel("r") };
|
||||
}
|
||||
|
||||
function relativeLuminance(color) {
|
||||
const linear = (channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return linear(color.r) * 0.2126 + linear(color.g) * 0.7152 + linear(color.b) * 0.0722;
|
||||
}
|
||||
|
||||
function colorContrastRatio(first, second) {
|
||||
const firstLuminance = relativeLuminance(first);
|
||||
const secondLuminance = relativeLuminance(second);
|
||||
const lighter = Math.max(firstLuminance, secondLuminance);
|
||||
const darker = Math.min(firstLuminance, secondLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function assertExpectedWatchCounts(expectedCounts) {
|
||||
assert.ok(expectedCounts && typeof expectedCounts === "object", "Exact Watch API counts are required.");
|
||||
for (const key of ["likes", "dislikes"]) {
|
||||
assert.ok(
|
||||
Number.isSafeInteger(expectedCounts[key]) && expectedCounts[key] >= 0,
|
||||
`The Watch API ${key} count must be a non-negative integer.`,
|
||||
);
|
||||
}
|
||||
return expectedCounts;
|
||||
}
|
||||
|
||||
function assertWatchRatioData(
|
||||
measurement,
|
||||
{
|
||||
expectedCounts = null,
|
||||
expectedTooltipCandidates = [],
|
||||
minimumNegativeTrackContrast = WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
|
||||
runtime,
|
||||
videoId,
|
||||
},
|
||||
) {
|
||||
assert.ok(measurement?.appearance, "The Watch ratio-bar appearance audit is missing.");
|
||||
assert.equal(
|
||||
measurement.appearance.wrapperVideoId,
|
||||
videoId,
|
||||
`The ${runtime} Watch ratio bar is owned by ${measurement.appearance.wrapperVideoId ?? "no video"}, not ${videoId}.`,
|
||||
);
|
||||
|
||||
const pageBackground = parseCssColor(measurement.appearance.pageBackgroundColor);
|
||||
const negativeTrack = parseCssColor(measurement.appearance.negativeTrackColor);
|
||||
assert.ok(
|
||||
pageBackground,
|
||||
`Cannot resolve the Watch page background color: ${measurement.appearance.pageBackgroundColor}.`,
|
||||
);
|
||||
assert.ok(
|
||||
negativeTrack,
|
||||
`Cannot resolve the Watch negative-track color: ${measurement.appearance.negativeTrackColor}.`,
|
||||
);
|
||||
assert.equal(
|
||||
measurement.appearance.negativeTrackBackgroundImage ?? "none",
|
||||
"none",
|
||||
"The Watch negative track uses a background image whose effective contrast cannot be audited.",
|
||||
);
|
||||
assert.ok(pageBackground.a >= 0.99, "The resolved Watch page background is not opaque enough for a contrast audit.");
|
||||
const negativeTrackOpacity = Number(measurement.appearance.negativeTrackOpacity ?? 1);
|
||||
assert.ok(
|
||||
Number.isFinite(negativeTrackOpacity) && negativeTrackOpacity >= 0 && negativeTrackOpacity <= 1,
|
||||
`The Watch negative-track effective opacity is invalid: ${measurement.appearance.negativeTrackOpacity}.`,
|
||||
);
|
||||
negativeTrack.a *= negativeTrackOpacity;
|
||||
const effectiveNegativeTrack = compositeColor(negativeTrack, pageBackground);
|
||||
const negativeTrackContrast = colorContrastRatio(effectiveNegativeTrack, pageBackground);
|
||||
assert.ok(
|
||||
negativeTrack.a > 0 && negativeTrackContrast >= minimumNegativeTrackContrast,
|
||||
`The Watch negative track is effectively invisible against the page background: contrast ${negativeTrackContrast.toFixed(2)}:1 is below ${minimumNegativeTrackContrast}:1.`,
|
||||
);
|
||||
|
||||
const result = { negativeTrackContrast, wrapperVideoId: measurement.appearance.wrapperVideoId };
|
||||
if (expectedCounts === null) return result;
|
||||
|
||||
assertExpectedWatchCounts(expectedCounts);
|
||||
const total = expectedCounts.likes + expectedCounts.dislikes;
|
||||
const expectedRatio = total > 0 ? expectedCounts.likes / total : 0.5;
|
||||
const inlineRatio = Number.parseFloat(measurement.appearance.inlineFillWidth) / 100;
|
||||
assert.ok(Number.isFinite(inlineRatio), "The Watch ratio fill has no numeric inline percentage width.");
|
||||
assert.ok(
|
||||
Math.abs(inlineRatio - expectedRatio) <= WATCH_RATIO_INLINE_TOLERANCE,
|
||||
`The Watch ratio fill inline width represents ${(inlineRatio * 100).toFixed(3)}%, expected ${(expectedRatio * 100).toFixed(3)}% from exact API counts.`,
|
||||
);
|
||||
|
||||
const { bar, container } = measurement.geometry ?? {};
|
||||
assert.ok(container?.width > 0 && bar?.width >= 0, "The Watch ratio geometry cannot represent an exact ratio.");
|
||||
const renderedRatio = bar.width / container.width;
|
||||
const pixelTolerance = 1 / container.width;
|
||||
assert.ok(
|
||||
Math.abs(renderedRatio - expectedRatio) <= Math.max(WATCH_RATIO_GEOMETRY_TOLERANCE, pixelTolerance),
|
||||
`The rendered Watch ratio is ${(renderedRatio * 100).toFixed(2)}%, expected ${(expectedRatio * 100).toFixed(2)}% from exact API counts.`,
|
||||
);
|
||||
|
||||
const normalizedTooltip = normalizeTooltipText(measurement.appearance.tooltipText);
|
||||
const normalizedCandidates = expectedTooltipCandidates.map((candidate) => ({
|
||||
form: typeof candidate === "string" ? "unspecified" : candidate.form,
|
||||
text: normalizeTooltipText(typeof candidate === "string" ? candidate : candidate.text),
|
||||
}));
|
||||
assert.ok(normalizedCandidates.length > 0, "Exact Watch tooltip expectations are required with API counts.");
|
||||
const tooltipMatch = normalizedCandidates.find((candidate) => candidate.text === normalizedTooltip);
|
||||
assert.ok(
|
||||
tooltipMatch,
|
||||
`The Watch ratio tooltip ${JSON.stringify(normalizedTooltip)} does not exactly match the API counts and configured percentage form.`,
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
expectedRatio,
|
||||
inlineRatio,
|
||||
renderedRatio,
|
||||
tooltipForm: tooltipMatch.form,
|
||||
tooltipText: normalizedTooltip,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WATCH_NEGATIVE_TRACK_MINIMUM_CONTRAST,
|
||||
WATCH_RATIO_GEOMETRY_TOLERANCE,
|
||||
WATCH_RATIO_INLINE_TOLERANCE,
|
||||
assertExpectedWatchCounts,
|
||||
assertWatchRatioData,
|
||||
colorContrastRatio,
|
||||
formattedWatchTooltipCandidates,
|
||||
normalizeTooltipText,
|
||||
parseCssColor,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
registrationConfirmed: true,
|
||||
userId: "ExistingUserscriptCredential00000001",
|
||||
};
|
||||
|
||||
test("the first synchronous click on a replaced current control votes once while a hidden stale control is ignored", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await context.addInitScript(() => {
|
||||
globalThis.__unhandledRejections = [];
|
||||
addEventListener("unhandledrejection", (event) => {
|
||||
globalThis.__unhandledRejections.push(String(event.reason));
|
||||
});
|
||||
});
|
||||
|
||||
const backend = createFakeBackend();
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
const currentDislike = page.locator('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
|
||||
await expect(currentDislike.locator("#text")).toHaveText("25");
|
||||
await expect(page.locator("#return-youtube-dislike-bar-container")).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
|
||||
const stale = current.cloneNode(true);
|
||||
stale.hidden = true;
|
||||
stale.setAttribute("data-fixture-stale-dislike", "true");
|
||||
document.body.append(stale);
|
||||
stale.querySelector("button").click();
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
||||
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(0);
|
||||
await expect(currentDislike.locator("#text")).toHaveText("25");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const current = document.querySelector('[data-ryd-role="buttons"] [data-ryd-role="dislike"]');
|
||||
const replacement = current.cloneNode(true);
|
||||
replacement.setAttribute("data-fixture-replaced-current-dislike", "true");
|
||||
current.replaceWith(replacement);
|
||||
replacement.querySelector("button").click();
|
||||
});
|
||||
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
await expect(page.locator('[data-fixture-replaced-current-dislike="true"] #text')).toHaveText("26");
|
||||
await page.waitForTimeout(650);
|
||||
|
||||
expect(backend.requestsFor("POST", "/interact/vote").map(({ body }) => body)).toEqual([
|
||||
{ userId: EXISTING_CREDENTIALS.userId, value: -1, videoId: VIDEO_A },
|
||||
]);
|
||||
expect(backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(consoleErrors).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
VIDEO_B,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openShortsFixture,
|
||||
readGmValue,
|
||||
} = require("./harness");
|
||||
|
||||
const EXISTING_CREDENTIALS = {
|
||||
userId: "ExistingUserscriptCredential000000000001",
|
||||
registrationConfirmed: true,
|
||||
};
|
||||
const SYNTHETIC_STATE_KEY = `rydSyntheticDislikedShort:${VIDEO_A}`;
|
||||
|
||||
function visibleVoteButton(page, role) {
|
||||
return page.locator(`[data-ryd-role="${role}"]:visible button`);
|
||||
}
|
||||
|
||||
async function installDelayedSyntheticStateRead(page) {
|
||||
await page.evaluate((delayedKey) => {
|
||||
const originalGetValue = globalThis.GM.getValue;
|
||||
let releaseRead;
|
||||
let reportReadStarted;
|
||||
let delayed = false;
|
||||
const readGate = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
globalThis.__rydDelayedGmReadStarted = new Promise((resolve) => {
|
||||
reportReadStarted = resolve;
|
||||
});
|
||||
globalThis.__releaseRydDelayedGmRead = releaseRead;
|
||||
globalThis.GM.getValue = async (key, fallbackValue) => {
|
||||
if (key === delayedKey) {
|
||||
if (!delayed) {
|
||||
delayed = true;
|
||||
reportReadStarted();
|
||||
await readGate;
|
||||
}
|
||||
}
|
||||
return originalGetValue(key, fallbackValue);
|
||||
};
|
||||
}, SYNTHETIC_STATE_KEY);
|
||||
}
|
||||
|
||||
async function launchDelayedShorts(
|
||||
{ context, page },
|
||||
{ backendOptions, configureBackend, nativeDislike = false } = {},
|
||||
) {
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
|
||||
const backend = createFakeBackend(backendOptions);
|
||||
configureBackend?.(backend);
|
||||
await installGmEnvironment(context, { [CREDENTIAL_KEY]: EXISTING_CREDENTIALS });
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openShortsFixture(page, VIDEO_A);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__unhandledRejections = [];
|
||||
addEventListener("unhandledrejection", (event) => {
|
||||
globalThis.__unhandledRejections.push(
|
||||
event.reason instanceof Error ? event.reason.message : String(event.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
if (nativeDislike) {
|
||||
await page.evaluate(() => window.__shortsFixture.installNativeDislike());
|
||||
}
|
||||
await installDelayedSyntheticStateRead(page);
|
||||
await injectGeneratedUserscript(page);
|
||||
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
|
||||
|
||||
return { backend, consoleErrors, pageErrors };
|
||||
}
|
||||
|
||||
async function releaseDelayedRead(page) {
|
||||
await page.evaluate(() => globalThis.__releaseRydDelayedGmRead());
|
||||
}
|
||||
|
||||
async function expectCompletedHandshakes(backend, expectedValues) {
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(expectedValues.length);
|
||||
await expect
|
||||
.poll(() => backend.requestsFor("POST", "/interact/confirmVote").every((request) => request.respondedAt))
|
||||
.toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const votes = backend.requestsFor("POST", "/interact/vote");
|
||||
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
|
||||
expect(votes).toHaveLength(expectedValues.length);
|
||||
expect(confirmations).toHaveLength(expectedValues.length);
|
||||
expect(votes.map((request) => request.body.value)).toEqual(expectedValues);
|
||||
for (let index = 0; index < expectedValues.length; index += 1) {
|
||||
expect(votes[index].body).toEqual({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
value: expectedValues[index],
|
||||
});
|
||||
expect(confirmations[index].body).toMatchObject({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSingleCompletedHandshake(backend, { value, videoId }) {
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote")[0]?.respondedAt).toBeTruthy();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const votes = backend.requestsFor("POST", "/interact/vote");
|
||||
const confirmations = backend.requestsFor("POST", "/interact/confirmVote");
|
||||
expect(votes).toHaveLength(1);
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(votes[0].body).toEqual({ userId: EXISTING_CREDENTIALS.userId, videoId, value });
|
||||
expect(confirmations[0].body).toMatchObject({ userId: EXISTING_CREDENTIALS.userId, videoId });
|
||||
}
|
||||
|
||||
async function expectNoRuntimeFailures(page, harness) {
|
||||
expect(harness.backend.blockedRequests).toEqual([]);
|
||||
expect(harness.consoleErrors).toEqual([]);
|
||||
expect(harness.pageErrors).toEqual([]);
|
||||
expect(await page.evaluate(() => globalThis.__unhandledRejections)).toEqual([]);
|
||||
}
|
||||
|
||||
test("immediate native Like during synthetic Shorts initialization submits once and remains coherent", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
const likeButton = visibleVoteButton(page, "like");
|
||||
|
||||
await likeButton.click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible #text")).toHaveText("25");
|
||||
await expect(likeButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
await likeButton.click();
|
||||
await expectCompletedHandshakes(harness.backend, [1, 0]);
|
||||
await expect(likeButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("immediate native Dislike during native Shorts initialization submits once and remains coherent", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page }, { nativeDislike: true });
|
||||
const dislikeButton = visibleVoteButton(page, "dislike");
|
||||
|
||||
await dislikeButton.click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(dislikeButton.locator("#text")).toHaveText(/\d/);
|
||||
await expect(dislikeButton).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await dislikeButton.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(dislikeButton).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("stale native Shorts hydration preserves video A's captured dislike across a recycled video B", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 11, likes: 100 },
|
||||
[VIDEO_B]: { dislikes: 22, likes: 200 },
|
||||
},
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
try {
|
||||
await expect.poll(() => harness.backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote")[0].body).toEqual({
|
||||
userId: EXISTING_CREDENTIALS.userId,
|
||||
videoId: VIDEO_A,
|
||||
value: -1,
|
||||
});
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
|
||||
await page.evaluate((videoId) => {
|
||||
window.__shortsFixture.recycleActiveRenderer(videoId);
|
||||
window.__shortsFixture.removeNativeDislike();
|
||||
}, VIDEO_A);
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("11");
|
||||
const restoredSyntheticDislike = page.locator("[data-ryd-synthetic-shorts-dislike]:visible button");
|
||||
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await restoredSyntheticDislike.click();
|
||||
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(restoredSyntheticDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
expect(
|
||||
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_B),
|
||||
).toHaveLength(0);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("video B Like remains bound while video A hydration is delayed", async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 11, likes: 100 },
|
||||
[VIDEO_B]: { dislikes: 22, likes: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
const videoBLike = visibleVoteButton(page, "like");
|
||||
await videoBLike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_B, value: 1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("22");
|
||||
await expect(videoBLike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "false");
|
||||
expect(
|
||||
harness.backend.requestsFor("POST", "/interact/vote").filter((request) => request.body.videoId === VIDEO_A),
|
||||
).toHaveLength(0);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
for (const replacement of ["inner Like button", "action bar"]) {
|
||||
test(`a replacement ${replacement} receives the pending same-video hydration listener`, async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
|
||||
await page.evaluate((replacementKind) => {
|
||||
if (replacementKind === "inner Like button") {
|
||||
window.__shortsFixture.replaceInnerButton("like");
|
||||
} else {
|
||||
window.__shortsFixture.replaceActionBar();
|
||||
}
|
||||
}, replacement);
|
||||
const replacementLike = visibleVoteButton(page, "like");
|
||||
await replacementLike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: 1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect(replacementLike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator("[data-ryd-synthetic-shorts-dislike]:visible")).toHaveCount(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
}
|
||||
|
||||
test("enabled synthetic Dislike clears native Like during a pending same-wrapper rehydration", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts({ context, page });
|
||||
await releaseDelayedRead(page);
|
||||
const syntheticDislike = visibleVoteButton(page, "dislike");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect(syntheticDislike).toHaveAttribute("aria-disabled", "false");
|
||||
|
||||
await installDelayedSyntheticStateRead(page);
|
||||
await page.evaluate((videoId) => window.__shortsFixture.activate(videoId, { state: "liked" }), VIDEO_A);
|
||||
await page.evaluate(() => globalThis.__rydDelayedGmReadStarted);
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await syntheticDislike.click();
|
||||
try {
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await expect(visibleVoteButton(page, "like")).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(syntheticDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("new same-video hydration wins after two native Dislike transitions complete before the old read", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
backendOptions: {
|
||||
countsByVideo: {
|
||||
[VIDEO_A]: { dislikes: 25, likes: 100 },
|
||||
},
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
let readReleased = false;
|
||||
try {
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
|
||||
|
||||
await page.evaluate(() => window.__shortsFixture.replaceActionBar());
|
||||
const replacementDislike = visibleVoteButton(page, "dislike");
|
||||
await replacementDislike.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0]);
|
||||
await expect(replacementDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
readReleased = true;
|
||||
}
|
||||
|
||||
expect(readReleased).toBe(true);
|
||||
const finalDislike = visibleVoteButton(page, "dislike");
|
||||
await expect(finalDislike).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("25");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(null);
|
||||
await page.waitForTimeout(200);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1, 0]);
|
||||
|
||||
await finalDislike.click();
|
||||
await expectCompletedHandshakes(harness.backend, [-1, 0, -1]);
|
||||
await expect(finalDislike).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator('[data-ryd-role="dislike"]:visible #text')).toHaveText("26");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
|
||||
test("fresh same-video count state supersedes stale hydration correction", async ({ context, page }) => {
|
||||
const harness = await launchDelayedShorts(
|
||||
{ context, page },
|
||||
{
|
||||
configureBackend: (backend) => {
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 25, likes: 100, rating: 4.5 } });
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 22, likes: 200, rating: 4.5 } });
|
||||
backend.enqueue("GET", "/votes", { body: { dislikes: 26, likes: 100, rating: 4.5 } });
|
||||
},
|
||||
nativeDislike: true,
|
||||
},
|
||||
);
|
||||
|
||||
const dislikeCount = page.locator('[data-ryd-role="dislike"]:visible #text');
|
||||
await expect(dislikeCount).toHaveText("25");
|
||||
try {
|
||||
await visibleVoteButton(page, "dislike").click();
|
||||
await expectSingleCompletedHandshake(harness.backend, { videoId: VIDEO_A, value: -1 });
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
|
||||
await page.evaluate((videoId) => window.__shortsFixture.recycleActiveRenderer(videoId), VIDEO_B);
|
||||
await expect(dislikeCount).toHaveText("22");
|
||||
|
||||
await page.evaluate(
|
||||
(videoId) => window.__shortsFixture.recycleActiveRenderer(videoId, { state: "disliked" }),
|
||||
VIDEO_A,
|
||||
);
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
expect(
|
||||
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_A),
|
||||
).toHaveLength(2);
|
||||
} finally {
|
||||
await releaseDelayedRead(page);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(250);
|
||||
await expect(dislikeCount).toHaveText("26");
|
||||
await expect(visibleVoteButton(page, "dislike")).toHaveAttribute("aria-pressed", "true");
|
||||
await expect.poll(() => readGmValue(page, SYNTHETIC_STATE_KEY)).toBe(true);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/vote").map((request) => request.body.value)).toEqual([-1]);
|
||||
expect(harness.backend.requestsFor("POST", "/interact/confirmVote")).toHaveLength(1);
|
||||
expect(harness.backend.requestsFor("GET", "/votes")).toHaveLength(3);
|
||||
expect(
|
||||
harness.backend.requestsFor("GET", "/votes").filter((request) => request.query.videoId === VIDEO_B),
|
||||
).toHaveLength(1);
|
||||
await expectNoRuntimeFailures(page, harness);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user