BikeControl (SwiftControl)
Use Zwift Click, Zwift Play, Shimano Di2, or any Bluetooth controller with MyWhoosh, Rouvy, Zwift, and more. Free to try on iOS, Android, Windows, and macOS.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기happen Jerusalem fan app
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
פרויקט קהילתי לבניית אפליקציית האוהדים החינמית הטובה ביותר למועדון הכדורסל הפועל ירושלים. ״יושב סופר את הדקות״, אפליקציית ווב (PWA) בעברית:README 전문 보기 ↗
פרויקט קהילתי לבניית אפליקציית האוהדים החינמית הטובה ביותר למועדון הכדורסל הפועל ירושלים.
״יושב סופר את הדקות״, אפליקציית ווב (PWA) בעברית:
עובדת גם בלי אינטרנט וניתנת להתקנה למסך הבית.
יש גם גרסת קובץ HTML בודד שמכילה הכול, עיצוב, קוד ונתונים, ונפתחת ישירות מהמכשיר בלי שרת ובלי אינטרנט. נוח לשליחה בוואטסאפ או במייל.
https://hapoel.site/hapoel-standalone.html (שמירה בשם / Save As)python scripts/build_standalone.pyהקובץ הוא צילום מצב: הנתונים בתוכו קפואים ליום הבנייה, ובראשו מוצג באנר שאומר זאת ומפנה לגרסה המתעדכנת.
עד 10.9.2026 לא הייתה באתר שום מדידה, ולכן על השאלה ״כמה אוהדים נכנסו״ לא הייתה תשובה: לא מספר גס, לא הערכה, כלום. מה שכן היה זה רצפה, ורק רצפה: טופס המשוב נפתח מעצמו רק אחרי שלוש פתיחות של האפליקציה, ארבעה מסכים שונים בביקור אחד ותשעים שניות בפנים, ולכן 20 תשובות אומרות שלפחות 20 מכשירים שונים עברו את הרף הזה. זה לא מספר כניסות והוא לא מתיימר להיות.
מאז מותקנת Vercel Web Analytics: הסקריפט מוגש מאותו שרת שמגיש את האתר, אין בקשה לצד שלישי, אין עוגיות ואין מזהה שנשמר. היא סופרת כניסות, עמודים, סוג מכשיר ומדינה, ולא מזהה מי נכנס.
שני סייגים שכדאי להכיר:
hapoel.site. הנתיב /_vercel/insights לא קיים ב־GitHub Pages, ולכן index.html בודק את שם המארח לפני הטעינה. בלי הבדיקה הזאת חצי מהאוהדים היו מקבלים 404 בכל פתיחה.הטופס נפתח בתוך האפליקציה, ונשלח מתוכה. חמש שאלות, ארבע מהן בלחיצה:
שום שאלה אינה חובה, אבל צריך לענות על אחת לפחות. לא מבקשים שם, טלפון או אימייל, ואין באפליקציה מעקב.
אין מה להגדיר, ואין טוקן לייצר. זה היה החסם: כתיבה לגיטהאב דורשת טוקן, וטוקן אפשר לייצר רק בדפדפן, ביד. אבל טוקן אחד כבר קיים ממילא, זה של GitHub Actions, ולכן הנתיב מפוצל לשניים:
api/feedback.js, הפונקציה שרצה ליד האתר, מחנה את התשובה בנושא ציבורי ב־ntfy. פרסום שם לא דורש חשבון ולא מפתח..github/workflows/feedback-drain.yml רץ כל שעה, אוסף את מה שממתין שם, ופותח כרטיס לכל תשובה, עם הטוקן שכבר יש ל־Actions.המספר שקובע את הקצב נמדד ולא הונח: ntfy שומר הודעה 12 שעות. הרצה כל שעה נותנת לכל תשובה תריסר הזדמנויות להיאסף, והרצה שהוחמצה לא עולה כלום, כי האיסוף תמיד סורק 12 שעות אחורה ומדלג על מזהים שכבר הפכו לכרטיס. המזהה נשמר בגוף הכרטיס (ntfy:<id>) ונקרא משם, אז אין קובץ מצב שאפשר לאבד.
מה זה עולה. הנושא ב־ntfy ציבורי, ושמו כתוב ב־api/feedback.js בריפו הפומבי. כלומר מי שקורא את הקוד יכול לקרוא שם תשובות בחלון של 12 שעות, וגם לפרסם לשם זבל. זו עסקה מודעת: התשובות ממילא נוחתות בכרטיסים פומביים, והחלופה הייתה טוקן שלא היה נוצר לעולם. האיסוף מצדו לא סומך על מה שמגיע: הוא בודק מבנה, חותך אורכים, זורק מה שאינו תשובה, ולא פותח יותר מ־20 כרטיסים בהרצה אחת.
מה האוהד רואה. הטופס לא מציג כתובת, לא פותח לשונית ולא מקשר לשום מקום, והוא אומר בפירוש שמה שנכתב נשמר בארגז הפתוח של הפרויקט ושלא כדאי לכתוב פרטים אישיים. אם הפרסום נכשל, ההודעה מפורשת, מה שנכתב נשאר על המסך, ואפשר לנסות שוב.
FEEDBACK_TOPIC (בוורסל) ו־FEEDBACK_TOPIC (ב־Actions) יכולים להחליף את שם הנושא בשני הצדדים, אם צריך להחליף אותו יום אחד.
הכי פשוט: docs/feedback/digest.md. דף אחד שמתעדכן מעצמו כל 12 שעות, ב־08:00 וב־20:00 שעון ישראל, ונקרא יפה גם מהטלפון. אותו טקסט בדיוק יושב גם בכרטיס אחד קבוע בשם ״סיכום משוב מאוהדים״, שנערך במקום ולא נפתח מחדש בכל פעם, כך שאפשר לעקוב אחריו כמו אחרי כל כרטיס.
מה יש שם: כמה תשובות הגיעו וכמה מהן חדשות מאז הסיכום הקודם, ציון השימושיות והאם הוא עלה או ירד, מה הכי יעזור לאוהדים מדורג, ואותו דירוג בחתך לפי סוג האוהד. החתך הזה הוא כל הסיבה שהטופס שואל מי עונה: בקשה שהיא שלישית בסך הכול יכולה להיות ראשונה אצל מנויים, וממוצע בין השניים לא משרת אף אחד מהם. בסוף הדף כל מה שנכתב, מילה במילה, באגים לחוד ורעיונות לחוד, עם סימון על מה שנוסף מאז הקריאה הקודמת.
.github/workflows/feedback-digest.yml מריץ קודם את האיסוף ורק אחר כך את הסיכום, כדי שהדף לא יפגר בתשובה אחת. scripts/feedback_digest.py כותב אותו. הוא לא נוגע בתשובות עצמן: הן נשארות בכרטיסים שלהן, והדף נגזר מהן.
התשובות הגולמיות עצמן: הכרטיסים עם התווית ״משוב״. כרטיס לכל תשובה, בדיוק כפי שנשלחה.
ואם רוצים את הדוח המלא עכשיו ולא בעוד שש שעות:
Actions → דוח משוב מאוהדים (ידני) → Run workflow
scripts/feedback_report.py מדפיס אותו ללוג. קורא בלבד.
ביקור חוזר הוא לא אותו דבר כמו לראות את האפליקציה. לשאול מישהו מה דעתו אחרי שתי שניות במסך הבית זה לשאול מה דעתו על ספר שלא פתח, התשובה חסרת ערך, וההפרעה המוקדמת היא בדיוק הסיבה שאנשים סוגרים דברים.
לכן שלושה תנאים, וכולם חייבים להתקיים יחד:
| שדה | ברירת מחדל | מה הוא מודד |
|---|---|---|
afterVisits | 3 | כמה פעמים נפתחה האפליקציה (נספר פעם אחת לכל טעינה) |
afterScreens | 4 | כמה מסכים שונים נראו בביקור הזה, לחזור פעמיים בין בית למשחקים אינו סיור |
afterSeconds | 90 | כמה שניות בפנים בביקור הזה |
אף אחד מהם לא מתקיים מישיבה בטלה, וזו הכוונה. בזמן משחק חי לא שואלים בכלל, מי שעוקב אחרי תוצאה זזה לא עונה על שאלון. ״לא עכשיו״ דוחה ב־snoozeDays ימים (כרגע 30).
בנוסף לחלון הקופץ יש כרטיס קבוע בעמוד הבית ולינק בתחתית, כך שמי שרוצה להגיד משהו לא צריך לחכות לשום תנאי.
בעמוד הבית ובמסך #/news מוצגות הכותרות האחרונות על הקבוצה מאתרי הספורט. כותרות בלבד: הכותרת, מי פרסם אותה ומתי, לחיצה פותחת את הכתבה באתר שפרסם אותה, ושם היא נקראת. הטקסט שייך לאתר שכתב אותו, והאפליקציה מפנה אליו ולא מעתיקה אותו.
הבדיקה מול המקורות העלתה שאין ברירה אחרת: ה־RSS של ספורט 5, ONE, mako וכלכליסט מחזיר 404/403, של וואלה 500, ואתר המועדון מחזיר ״אופס! תקלה״ ל־/feed, ל־/news ול־wp-json. הפיד של ynet חי אבל הוא ספורט כללי, במדגם של 30 פריטים לא היה אף אחד עלינו. חדשות Google מאנדקסת את כולם ומחזירה 100 פריטים לשאילתה, כל אחד עם שם המקור ותאריך, ולכן היא הצינור.
ההגדרות ב־app/data/news-sources.json. נערך ידנית, האיסוף קורא ולא כותב:
queries: שאילתות החיפוש. שתיהן מצמידות ״כדורסל״ לשם המועדון, כי ״הפועל ירושלים״ היא גם קבוצת כדורגל.names: שם המקור כפי שיוצג לאוהד, לפי הדומיין (sport5.co.il ← ״ספורט 5״).block: מקור שלא רוצים לראות.maxItems (24) ו־maxAgeDays (45): כמה כותרות נשמרות ועד איזה גיל.הסינון הוא החלק העדין. פריט נשמר רק אם המועדון מופיע בכותרת עצמה, חיפוש על השם לבדו החזיר גם כתבות על מכבי תל אביב שרק הזכירו אותנו בגוף הטקסט, ונפסל אם הכותרת נקראת ככדורגל (״כדורגל״, ״ביתר״ וכדומה). כשהאיסוף לא מוצא כלום הוא נכשל בכוונה, והפיד הקודם נשאר במקומו במקום להתרוקן.
לחסום כותרת מהטלפון, בלי לחכות לשינוי קוד. ב־Actions יש פעולה ידנית בשם חסימת כותרת מהמדור: כותבים לה מילים מופרדות בפסיק (למשל פוקסמן, בסמטה), והיא מוסיפה אותן ל־blockPhrases, מנקה מיד את news.json החי ודוחפת. הדחיפה ל־app/data מפעילה בעצמה את פרסום האתר, אז הכותרת יורדת מהאתר תוך דקות ולא מחכה לאיסוף הבא. הלוג מדפיס בדיוק אילו כותרות ירדו, כך שביטוי רחב מדי נתפס מיד. לביטול: מוחקים את השורה מהקובץ, והכותרות חוזרות באיסוף הבא. אותו דבר משורת הפקודה: python scripts/block_headline.py "פוקסמן".
כשהסינון לא יכול לדעת. ״הפועל ירושלים״ הוא גם מועדון כדורגל, ויש סיפורים ששום כלל אוטומטי לא יסווג נכון כי הם מדברים על המועדון בלי שום רמז לענף. לכן ב־app/data/news-sources.json יש blockPhrases, רשימה ידנית של ביטויים שנחסמים בכותרת. מה שיש בה עכשיו הוא סיפור התקיפה של אוהדים באוגוסט 2026, שהתברר כשייך למועדון הכדורגל. החסימה שם רחבה מהסיפור עצמו, כלומר גם תקיפה של אוהדי כדורסל בעתיד תיחסם, וזו החלטה מודעת: אם זה יקרה, מורידים שורה מהקובץ.
רשימת מועדוני הכדורגל מוצלבת מול הנתונים. SOCCER_CLUBS ב־scripts/news_feed.py חוסם כותרת שמזכירה אותנו לצד מועדון כדורגל, וזה הכלל היחיד שתופס כותרת כמו ״מדמון ייעדר מול הפועל פ״ת״. כדי שהרשימה לא תישען על זיכרון של מי שכתב אותה, היא מוצלבת לפני כל שימוש מול טבלת ליגת ווינר והיריבות שלנו העונה: כל שם שנמצא ביקום הכדורסל שלנו יורד ממנה ונרשם בלוג. מועדון שיעלה לליגת הכדורסל יפסיק להיחסם מעצמו.
תפקיד הוא ראיה לענף. אצלנו כותבים רכז, קלע, כנף, פורוורד וסנטר, ואף פעם לא חלוץ, קיצוני או בלם. הכותרת ״קיצוני ממיטיולן סיכם בהפועל ירושלים״ (1.9.2026) עברה את כל הכללים הקודמים כי אין בה שום מילה מהכדורגל חוץ מהתפקיד, והיריבה בה היא מועדון דני שלעולם לא יופיע ברשימת המועדונים הישראליים. אותו סיפור פורסם אחר כך שוב, הפעם כ״חלוץ ממיטיולן״, ונכנס שוב. התפקידים כתובים כביטוי רגולרי ולא כרשימת מילים, כי ״קיצוני״ הוא גם שם תואר תמים (״שינוי קיצוני״) ונחשב תפקיד רק כשבא אחריו מקור או שייכות, ו״כבש״ הוא גם כיבוש (״כבשה את אירופה״) ונחשב שער רק כשלא בא אחריו ״את״.
כתיב. ״הפועל פתח תקוה״ בוו אחת ו״הפועל פתח תקווה״ בשתיים הן אותה קבוצה, וההבדל הזה לבדו החזיר לנו כותרת כדורגל מלאה. לכן ההשוואה מנרמלת את שני הצדדים, הכותרת והרשימה: גרשיים יורדים ווו כפולה מתכווצת, כך שפ״ת, פ"ת ופת הם אותו דבר.
בדיקה לפני שינוי. scripts/news_filter_test.py מריץ את הסינון על שתי רשימות: כותרות כדורגל אמיתיות שהגיעו מהפיד והאוהד ראה על המסך, וכותרות כדורסל שחייבות להמשיך לעבור, ובהן המקרים שנועדו לשבור כל כלל חדש. כלל חדש חוסם בשקט, וכותרת שנעלמה לא משאירה סימן, ולכן כל תוספת לסינון עוברת דרך הקובץ הזה:
python scripts/news_filter_test.py
הוא גם מציין כותרות שכבר יושבות ב־news.json ולא היו עוברות היום. הן יורדות באיסוף הבא ממילא.
לכל משחק קרוב מופיע צ׳יפ עם הערוץ, כשיש ערוץ. לא כל משחק מקבל אחד, וזה לא באג.
אתר המועדון הוא המקור היחיד. נמדד ב־11.9.2026: אתר הליגה לא מחזיק עמודת שידור בכלל, ועמודי לוח השידורים של ספורט 5 ושל ONE מחזירים 404. לכן אין כאן מקור שני להצליב מולו, ומה שאפשר לעשות הוא לוודא שהחילוץ עצמו לא משקר.
השדה יושב באותו סלקטור של המקום, אחריו, ולכן .game-type .container .text נקרא כרשימה ולא ב־select_one. החילוץ הוא לפי תוכן ולא לפי מיקום: תא שהוא ״שידור טרם נקבע״ או שם ערוץ מוכר הוא שידור, והראשון שאינו כזה הוא המקום. חילוץ לפי מיקום היה נשבר על כרטיס שיש בו שידור ואין בו מקום, ואז שם הערוץ היה מוצג לאוהד ככתובת האולם.
שלושה מצבים ולא שניים. ערוץ; ״שידור טרם נקבע״, שהיא אמירה של המועדון; והיעדר מוחלט של השדה, שאומר רק שאיננו יודעים. על פני 42 כרטיסים השדה הופיע שלוש פעמים בלבד, ולכן האפליקציה לעולם לא כותבת ״לא משודר״: היא שותקת.
ושתי מלכודות שכבר נפלנו בהן, ושמורות ב־scripts/broadcast_test.py:
venue-names.json, ומשחק ביתי בירושלים הוצג כ״בית מחוץ לישראל״. ההשוואה מתעלמת עכשיו מסיומת העיר שאחרי הפסיק, והבדיקה מוודאת ששני הקבצים מסכימים.ומה לגבי ערוץ היוטיוב של המועדון. זו שאלה שחוזרת על כל משחק חוץ שלא משודר, ולכן התשובה כתובה כאן ולא נבדקת מחדש בכל פעם. הערוץ הוא UCM2ng9CAAebh7NIK_CmcsxA, ואפשר לקרוא אותו בלי מפתח דרך https://www.youtube.com/feeds/videos.xml?channel_id=.... נבדק ב־11.9.2026: שנים־עשר הסרטונים האחרונים, אחורה עד 13.7, הם תקצירים וקטעי מועדון, בלי אף שידור חי ובלי אף משחק מלא. הקרוב ביותר הוא ״סיכום משחק ההכנה נגד העמק״, שעלה יומיים אחרי המשחק. כלומר הערוץ הוא ארכיון ולא אמצעי צפייה.
ומשחק שכבר הגיע ממקור רשמי לא מפספס את השידור. מיזוג לוח המועדון מוסיף רק משחקים חדשים לפי תאריך, ולכן משחק שהליגה כבר נתנה נזרק כולו ואיתו השידור. enrich_from_club משלים על משחקים קיימים את השידור ואת המקום כשהוא חסר, ולא נוגע בשעה ובתוצאה שמגיעות מהמקור הרשמי ומדויקות יותר.
אחרי משחק שהסתיים, שורת התוצאה ברשימה וכרטיס ״התוצאה האחרונה״ בעמוד הבית מציגים כפתור טופס המשחק, שפותח מסך משלו ב־#/game/<id>: התוצאה והרבעים, דיווח קצר על מה שקרה, מי הוביל אצלנו, טבלת סטטיסטיקה מלאה לשתי הקבוצות, השוואה בין הקבוצות וקישור לסיקור.
הנתונים נכתבים ביד ואין מאחוריהם איסוף. אף מקור חינמי לא מפרסם טופס משחק מלא, ולכן app/data/boxscores.json מוזן מטופס המשחק הרשמי, אותו דף שמופק בסיום המשחק. הטופס הראשון בקובץ הוא המשחק מול הפועל חולון ב־31.8.2026.
כל מספר מאומת לפני שהוא מגיע למסך. scripts/boxscore_test.py מריץ את ההעתקה מול הסכומים שמודפסים באותו טופס:
python scripts/boxscore_test.py
הבדיקה סוגרת כל עמודה (קליעה, ריבאונדים, אסיסטים, איבודים, חטיפות, חסימות, עבירות, נקודות ומדד), ומוסיפה הצלבות שקשה לזייף בטעות: הנקודות של כל שחקן מול הקליעה שלו, סכום הרבעים מול התוצאה, נקודות הספסל מול המודפס, וגם שהתוצאה בלוח המשחקים מסכימה עם הטופס ולא הפוכה. הסכומים המודפסים יושבים בסקריפט ולא בקובץ הנתונים, כי הם עדות נפרדת: מי שהפיק את הטופס כתב אותם, ואנחנו רק מוודאים שההעתקה שלנו מסכימה איתם.
תוצאה ידנית לא נמחקת. משחקי הכנה הם המקרה שבו אף מקור חינמי לא מפרסם תוצאה, אבל אתר המועדון כן מפרסם את המשחק עצמו. כל עוד הוא מופיע שם, האיסוף היה דורס את התוצאה שהוזנה מטופס המשחק וכותב שוב scheduled בלי מספרים. עכשיו הכיוון הפוך: רשומה ב־app/data/friendlies.json שיש בה תוצאה מנצחת מקור שאין לו, לפי מזהה המשחק. מקור שכן פרסם תוצאה ממשיך לנצח, כי הוא הרי יודע יותר.
שמות. שחקן שלנו מוצג בשם העברי מהסגל המאומת ומקושר לעמוד השחקן שלו. מי שאין לו שם מאומת אצלנו, כולל כל שחקני היריבה, מוצג בדיוק כפי שהוא כתוב בטופס: לא ממציאים לאדם אמיתי איות בעברית.
הטבלה נגררת, העמוד לא. שש עשרה עמודות לא נכנסות לרוחב של טלפון, ולכן הגלילה האופקית חיה בתוך המסגרת של הטבלה בלבד, ועמודת השם נשארת מוצמדת כדי שתמיד יהיה ברור של מי השורה. כל תא מספרי עטוף ב־direction: ltr, אחרת ״5/6״ מתהפך ל־6/5 בדיוק כמו שתוצאה מתהפכת.
בעמוד הבית ובמסך #/podcasts מוצגים הפרקים האחרונים מפודקאסטים על הפועל ועל הכדורסל הישראלי. במסך יש שתי לשוניות, ״על הפועל״ ו״כדורסל ישראלי״, כי אוהד שמחפש את האחת לא מחפש את השנייה. השיוך נקבע בשדה about של כל תוכנית. אותה אתיקה של מדור החדשות, ומאותה סיבה: כותרות בלבד, שם הפרק, שם התוכנית, כמה הוא אורך ומתי יצא. לחיצה פותחת את הפרק אצל מי שהקליט אותו. אין נגן מוטמע, אין תיאור פרק, ואין העתקה של שום דבר מעבר לשם.
למה הקונפיג לא מחזיק כתובות RSS. תוכנית עוברת מארח בלי להודיע לאף אחד, ופודקאסטים ישראליים יושבים על Acast, Buzzsprout, Omny ו־Spotify for Creators. כתובת פיד קבועה בקובץ היא כתובת שתמות בשקט ביום שמישהו יעביר את התוכנית. לכן ב־app/data/podcast-sources.json רשום המזהה של התוכנית בחנות של אפל, והאיסוף פותר ממנו את כתובת הפיד בכל ריצה. נערך ידנית, האיסוף קורא ולא כותב:
appleId: המזהה בחנות. מספר יציב, לא זז כשהתוכנית עוברת מארח.expect: קטע משם התוכנית. אם השם שחזר מהחנות לא מכיל אותו, התוכנית מדולגת ונרשמת שורה בלוג. זו הבדיקה שמונעת מהמזהה השגוי להכניס לאפליקציה תוכנית אחרת לגמרי, כי מזהה מוטעה מחזיר תוכנית אמיתית, פשוט לא את זאת שרצינו.search: חלופה לתוכנית שאין לה מזהה. האיסוף מחפש את הביטוי בחנות ולוקח את ההתאמה הראשונה שעוברת את expect.name: שם קצר להצגה, לתוכנית שכותרתה בחנות נושאת גם תיאור (״ספיק נ׳ רול - פודקאסט כדורסל ישראלי״ ← ״ספיק נ׳ רול״). בלעדיו מוצג השם מהחנות.about: באיזו לשונית התוכנית מופיעה. team לפודקאסט של אוהדי הפועל, league לפודקאסט על הכדורסל הישראלי כולו.maxPerShow (4), maxItems (20) ו־maxAgeDays (120): כמה פרקים לתוכנית, כמה בסך הכול, ועד איזה גיל.מה חדש. האפליקציה שומרת ב־localStorage את זמן הפרק החדש ביותר שהאוהד ראה, ומסמנת ״חדש״ על כל פרק שיצא מאז. הסימון נמחק כשנכנסים למסך הפודקאסטים, לא כשרואים את הבית, כך ש״חדש״ אומר ״לא פתחת״ ולא ״לא גללת״. בביקור הראשון אין מה להשוות אליו, והשעון פשוט מתחיל מעכשיו במקום לצבוע את כל הרשימה. בנוסף, חזרה לאפליקציה אחרי שהיא הייתה ברקע מושכת את הרשימה מחדש, כדי שאוהד שמשאיר אותה פתוחה על הטלפון יראה מה יצא בינתיים בלי לרענן ביד.
תוכנית שהמזהה שלה שגוי או שהפיד שלה מת מדולגת ונרשמת בלוג של Actions, ושאר התוכניות ממשיכות. כשאף תוכנית לא החזירה פרק האיסוף נכשל בכוונה, והקובץ הקודם נשאר במקומו במקום להתרוקן.
שני דברים שנועדו לצאת מהאפליקציה החוצה, לקבוצת הוואטסאפ.
כרטיס תוצאה. אחרי משחק שהסתיים אפשר לשתף תמונה: הסמל, התוצאה, ניצחון או הפסד, מול מי, התחרות, בית או חוץ, התאריך והכתובת. ״הייתי שם״ מופיע רק אם האוהד סימן נוכחות ביומן. הכפתור נמצא בכרטיס ״התוצאה האחרונה״ בבית ובכל שורה בלשונית התוצאות.
ניחוש. בכרטיס המשחק הבא יש שתי משבצות. הצד שלנו מימין, והוא מסומן בסמל המועדון בלבד ולא במילה, ומשמאל שם היריבה של המשחק הקרוב. כך ברור איזה מספר שייך למי בלי לקרוא כיתוב, וזה גם הכיוון שבו אוהד קורא. בשאר האפליקציה תוצאה נכתבת מימין לשמאל הפוך (direction: ltr) כי אין שם מה שיסמן צד, וכאן הסמל והשם עושים את זה במקום סדר המספרים. משבצת ריקה נראית כמו משהו שממתין למילוי: מסגרת מקווקוות בצבע המועדון, רקע בהיר ו־00 דהוי, וברגע שיש בה מספר היא נסגרת למסגרת מלאה. הקשה על משבצת מסמנת את מה שכבר בתוכה כדי שהקלדה תחליף אותו, אנטר במשבצת הראשונה קופץ לשנייה ובשנייה שומר, וחיצי המספרים של הדפדפן מוסתרים כי הם רק מקטינים את שטח ההקשה. הניחוש נשמר על המכשיר בלבד ב־localStorage תחת hapoel-picks-v1, בדיוק כמו יומן האוהד. אחרי השמירה אפשר לשתף כרטיס ניחוש (״ומה אתם אומרים?״), ובשורת המשחק מופיע ״ניחשת 88-84״. אחרי שהמשחק נגמר הצ׳יפ אומר גם כמה קלעת: ״בול!״ לתוצאה מדויקת, ״צדקת במנצחת״ אם רק הצד נכון.
אין שרת ואין טבלת מובילים. התמונות נוצרות ב־canvas במכשיר עצמו, והסמל כבר שמור במטמון, ולכן הכל עובד גם בלי רשת. השיתוף עובר דרך navigator.share עם קובץ, ואיפה שאין תמיכה הקובץ פשוט יורד.
מלכודת ה־bidi חוזרת גם בקנבס. ציור התוצאה כשההקשר rtl הופך את סדר המספרים, וכרטיס הראה 78-92 במקום 92-78. באפליקציה זה פתור ב־CSS (direction: ltr על .score), ובקנבס צריך ctx.direction = "ltr" סביב כל מספר. כל טקסט חדש עם מספרים בכרטיס חייב לעבור את אותה בדיקה, ובעין ולא רק בקוד.
במסך #/press יש רשימה של אנשי התקשורת שכותבים על הפועל ועל הכדורסל הישראלי, ולצידה חשבונות רשמיים. ליד כל שם יש כפתור ״הציוצים״, ולחיצה עליו טוענת את הציוצים האחרונים של אותו חשבון. עד שלוחצים, שום דבר לא נטען מ־X.
למה זה בנוי אחרת מהחדשות ומהפודקאסטים. לשני המדורים ההם יש קובץ שנאסף מראש, ולמדור הזה אין, כי אין מאיפה לאסוף. ה־API של X עולה כסף, וכל הדרכים הציבוריות נבדקו ב־27.8.2026 מריצת Actions ולא נוחשו: nitter.net מחזיר 410, שתי מראות אחרות לא עונות בכלל, rsshub מחזיר 404, נקודת ה־CDN של X מחזירה גוף ריק, ונקודת ה־syndication מחזירה 429. היחיד שעונה הוא publish.twitter.com/oembed, והוא מחזיר תגית הטמעה בלי שום תוכן. לכן הציוצים נטענים בדפדפן של האוהד, מההטמעה הרשמית של X, ורק לפי בקשה.
מה קורה כשזה לא נטען. אצל מי שיש לו חוסם פרסומות, או שהרשת שלו חוסמת את X, ההטמעה לא תעלה. במקרה כזה הכרטיס לא נשאר ריק: מופיע משפט שאומר מה קרה, ולצידו קישור לפרופיל עצמו. אותו דבר קורה כשאין רשת בכלל, ואז אפילו לא נעשה ניסיון.
מי נכנס לרשימה. app/data/press.json נערך ידנית. כל ידית שם אומתה מול X לפני שנכנסה: publish.twitter.com/oembed מחזיר 200 לחשבון קיים ו־404 לחשבון שאינו קיים (נבדק גם מול ידית מומצאת, כדי לוודא שהבדיקה בכלל מבחינה), והשם לצד הידית הוצלב מול תוצאת חיפוש. ידית שלא אומתה לא נכנסת, כי חשבון שמיוחס לאדם הלא נכון הוא בדיוק סוג הנתון שהפרויקט הזה לא ממציא.
name: השם שמוצג לאוהד.handle: הידית ב־X, בלי @.kind: press לאיש תקשורת, official לחשבון רשמי. זה מה שקובע תחת איזו כותרת הוא מופיע.role: שורה אחת קצרה שאומרת מי זה.חשבון הכדורגל של הפועל ירושלים לא נמצא ברשימה בכוונה, והדבר כתוב גם בקובץ עצמו: האפליקציה היא של מועדון הכדורסל, ושני המועדונים חולקים את השם.
הסמל באפליקציה הוא הסמל של המועדון, מתוך app/icons/crest.png. זהו מיזם אוהדים לא רשמי, וכתוב כך בראש כל מסך; הסימן שייך להפועל ירושלים.
מ־crest.png נגזרת כל ערכת האייקונים בהרצה אחת:
python scripts/make_icons.py
הסקריפט חותך את השוליים השקופים, ממרכז את הסמל על רקע לבן, ומותח מתחתיו באנר שחור עם שם האפליקציה. הרקע לבן ולא שחור מפני שהטבעת החיצונית של הסמל כמעט שחורה, על אריח כהה הקצה שלו היה נעלם. גרסת ה־maskable כולה, באנר כלול, מצטמצמת פנימה, כי אנדרואיד חותך עד 20 אחוז מכל צד.
למה הבאנר. בתוך האפליקציה הסמל אף פעם לא מופיע לבד: השם צמוד אליו בסרגל העליון. מחוץ לאפליקציה הוא כן הופיע לבד, ושם זה בדיוק הבעיה, כי לאריח במסך הבית, למסך הפתיחה ולכרטיס בוואטסאפ אין טקסט מסביב, וסמל עירום נקרא כמו האפליקציה הרשמית של המועדון. לכן כל אייקון נושא עליו את שם האפליקציה, וגם og.png, התמונה שנפתחת כשמדביקים את הקישור, היא כרטיס עם השם והמשפט ״מיזם אוהדים לא רשמי״ ולא הסמל לבדו.
עברית ב־Pillow: אם הספרייה נבנתה עם Raqm היא מסדרת את הטקסט בעצמה; בלעדיה צריך להפוך את המחרוזת ביד. הסקריפט בודק מה מהשניים ומתנהג בהתאם, כי טעות בכיוון הזה הופכת את כל הבאנר.
הסמל החדש, אוגוסט 2026. המועדון החליף סמל: מהעיגול הלבן עם ״HAPOEL JERUSALEM B.C.״ לסמל האליפטי, טבעת שחורה עם HAPOEL ו־JERUSALEM, פנים אדום, שני אריות והפועל הרים את הפטיש. הקובץ באפליקציה הוחלף בעקבות זה.
הבדיקה לפני ההחלפה לא הייתה ״נראה נכון״. הקובץ הראשון שנמצא ישב בתיקיית הלוגואים של אתר הליגה ונקרא ״ChatGPT Image Aug 23, 2026״, כלומר מישהו ייצר אותו במחולל תמונות ביום ההשקה, וזה לא מספיק כדי להכניס סמל של מועדון אמיתי לאפליקציה. מה שסגר את השאלה הוא שאתר המועדון עצמו, hapoel.co.il, מגיש בדיוק את הקובץ הזה בשלושה מקומות בעמוד הבית: בסרגל העליון, ליד שם הקבוצה בלוח המשחקים ובפוטר, עם alt=״הפועל ״מידטאון״ ירושלים״. שם הקובץ הוא איך שמנהל התוכן קרא לו, לא ראיה לגבי מי מפרסם אותו.
על הרזולוציה: קובץ המקור הוא 300×300, וזה הגדול ביותר שנמצא. סריקה של כל התמונות בעמודים של המועדון, לפי ה־alt שהאתר עצמו נותן להן ולא לפי הגודל, החזירה שלוש תמונות שמסומנות כשלנו: הסמל ב־300×300, באנר ליגה ב־135×41 ולוגו ספונסר ב־138×72. אין גרסה גדולה יותר ואין וקטור פומבי. לכן ה־192 מוקטן מ־300 ונשאר חד, וה־512 מוגדל פי 1.7, פחות מהפי 3.2 שהיה קודם. בפועל אנדרואיד משתמש ב־192 במסך הבית, וה־512 משמש למסך פתיחה ולרשימות, שם הוא ממילא מוצג קטן בהרבה. אם יימצא קובץ גדול יותר, מחליפים את crest.png ומריצים שוב.
חשוב לזכור: הכלל ״הכי גדולה מבין המועמדות״ הוא זה שפעם בחר את הסמל של הפועל חולון, שמופיע באתר שלנו כי הוא היריבה במשחק הקרוב. הזיהוי חייב להיות לפי מה שהאתר אומר על התמונה, לא לפי כמה בתים יש בה.
app/: האפליקציה עצמה (HTML/CSS/JS, ללא תלות בכלים חיצוניים). הנתונים בקבצי JSON תחת app/data/.
api/feedback.js: הפונקציה שקולטת את טופס המשוב ומחנה אותו. רצה בוורסל ליד האפליקציה; ראו ״טופס המשוב״ למעלה.
scripts/feedback_drain.py: אוסף כל שעה את התשובות הממתינות ופותח מהן כרטיסים. רץ ב־Actions, כי שם יש טוקן.
scripts/feedback_digest.py: כותב כל 12 שעות את docs/feedback/digest.md ומעדכן את כרטיס הסיכום. גוזר מהתשובות ולא נוגע בהן. ראו ״לקרוא את התשובות״ למעלה.
scripts/feedback_report.py: מדפיס את אותו דוח ללוג לפי דרישה, למי שרוצה אותו עכשיו. קורא בלבד.
scripts/update_data.py: אוסף הנתונים האוטומטי. לוח משחקים וטבלה מאתר הליגה, סגל וטבלת בתים מהפיד של היורוקאפ. הדגל --only news,podcasts אוסף רק חלק מהמקורות, וזה מה שמאפשר את המעבר הקל בלולאה שלמטה.
scripts/update_live.py: סוקר תוצאה חיה כל חמש דקות בשעות שבהן משחקים, ומפרסם live.json ישירות לאתר. בערב שקט הוא יוצא תוך שנייה ולא כותב כלום. למשחקי הליגה אין הזנה חיה, ואז מוצג ״המשחק מתנהל״ בלי תוצאה.
: כלי אבחון ידני שמדפיס את מבנה המקורות, כדי לכתוב פרסרים מול נתונים אמיתיים ולא מהניחוש.
מה שנמדד. בין 30.8 ל־2.9.2026 נורו 5 סלוטים מתוך 12 מבוקשים, כלומר כשליש, והפער הגדול ביותר בין שני איסופים היה 18 שעות. גם השומר לא עזר: הוא נבנה על ההנחה ששני תזמונים נפרדים ייפלו באופן בלתי תלוי, ובפועל בכל חמש הריצות שלו הוא נורה 8 עד 10 דקות אחרי איסוף מוצלח, מצא נתונים טריים ולא עשה כלום, ובפערים האמיתיים לא נורה בכלל. גיטהאב יורה את שני התזמונים מאותו רגע עיבוד תור.
מה שנעשה. אותו פתרון שכבר הוכיח את עצמו בפולר של התוצאה החיה: הריצה מחזיקה את עצמה. סלוט שנורה נשאר חי כחמש שעות וחצי ודוגם בלולאה, כך שסלוט אחד מספיק לחצי יום. שני סלוטים שנורים ברצף לא מתבזבזים, כי ה־concurrency מעמיד את השני בתור והוא מתחיל בדיוק כשהראשון מסיים.
ובתוך הלולאה יש שלושה קצבים, וזו הנקודה.
games.json מהדיסק ושואלת את גיטהאב אם הפולר כבר עובד, ולכן היא לא עולה כלום. אם היא הייתה מתפזרת יחד עם האיסוף, שעת הקפיצה הייתה נופלת בין שתי בדיקות והפולר היה מתחיל באמצע המשחק. זה בדיוק הכשל שנמדד ב־8.9, אז בחלון של הפולר עצמו.ומה שמחזיק את ״פעמיים ביום״ בפועל. המרווח נמדד מול הזמן שכתוב ב־meta.json שכבר נדחף, ולא מתחילת המשמרת. זה נראה כמו פרט, והוא לא: התזמון מבקש שבעה סלוטים ביום, ומשמרת חדשה שהייתה אוספת מיד עם הפעלתה הייתה הופכת ״פעמיים ביום״ לשבע פעמים, בלי שאף ריצה תיכשל ובלי שום סימן.
והמגבלה שקובעת את הקצב: ורסל. כל דחיפה ל־main היא דיפלוי, ובתוכנית החינמית יש תקרה של 100 דיפלויים ביום. ב־4.9.2026 נמדדו 148 דחיפות ביממה, 95 מהפולר של התוצאה החיה ו־51 מהאיסוף, וורסל עצרה את הפרסום ל־24 שעות עם ״Deployment rate limited״. כלומר האתר קפא בגרסה ישנה בדיוק בגלל ניסיון לרענן אותו יותר. ב־8.9 נמדדו 89, עדיין קרוב מדי לתקרה ליום שיהיו בו שני משחקים.
הקצב היום, וכל מספר בו נבחר מול התקרה הזאת: האיסוף פעמיים ביום ביום רגוע, וכל שלוש שעות סביב משחק; הפולר כל חמש דקות (עד 42 במשחק); הדופק כל אחת עשרה שעות. יום רגוע סוגר על 2 דיפלויים, יום משחק על 47 במקרה הגרוע, ויום עם שני משחקים על 89. gh-pages אינו מוגבל, ולכן העותק שם ממשיך להתעדכן גם כשוורסל עוצרת.
המחיר משולם בשני מקומות, וכדאי להכיר אותו: מדור החדשות מתעדכן פעמיים ביום, כלומר כותרת יכולה להמתין עד אחת עשרה שעות, ובמשחק שיש לו הזנה חיה, כמו ביורוקאפ, התוצאה על המסך יכולה לפגר עד חמש דקות אחרי המגרש. למשחקי הליגה אין הזנה חיה ממילא. התוצאה עצמה לא משלמת את המחיר הזה, כי סביב משחק הקצב חוזר לשלוש שעות.
החשבון הזה הוא בדיקה, לא הערה. scripts/cadence_test.py קורא את המספרים משלושת הקבצים עצמם, מוודא שהם מסכימים זה עם זה, וסופר את הדיפלויים:
python scripts/cadence_test.py
הוא נכתב אחרי שאותה משפחת באגים הכתה פעמיים: פעם אחת כשחלון ההתעוררות של הפולר היה 20 דקות בזמן שהאיסוף דקר אותו פעם בשעה, ושתי הדקירות של 8.9 נפלו מחוץ לחלון בלי שאף ריצה תיכשל, ופעם שנייה כשהדחיפות חצו את התקרה של ורסל. שני הכשלים האלה הם חשבון בין קבצים נפרדים, ולכן אפשר לבדוק אותם. מי שמשנה מספר אחד רואה מיד את מי הוא שבר.
ובלי להציף את ההיסטוריה. meta.json נושא חותמת זמן שמשתנה בכל דגימה, ולכן דגימה שלא מצאה כלום עדיין ״משנה״ קובץ. קומיט נדחף רק כשיש שינוי תוכן אמיתי, ומעבר לזה נדחף דופק אחד כל אחת עשרה שעות כדי שגיל הנתונים באתר יישאר אמיתי ושאפשר יהיה למדוד שהריצה חיה.
השומר שואל עכשיו שאלה אחרת. לא ״מתי עודכן הקובץ״ אלא ״האם יש בכלל ריצת איסוף חיה״, כי ריצה חיה שקטה כשאין חדשות. הוא מזמן איסוף רק כששני התנאים מתקיימים יחד: אין ריצה חיה, וגם הנתונים ישנים ביותר משלוש עשרה שעות. השלוש עשרה הן הדופק של אחת עשרה ועוד שעתיים מרווח, וזו הסכמה בין שני קבצים ש־cadence_test.py שומר עליה.
האתר מתפרסם בוורסל תחת hapoel.site. vercel.json מגדיר את הפרסום: תיקיית app/ היא הפלט, sw.js לא נתפס במטמון (אחרת גרסה חדשה לא מגיעה לאוהדים), ל־JSON תוקף אפס ולתמונות ולאייקונים שבוע.
הכתובת עצמה כתובה בשני מקומות בלבד, ומעבר לאירוח אחר הוא שינוי שלהם:
app/data/club.json, שדה url: ממנו נבנית הודעת השיתוף בוואטסאפ והבאנר בעותק להורדה.app/index.html: כרטיס התצוגה המקדימה שנפתח כשמדביקים את הקישור בוואטסאפ. סורקים לא מריצים JavaScript, ולכן דווקא הכתובת הזאת לא יכולה להיקרא מ־club.json.התוצאה החיה מתפרסמת לשני היעדים. ורסל מגיש את main ו־GitHub Pages מגיש את gh-pages, ולכן live.yml כותב את live.json לשניהם. קובץ שנכתב רק לאחד מהם הוא תוצאה חיה שפשוט לא מופיעה בשני. הדחיפה ל־main לא מפעילה בנייה מחדש של האתר, deploy.yml מחריג את הנתיב הזה במפורש, אחרת כל עדכון תוצאה היה גורר בנייה מלאה כל חמש דקות.
GitHub Pages ממשיך לעבוד במקביל תחת behemot46.github.io/Hapoel. אפשר להשאיר את שניהם או לכבות את Pages בהגדרות הריפו.
מה זה לא מסתיר: הריפו עצמו ציבורי תחת חשבון הגיטהאב, וגם הקומיטים. הכתובת שנשלחת לאוהדים היא hapoel.site ואינה נושאת שם משתמש, אבל מי שיחפש את הפרויקט ימצא אותו. אם צריך הסתרה אמיתית, להפוך את הריפו לפרטי (ורסל עובד עם ריפו פרטי; GitHub Pages בחשבון חינמי לא).
בהגדרות הריפו: Settings → Pages → Source: Deploy from a branch → ענף gh-pages, תיקייה / (root).
תוך דקה האתר עולה גם לכתובת https://behemot46.github.io/Hapoel/, במקביל ל־hapoel.site. מכאן כל עדכון נתונים מפרסם את עצמו אוטומטית לשתי הכתובות.
scripts/probe_sources.pyscripts/news_feed.py: אוסף את כותרות החדשות וכותב app/data/news.json. רץ כחלק מהאיסוף האוטומטי, ראו ״מדור החדשות״ למעלה.
app/data/news-sources.json: הגדרות מדור החדשות. נערך ידנית.
scripts/podcast_feed.py: פותר את כתובות הפידים מהחנות של אפל, אוסף את הפרקים האחרונים וכותב app/data/podcasts.json. רץ כחלק מהאיסוף האוטומטי, ראו ״מדור הפודקאסטים״ למעלה.
app/data/podcast-sources.json: רשימת התוכניות. נערך ידנית.
app/data/player-names.json: שמות השחקנים בעברית. נערך ידנית, האיסוף האוטומטי לא נוגע בו.
app/data/player-profiles.json: פרופילי השחקנים ל״בואו נכיר״. נערך ידנית, עם קישור למקור לכל שחקן.
app/data/history.json: תארים, ציר זמן וכוכבי עבר. נערך ידנית, עם מקורות.
app/data/hall-of-fame.json: אולם התהילה. נערך ידנית, עם קישור למקור לכל שחקן. כלל ברזל: איפה שלא אימתנו מספר, כתוב ההישג ולא הסטטיסטיקה.
app/data/lastseason.json: צילום מצב של עונת 2025/26. עונה שהסתיימה לא משתנה, אז הועתקה פעם אחת מאתר הליגה.
app/data/season-stats.json: ממוצעי השחקנים בעונה הנוכחית, נכתב אוטומטית מהפיד של היורוקאפ.
app/data/feedback.json: הגדרות טופס המשוב. נערך ידנית, ראו ״טופס המשוב״ למעלה.
app/data/team-names.json: שמות קבוצות אירופיות בעברית. נערך ידנית.
app/data/player-overrides.json: תיקונים ידניים לנתוני שחקנים, למקרה שמקור אוטומטי חסר או טועה. נערך ידנית, האיסוף קורא ולא כותב. ערך שנכתב כאן גובר; ערך של שחקן שכבר לא בסגל מסומן בלוג כלא־בשימוש.
app/data/player-details.json: תאריכי לידה, חוזים, שכר וסטטיסטיקות מהעונה שעברה. נערך ידנית. שכר מוצג רק כשדווח בתקשורת, תמיד עם מקור ותמיד מסומן כדיווח ולא כנתון רשמי.
app/img/players/<slug>.jpg: תמונות שחקנים, מורדות לריפו מרשומת האדם של היורוקאפ (/v2/competitions/U/people/{code}) כדי שהאפליקציה תעבוד גם בלי אינטרנט ולא תפנה לשרת של צד שלישי מהמכשיר של האוהד. באפליקציה מוצג קרדיט למקור. שחקן בלי תמונה מוצג בראשי תיבות, קורה כשאין לו היסטוריה ביורוקאפ, או כשהפיד לא מחזיר לו מזהה. אפשר גם להניח קובץ ידנית; קובץ קיים לא נדרס ולא נמשך שוב.
לשחקן שאין לו היסטוריה ביורוקאפ אפשר לרשום כתובת תמונה ב־app/data/player-photo-sources.json, והאיסוף ימשוך משם. נערך ידנית, והכלל היחיד הוא שרושמים רק תמונה שמישהו פתח בעיניים וזיהה מי בה, תמונה של האדם הלא נכון ליד שם של שחקן גרועה מראשי תיבות. הכתובת צריכה להיות של קובץ התמונה עצמו: קישור לעמוד תוצאות של גוגל, לפוסט באינסטגרם או לעמוד שנבנה בדפדפן לא יעבוד.
התמונה נחתכת סביב הראש כבר בהורדה, כי מסגרת עגולה עם object-fit: cover על דיוקן 3:4 מרכזה על החזה. אין מזהה פנים, התמונות הן גזירה על רקע אחיד, ולכן ראש הצללית הוא ראש השחקן. להוספה ידנית: לשים קובץ בשם <slug>.jpg בתיקייה ולהריץ python scripts/crop_photos.py, שינרמל אותו (ויגיד בפירוש אם התמונה אינה גזירה ודורשת חיתוך ידני).
משיכת התמונות מוגבלת ל־4 שחקנים חדשים לריצה, ורצה אחרי כל שאר המקורות: ה־API מחזיר 429 לרצף בקשות, ובלי המגבלה הזו שלב התמונות היה חונק את איסוף הסטטיסטיקות.
.github/workflows/: פרסום האתר לחינם ב־GitHub Pages + עדכון הנתונים המתוזמן.
docs/spec/v1-spec.md: מסמך האפיון של גרסה 1. השאלון שממנו נולד: docs/codesign/questionnaire.html. הרעיון של יומן האוהד הגיע מלימוד של Futbology.